Coverage for polar/config.py: 93%
190 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« 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
7from annotated_types import Ge 1ba
8from pydantic import AfterValidator, DirectoryPath, Field, PostgresDsn 1ba
9from pydantic_settings import BaseSettings, SettingsConfigDict 1ba
11from polar.kit.address import Address, CountryAlpha2 1ba
12from polar.kit.jwk import JWKSFile 1ba
15class Environment(StrEnum): 1ba
16 development = "development" 1ba
17 testing = "testing" 1ba
18 sandbox = "sandbox" 1ba
19 production = "production" 1ba
22class EmailSender(StrEnum): 1ba
23 logger = "logger" 1ba
24 resend = "resend" 1ba
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 )
39 return value 1ba
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
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
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
58 WEBHOOK_MAX_RETRIES: int = 10 1ba
59 WEBHOOK_EVENT_RETENTION_PERIOD: timedelta = timedelta(days=30) 1ba
60 WEBHOOK_FAILURE_THRESHOLD: int = 10 1ba
62 CUSTOMER_METER_UPDATE_DEBOUNCE_MIN_THRESHOLD: timedelta = timedelta(seconds=5) 1ba
63 CUSTOMER_METER_UPDATE_DEBOUNCE_MAX_THRESHOLD: timedelta = timedelta(minutes=15) 1ba
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
70 # JSON list of accepted CORS origins
71 CORS_ORIGINS: list[str] = [] 1ba
73 ALLOWED_HOSTS: set[str] = {"127.0.0.1:3000", "localhost:3000"} 1ba
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
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 )
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
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
99 # Impersonation session
100 IMPERSONATION_COOKIE_KEY: str = "polar_original_session" 1ba
101 IMPERSONATION_INDICATOR_COOKIE_KEY: str = "polar_is_impersonating" 1ba
103 # Login code
104 LOGIN_CODE_TTL_SECONDS: int = 60 * 30 # 30 minutes 1ba
105 LOGIN_CODE_LENGTH: int = 6 1ba
107 # Email verification
108 EMAIL_VERIFICATION_TTL_SECONDS: int = 60 * 30 # 30 minutes 1ba
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
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
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
135 # Redis
136 REDIS_HOST: str = "127.0.0.1" 1ba
137 REDIS_PORT: int = 6379 1ba
138 REDIS_DB: int = 0 1ba
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
156 # Github App
157 GITHUB_CLIENT_ID: str = "" 1ba
158 GITHUB_CLIENT_SECRET: str = "" 1ba
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
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 )
175 # Google
176 GOOGLE_CLIENT_ID: str = "" 1ba
177 GOOGLE_CLIENT_SECRET: str = "" 1ba
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
185 # OpenAI
186 OPENAI_API_KEY: str = "" 1ba
187 OPENAI_MODEL: str = "o4-mini-2025-04-16" 1ba
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
197 # Open Collective
198 OPEN_COLLECTIVE_PERSONAL_TOKEN: str | None = None 1ba
200 # Sentry
201 SENTRY_DSN: str | None = None 1ba
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
207 # Posthog
208 POSTHOG_PROJECT_API_KEY: str = "" 1ba
210 # Loops
211 LOOPS_API_KEY: str | None = None 1ba
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 }
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
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
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
240 MINIO_USER: str = "polar" 1ba
241 MINIO_PWD: str = "polarpolar" 1ba
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
258 # Application behaviours
259 API_PAGINATION_MAX_LIMIT: int = 100 1ba
261 ACCOUNT_PAYOUT_DELAY: timedelta = timedelta(seconds=1) 1ba
262 ACCOUNT_PAYOUT_MINIMUM_BALANCE: int = 1000 1ba
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
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 ]
333 ORGANIZATIONS_BILLING_ENGINE_DEFAULT: bool = True 1ba
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 ]
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 )
351 @property 1ba
352 def redis_url(self) -> str: 1ba
353 return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" 1bac
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 )
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 )
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
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 )
395 def is_environment(self, environments: set[Environment]) -> bool: 1ba
396 return self.ENV in environments 1ac
398 def is_development(self) -> bool: 1ba
399 return self.is_environment({Environment.development}) 1ac
401 def is_testing(self) -> bool: 1ba
402 return self.is_environment({Environment.testing}) 1ac
404 def is_sandbox(self) -> bool: 1ba
405 return self.is_environment({Environment.sandbox})
407 def is_production(self) -> bool: 1ba
408 return self.is_environment({Environment.production})
410 def generate_external_url(self, path: str) -> str: 1ba
411 return f"{self.BASE_URL}{path}"
413 def generate_frontend_url(self, path: str) -> str: 1ba
414 return f"{self.FRONTEND_BASE_URL}{path}"
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}"
421 @property 1ba
422 def stripe_descriptor_suffix_max_length(self) -> int: 1ba
423 return 22 - len("* ") - len(self.STRIPE_STATEMENT_DESCRIPTOR)
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 )
431settings = Settings() 1ba