Coverage for polar/config.py: 93%

190 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 16:17 +0000

1import os 1ba

2from datetime import timedelta 1ba

3from enum import StrEnum 1ba

4from pathlib import Path 1ba

5from typing import Annotated, Literal 1ba

6 

7from annotated_types import Ge 1ba

8from pydantic import AfterValidator, DirectoryPath, Field, PostgresDsn 1ba

9from pydantic_settings import BaseSettings, SettingsConfigDict 1ba

10 

11from polar.kit.address import Address, CountryAlpha2 1ba

12from polar.kit.jwk import JWKSFile 1ba

13 

14 

15class Environment(StrEnum): 1ba

16 development = "development" 1ba

17 testing = "testing" 1ba

18 sandbox = "sandbox" 1ba

19 production = "production" 1ba

20 

21 

22class EmailSender(StrEnum): 1ba

23 logger = "logger" 1ba

24 resend = "resend" 1ba

25 

26 

27def _validate_email_renderer_binary_path(value: Path) -> Path: 1ba

28 if not value.exists() and not value.is_file(): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true1ba

29 raise ValueError( 

30 f""" 

31 The provided email renderer binary path {value} is not a valid file path 

32 or does not exist.\n 

33 If you're in local development, you should build the email renderer binary 

34 by running the following command:\n 

35 uv run task emails\n 

36 """ 

37 ) 

38 

39 return value 1ba

40 

41 

42env = Environment(os.getenv("POLAR_ENV", Environment.development)) 1ba

43env_file = ".env.testing" if env == Environment.testing else ".env" 1ba

44file_extension = ".exe" if os.name == "nt" else "" 1ba

45 

46 

47class Settings(BaseSettings): 1ba

48 ENV: Environment = Environment.development 1ba

49 SQLALCHEMY_DEBUG: bool = False 1ba

50 POSTHOG_DEBUG: bool = False 1ba

51 LOG_LEVEL: str = "DEBUG" 1ba

52 TESTING: bool = False 1ba

53 

54 WORKER_HEALTH_CHECK_INTERVAL: timedelta = timedelta(seconds=30) 1ba

55 WORKER_MAX_RETRIES: int = 20 1ba

56 WORKER_MIN_BACKOFF_MILLISECONDS: int = 2_000 1ba

57 

58 WEBHOOK_MAX_RETRIES: int = 10 1ba

59 WEBHOOK_EVENT_RETENTION_PERIOD: timedelta = timedelta(days=30) 1ba

60 WEBHOOK_FAILURE_THRESHOLD: int = 10 1ba

61 

62 CUSTOMER_METER_UPDATE_DEBOUNCE_MIN_THRESHOLD: timedelta = timedelta(seconds=5) 1ba

63 CUSTOMER_METER_UPDATE_DEBOUNCE_MAX_THRESHOLD: timedelta = timedelta(minutes=15) 1ba

64 

65 SECRET: str = "super secret jwt secret" 1ba

66 JWKS: JWKSFile = Field(default="./.jwks.json") 1ba

67 CURRENT_JWK_KID: str = "polar_dev" 1ba

68 WWW_AUTHENTICATE_REALM: str = "polar" 1ba

69 

70 # JSON list of accepted CORS origins 

71 CORS_ORIGINS: list[str] = [] 1ba

72 

73 ALLOWED_HOSTS: set[str] = {"127.0.0.1:3000", "localhost:3000"} 1ba

74 

75 # Base URL for the backend. Used by generate_external_url to 

76 # generate URLs to the backend accessible from the outside. 

77 BASE_URL: str = "http://127.0.0.1:8000" 1ba

78 BACKOFFICE_HOST: str | None = None 1ba

79 

80 # URL to frontend app. 

81 # Update to ngrok domain or similar in case you want 

82 # working Github badges in development. 

83 FRONTEND_BASE_URL: str = "http://127.0.0.1:3000" 1ba

84 FRONTEND_DEFAULT_RETURN_PATH: str = "/" 1ba

85 CHECKOUT_BASE_URL: str = ( 1ba

86 "http://127.0.0.1:8000/v1/checkout-links/{client_secret}/redirect" 

87 ) 

88 

89 # User session 

90 USER_SESSION_TTL: timedelta = timedelta(days=31) 1ba

91 USER_SESSION_COOKIE_KEY: str = "polar_session" 1ba

92 USER_SESSION_COOKIE_DOMAIN: str = "127.0.0.1" 1ba

93 

94 # Customer session 

95 CUSTOMER_SESSION_TTL: timedelta = timedelta(hours=1) 1ba

96 CUSTOMER_SESSION_CODE_TTL: timedelta = timedelta(minutes=30) 1ba

97 CUSTOMER_SESSION_CODE_LENGTH: int = 6 1ba

98 

99 # Impersonation session 

100 IMPERSONATION_COOKIE_KEY: str = "polar_original_session" 1ba

101 IMPERSONATION_INDICATOR_COOKIE_KEY: str = "polar_is_impersonating" 1ba

102 

103 # Login code 

104 LOGIN_CODE_TTL_SECONDS: int = 60 * 30 # 30 minutes 1ba

105 LOGIN_CODE_LENGTH: int = 6 1ba

106 

107 # Email verification 

108 EMAIL_VERIFICATION_TTL_SECONDS: int = 60 * 30 # 30 minutes 1ba

109 

110 # Checkout 

111 CUSTOM_PRICE_PRESET_FALLBACK: Annotated[int, Ge(50)] = 10_00 1ba

112 CHECKOUT_TTL_SECONDS: int = 60 * 60 # 1 hour 1ba

113 IP_GEOLOCATION_DATABASE_DIRECTORY_PATH: DirectoryPath = Path(__file__).parent.parent 1ba

114 IP_GEOLOCATION_DATABASE_NAME: str = "ip-geolocation.mmdb" 1ba

115 USE_TEST_CLOCK: bool = False 1ba

116 

117 # Database 

118 POSTGRES_USER: str = "polar" 1ba

119 POSTGRES_PWD: str = "polar" 1ba

120 POSTGRES_HOST: str = "127.0.0.1" 1ba

121 POSTGRES_PORT: int = 5432 1ba

122 POSTGRES_DATABASE: str = "polar_development" 1ba

123 DATABASE_POOL_SIZE: int = 5 1ba

124 DATABASE_SYNC_POOL_SIZE: int = 1 # Specific pool size for sync connection: since we only use it in OAuth2 router, don't waste resources. 1ba

125 DATABASE_POOL_RECYCLE_SECONDS: int = 600 # 10 minutes 1ba

126 DATABASE_COMMAND_TIMEOUT_SECONDS: float = 30.0 1ba

127 DATABASE_STREAM_YIELD_PER: int = 100 1ba

128 

129 POSTGRES_READ_USER: str | None = None 1ba

130 POSTGRES_READ_PWD: str | None = None 1ba

131 POSTGRES_READ_HOST: str | None = None 1ba

132 POSTGRES_READ_PORT: int | None = None 1ba

133 POSTGRES_READ_DATABASE: str | None = None 1ba

134 

135 # Redis 

136 REDIS_HOST: str = "127.0.0.1" 1ba

137 REDIS_PORT: int = 6379 1ba

138 REDIS_DB: int = 0 1ba

139 

140 # Emails 

141 EMAIL_RENDERER_BINARY_PATH: Annotated[ 1ba

142 Path, AfterValidator(_validate_email_renderer_binary_path) 

143 ] = ( 

144 Path(__file__).parent.parent 

145 / "emails" 

146 / "bin" 

147 / f"react-email-pkg{file_extension}" 

148 ) 

149 EMAIL_SENDER: EmailSender = EmailSender.logger 1ba

150 RESEND_API_KEY: str = "" 1ba

151 RESEND_API_BASE_URL: str = "https://api.resend.com" 1ba

152 EMAIL_FROM_NAME: str = "Polar" 1ba

153 EMAIL_FROM_DOMAIN: str = "notifications.polar.sh" 1ba

154 EMAIL_FROM_LOCAL: str = "mail" 1ba

155 

156 # Github App 

157 GITHUB_CLIENT_ID: str = "" 1ba

158 GITHUB_CLIENT_SECRET: str = "" 1ba

159 

160 # GitHub App for repository benefits 

161 GITHUB_REPOSITORY_BENEFITS_APP_NAMESPACE: str = "" 1ba

162 GITHUB_REPOSITORY_BENEFITS_APP_IDENTIFIER: str = "" 1ba

163 GITHUB_REPOSITORY_BENEFITS_APP_PRIVATE_KEY: str = "" 1ba

164 GITHUB_REPOSITORY_BENEFITS_CLIENT_ID: str = "" 1ba

165 GITHUB_REPOSITORY_BENEFITS_CLIENT_SECRET: str = "" 1ba

166 

167 # Discord 

168 DISCORD_CLIENT_ID: str = "" 1ba

169 DISCORD_CLIENT_SECRET: str = "" 1ba

170 DISCORD_BOT_TOKEN: str = "" 1ba

171 DISCORD_BOT_PERMISSIONS: str = ( 1ba

172 "268435459" # Manage Roles, Kick Members, Create Instant Invite 

173 ) 

174 

175 # Google 

176 GOOGLE_CLIENT_ID: str = "" 1ba

177 GOOGLE_CLIENT_SECRET: str = "" 1ba

178 

179 # Apple 

180 APPLE_CLIENT_ID: str = "" 1ba

181 APPLE_TEAM_ID: str = "" 1ba

182 APPLE_KEY_ID: str = "" 1ba

183 APPLE_KEY_VALUE: str = "" 1ba

184 

185 # OpenAI 

186 OPENAI_API_KEY: str = "" 1ba

187 OPENAI_MODEL: str = "o4-mini-2025-04-16" 1ba

188 

189 # Stripe 

190 STRIPE_SECRET_KEY: str = "" 1ba

191 STRIPE_PUBLISHABLE_KEY: str = "" 1ba

192 # Stripe webhook secrets 

193 STRIPE_WEBHOOK_SECRET: str = "" 1ba

194 STRIPE_CONNECT_WEBHOOK_SECRET: str = "" 1ba

195 STRIPE_STATEMENT_DESCRIPTOR: str = "POLAR" 1ba

196 

197 # Open Collective 

198 OPEN_COLLECTIVE_PERSONAL_TOKEN: str | None = None 1ba

199 

200 # Sentry 

201 SENTRY_DSN: str | None = None 1ba

202 

203 # Discord 

204 FAVICON_URL: str = "https://raw.githubusercontent.com/polarsource/polar/2648cf7472b5128704a097cd1eb3ae5f1dd847e5/docs/docs/assets/favicon.png" 1ba

205 THUMBNAIL_URL: str = "https://raw.githubusercontent.com/polarsource/polar/4fd899222e200ca70982f437039f549b7a822ecc/clients/apps/web/public/email-logo-dark.png" 1ba

206 

207 # Posthog 

208 POSTHOG_PROJECT_API_KEY: str = "" 1ba

209 

210 # Loops 

211 LOOPS_API_KEY: str | None = None 1ba

212 

213 # Logfire 

214 LOGFIRE_TOKEN: str | None = None 1ba

215 LOGFIRE_IGNORED_ACTORS: set[str] = { 1ba

216 "organization_access_token.record_usage", 

217 "personal_access_token.record_usage", 

218 } 

219 

220 # Plain 

221 PLAIN_REQUEST_SIGNING_SECRET: str | None = None 1ba

222 PLAIN_TOKEN: str | None = None 1ba

223 PLAIN_CHAT_SECRET: str | None = None 1ba

224 

225 # AWS (File Downloads) 

226 AWS_ACCESS_KEY_ID: str = "polar-development" 1ba

227 AWS_SECRET_ACCESS_KEY: str = "polar123456789" 1ba

228 AWS_REGION: str = "us-east-2" 1ba

229 AWS_SIGNATURE_VERSION: str = "v4" 1ba

230 

231 # Downloadable files 

232 S3_FILES_BUCKET_NAME: str = "polar-s3" 1ba

233 S3_FILES_PUBLIC_BUCKET_NAME: str = "polar-s3-public" 1ba

234 S3_FILES_PRESIGN_TTL: int = 600 # 10 minutes 1ba

235 S3_FILES_DOWNLOAD_SECRET: str = "supersecret" 1ba

236 S3_FILES_DOWNLOAD_SALT: str = "saltysalty" 1ba

237 # Override to http://127.0.0.1:9000 in .env during development 

238 S3_ENDPOINT_URL: str | None = None 1ba

239 

240 MINIO_USER: str = "polar" 1ba

241 MINIO_PWD: str = "polarpolar" 1ba

242 

243 # Invoices 

244 S3_CUSTOMER_INVOICES_BUCKET_NAME: str = "polar-customer-invoices" 1ba

245 S3_PAYOUT_INVOICES_BUCKET_NAME: str = "polar-payout-invoices" 1ba

246 INVOICES_NAME: str = "Polar Software, Inc." 1ba

247 INVOICES_ADDRESS: Address = Address( 1ba

248 line1="548 Market St", 

249 line2="PMB 61301", 

250 postal_code="94104", 

251 city="San Francisco", 

252 state="US-CA", 

253 country=CountryAlpha2("US"), 

254 ) 

255 INVOICES_ADDITIONAL_INFO: str | None = "[support@polar.sh](mailto:support@polar.sh)" 1ba

256 PAYOUT_INVOICES_PREFIX: str = "POLAR-" 1ba

257 

258 # Application behaviours 

259 API_PAGINATION_MAX_LIMIT: int = 100 1ba

260 

261 ACCOUNT_PAYOUT_DELAY: timedelta = timedelta(seconds=1) 1ba

262 ACCOUNT_PAYOUT_MINIMUM_BALANCE: int = 1000 1ba

263 

264 _DEFAULT_ACCOUNT_PAYOUT_MINIMUM_BALANCE: int = 1000 1ba

265 ACCOUNT_PAYOUT_MINIMUM_BALANCE_PER_PAYOUT_CURRENCY: dict[str, int] = { 1ba

266 "all": 4000, 

267 "amd": 4000, 

268 "aoa": 3000, 

269 "azn": 4000, 

270 "bam": 4000, 

271 "bob": 4000, 

272 "btn": 4000, 

273 "chf": 1500, 

274 "clp": 4000, 

275 "cop": 5000, 

276 "eur": 1300, 

277 "gbp": 1500, 

278 "gmd": 4000, 

279 "gyd": 4000, 

280 "khr": 4000, 

281 "krw": 4000, 

282 "lak": 4000, 

283 "mdl": 4000, 

284 "mga": 4000, 

285 "mkd": 4000, 

286 "mnt": 4000, 

287 "myr": 4000, 

288 "mzn": 4000, 

289 "nad": 4000, 

290 "pyg": 4000, 

291 "rsd": 4000, 

292 "thb": 4000, 

293 "twd": 4000, 

294 "uzs": 4000, 

295 # USD, default 

296 "usd": _DEFAULT_ACCOUNT_PAYOUT_MINIMUM_BALANCE, 

297 } 

298 PLATFORM_FEE_BASIS_POINTS: int = 400 1ba

299 PLATFORM_FEE_FIXED: int = 40 1ba

300 

301 ORGANIZATION_SLUG_RESERVED_KEYWORDS: list[str] = [ 1ba

302 # Landing pages 

303 "benefits", 

304 "donations", 

305 "issue-funding", 

306 "newsletters", 

307 "products", 

308 "careers", 

309 "legal", 

310 # App 

311 "docs", 

312 "login", 

313 "signup", 

314 "oauth2", 

315 "checkout", 

316 "embed", 

317 "maintainer", 

318 "dashboard", 

319 "feed", 

320 "for-you", 

321 "posts", 

322 "purchases", 

323 "funding", 

324 "rewards", 

325 "settings", 

326 "backoffice", 

327 "maintainer", 

328 "finance", 

329 # Misc 

330 ".well-known", 

331 ] 

332 

333 ORGANIZATIONS_BILLING_ENGINE_DEFAULT: bool = True 1ba

334 

335 # Dunning Configuration 

336 DUNNING_RETRY_INTERVALS: list[timedelta] = [ 1ba

337 timedelta(days=2), # First retry after 2 days 

338 timedelta(days=5), # Second retry after 7 days (2 + 5) 

339 timedelta(days=7), # Third retry after 14 days (2 + 5 + 7) 

340 timedelta(days=7), # Fourth retry after 21 days (2 + 5 + 7 + 7) 

341 ] 

342 

343 model_config = SettingsConfigDict( 1ba

344 env_prefix="polar_", 

345 env_file_encoding="utf-8", 

346 case_sensitive=False, 

347 env_file=env_file, 

348 extra="allow", 

349 ) 

350 

351 @property 1ba

352 def redis_url(self) -> str: 1ba

353 return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" 1bac

354 

355 def get_postgres_dsn(self, driver: Literal["asyncpg", "psycopg2"]) -> str: 1ba

356 return str( 1bc

357 PostgresDsn.build( 

358 scheme=f"postgresql+{driver}", 

359 username=self.POSTGRES_USER, 

360 password=self.POSTGRES_PWD, 

361 host=self.POSTGRES_HOST, 

362 port=self.POSTGRES_PORT, 

363 path=self.POSTGRES_DATABASE, 

364 ) 

365 ) 

366 

367 def is_read_replica_configured(self) -> bool: 1ba

368 return all( 

369 [ 

370 self.POSTGRES_READ_USER, 

371 self.POSTGRES_READ_PWD, 

372 self.POSTGRES_READ_HOST, 

373 self.POSTGRES_READ_PORT, 

374 self.POSTGRES_READ_DATABASE, 

375 ] 

376 ) 

377 

378 def get_postgres_read_dsn( 1ba

379 self, driver: Literal["asyncpg", "psycopg2"] 

380 ) -> str | None: 

381 if not self.is_read_replica_configured(): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 return None 

383 

384 return str( 

385 PostgresDsn.build( 

386 scheme=f"postgresql+{driver}", 

387 username=self.POSTGRES_READ_USER, 

388 password=self.POSTGRES_READ_PWD, 

389 host=self.POSTGRES_READ_HOST, 

390 port=self.POSTGRES_READ_PORT, 

391 path=self.POSTGRES_READ_DATABASE, 

392 ) 

393 ) 

394 

395 def is_environment(self, environments: set[Environment]) -> bool: 1ba

396 return self.ENV in environments 1ac

397 

398 def is_development(self) -> bool: 1ba

399 return self.is_environment({Environment.development}) 1ac

400 

401 def is_testing(self) -> bool: 1ba

402 return self.is_environment({Environment.testing}) 1ac

403 

404 def is_sandbox(self) -> bool: 1ba

405 return self.is_environment({Environment.sandbox}) 

406 

407 def is_production(self) -> bool: 1ba

408 return self.is_environment({Environment.production}) 

409 

410 def generate_external_url(self, path: str) -> str: 1ba

411 return f"{self.BASE_URL}{path}" 

412 

413 def generate_frontend_url(self, path: str) -> str: 1ba

414 return f"{self.FRONTEND_BASE_URL}{path}" 

415 

416 def generate_backoffice_url(self, path: str) -> str: 1ba

417 if self.BACKOFFICE_HOST is None: 

418 return self.generate_external_url(f"/backoffice{path}") 

419 return f"https://{self.BACKOFFICE_HOST}{path}" 

420 

421 @property 1ba

422 def stripe_descriptor_suffix_max_length(self) -> int: 1ba

423 return 22 - len("* ") - len(self.STRIPE_STATEMENT_DESCRIPTOR) 

424 

425 def get_minimum_payout_for_currency(self, currency: str) -> int: 1ba

426 return self.ACCOUNT_PAYOUT_MINIMUM_BALANCE_PER_PAYOUT_CURRENCY.get( 

427 currency.lower(), self._DEFAULT_ACCOUNT_PAYOUT_MINIMUM_BALANCE 

428 ) 

429 

430 

431settings = Settings() 1ba