Coverage for polar/config.py: 93%
190 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1import os
2from datetime import timedelta
3from enum import StrEnum
4from pathlib import Path
5from typing import Annotated, Literal
7from annotated_types import Ge
8from pydantic import AfterValidator, DirectoryPath, Field, PostgresDsn
9from pydantic_settings import BaseSettings, SettingsConfigDict
11from polar.kit.address import Address, CountryAlpha2
12from polar.kit.jwk import JWKSFile
15class Environment(StrEnum):
16 development = "development"
17 testing = "testing"
18 sandbox = "sandbox"
19 production = "production"
22class EmailSender(StrEnum):
23 logger = "logger"
24 resend = "resend"
27def _validate_email_renderer_binary_path(value: Path) -> Path:
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 true
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
42env = Environment(os.getenv("POLAR_ENV", Environment.development))
43env_file = ".env.testing" if env == Environment.testing else ".env"
44file_extension = ".exe" if os.name == "nt" else ""
47class Settings(BaseSettings):
48 ENV: Environment = Environment.development
49 SQLALCHEMY_DEBUG: bool = False
50 POSTHOG_DEBUG: bool = False
51 LOG_LEVEL: str = "DEBUG"
52 TESTING: bool = False
54 WORKER_HEALTH_CHECK_INTERVAL: timedelta = timedelta(seconds=30)
55 WORKER_MAX_RETRIES: int = 20
56 WORKER_MIN_BACKOFF_MILLISECONDS: int = 2_000
58 WEBHOOK_MAX_RETRIES: int = 10
59 WEBHOOK_EVENT_RETENTION_PERIOD: timedelta = timedelta(days=30)
60 WEBHOOK_FAILURE_THRESHOLD: int = 10
62 CUSTOMER_METER_UPDATE_DEBOUNCE_MIN_THRESHOLD: timedelta = timedelta(seconds=5)
63 CUSTOMER_METER_UPDATE_DEBOUNCE_MAX_THRESHOLD: timedelta = timedelta(minutes=15)
65 SECRET: str = "super secret jwt secret"
66 JWKS: JWKSFile = Field(default="./.jwks.json")
67 CURRENT_JWK_KID: str = "polar_dev"
68 WWW_AUTHENTICATE_REALM: str = "polar"
70 # JSON list of accepted CORS origins
71 CORS_ORIGINS: list[str] = []
73 ALLOWED_HOSTS: set[str] = {"127.0.0.1:3000", "localhost:3000"}
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"
78 BACKOFFICE_HOST: str | None = None
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"
84 FRONTEND_DEFAULT_RETURN_PATH: str = "/"
85 CHECKOUT_BASE_URL: str = (
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)
91 USER_SESSION_COOKIE_KEY: str = "polar_session"
92 USER_SESSION_COOKIE_DOMAIN: str = "127.0.0.1"
94 # Customer session
95 CUSTOMER_SESSION_TTL: timedelta = timedelta(hours=1)
96 CUSTOMER_SESSION_CODE_TTL: timedelta = timedelta(minutes=30)
97 CUSTOMER_SESSION_CODE_LENGTH: int = 6
99 # Impersonation session
100 IMPERSONATION_COOKIE_KEY: str = "polar_original_session"
101 IMPERSONATION_INDICATOR_COOKIE_KEY: str = "polar_is_impersonating"
103 # Login code
104 LOGIN_CODE_TTL_SECONDS: int = 60 * 30 # 30 minutes
105 LOGIN_CODE_LENGTH: int = 6
107 # Email verification
108 EMAIL_VERIFICATION_TTL_SECONDS: int = 60 * 30 # 30 minutes
110 # Checkout
111 CUSTOM_PRICE_PRESET_FALLBACK: Annotated[int, Ge(50)] = 10_00
112 CHECKOUT_TTL_SECONDS: int = 60 * 60 # 1 hour
113 IP_GEOLOCATION_DATABASE_DIRECTORY_PATH: DirectoryPath = Path(__file__).parent.parent
114 IP_GEOLOCATION_DATABASE_NAME: str = "ip-geolocation.mmdb"
115 USE_TEST_CLOCK: bool = False
117 # Database
118 POSTGRES_USER: str = "polar"
119 POSTGRES_PWD: str = "polar"
120 POSTGRES_HOST: str = "127.0.0.1"
121 POSTGRES_PORT: int = 5432
122 POSTGRES_DATABASE: str = "polar_development"
123 DATABASE_POOL_SIZE: int = 5
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.
125 DATABASE_POOL_RECYCLE_SECONDS: int = 600 # 10 minutes
126 DATABASE_COMMAND_TIMEOUT_SECONDS: float = 30.0
127 DATABASE_STREAM_YIELD_PER: int = 100
129 POSTGRES_READ_USER: str | None = None
130 POSTGRES_READ_PWD: str | None = None
131 POSTGRES_READ_HOST: str | None = None
132 POSTGRES_READ_PORT: int | None = None
133 POSTGRES_READ_DATABASE: str | None = None
135 # Redis
136 REDIS_HOST: str = "127.0.0.1"
137 REDIS_PORT: int = 6379
138 REDIS_DB: int = 0
140 # Emails
141 EMAIL_RENDERER_BINARY_PATH: Annotated[
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
150 RESEND_API_KEY: str = ""
151 RESEND_API_BASE_URL: str = "https://api.resend.com"
152 EMAIL_FROM_NAME: str = "Polar"
153 EMAIL_FROM_DOMAIN: str = "notifications.polar.sh"
154 EMAIL_FROM_LOCAL: str = "mail"
156 # Github App
157 GITHUB_CLIENT_ID: str = ""
158 GITHUB_CLIENT_SECRET: str = ""
160 # GitHub App for repository benefits
161 GITHUB_REPOSITORY_BENEFITS_APP_NAMESPACE: str = ""
162 GITHUB_REPOSITORY_BENEFITS_APP_IDENTIFIER: str = ""
163 GITHUB_REPOSITORY_BENEFITS_APP_PRIVATE_KEY: str = ""
164 GITHUB_REPOSITORY_BENEFITS_CLIENT_ID: str = ""
165 GITHUB_REPOSITORY_BENEFITS_CLIENT_SECRET: str = ""
167 # Discord
168 DISCORD_CLIENT_ID: str = ""
169 DISCORD_CLIENT_SECRET: str = ""
170 DISCORD_BOT_TOKEN: str = ""
171 DISCORD_BOT_PERMISSIONS: str = (
172 "268435459" # Manage Roles, Kick Members, Create Instant Invite
173 )
175 # Google
176 GOOGLE_CLIENT_ID: str = ""
177 GOOGLE_CLIENT_SECRET: str = ""
179 # Apple
180 APPLE_CLIENT_ID: str = ""
181 APPLE_TEAM_ID: str = ""
182 APPLE_KEY_ID: str = ""
183 APPLE_KEY_VALUE: str = ""
185 # OpenAI
186 OPENAI_API_KEY: str = ""
187 OPENAI_MODEL: str = "o4-mini-2025-04-16"
189 # Stripe
190 STRIPE_SECRET_KEY: str = ""
191 STRIPE_PUBLISHABLE_KEY: str = ""
192 # Stripe webhook secrets
193 STRIPE_WEBHOOK_SECRET: str = ""
194 STRIPE_CONNECT_WEBHOOK_SECRET: str = ""
195 STRIPE_STATEMENT_DESCRIPTOR: str = "POLAR"
197 # Open Collective
198 OPEN_COLLECTIVE_PERSONAL_TOKEN: str | None = None
200 # Sentry
201 SENTRY_DSN: str | None = None
203 # Discord
204 FAVICON_URL: str = "https://raw.githubusercontent.com/polarsource/polar/2648cf7472b5128704a097cd1eb3ae5f1dd847e5/docs/docs/assets/favicon.png"
205 THUMBNAIL_URL: str = "https://raw.githubusercontent.com/polarsource/polar/4fd899222e200ca70982f437039f549b7a822ecc/clients/apps/web/public/email-logo-dark.png"
207 # Posthog
208 POSTHOG_PROJECT_API_KEY: str = ""
210 # Loops
211 LOOPS_API_KEY: str | None = None
213 # Logfire
214 LOGFIRE_TOKEN: str | None = None
215 LOGFIRE_IGNORED_ACTORS: set[str] = {
216 "organization_access_token.record_usage",
217 "personal_access_token.record_usage",
218 }
220 # Plain
221 PLAIN_REQUEST_SIGNING_SECRET: str | None = None
222 PLAIN_TOKEN: str | None = None
223 PLAIN_CHAT_SECRET: str | None = None
225 # AWS (File Downloads)
226 AWS_ACCESS_KEY_ID: str = "polar-development"
227 AWS_SECRET_ACCESS_KEY: str = "polar123456789"
228 AWS_REGION: str = "us-east-2"
229 AWS_SIGNATURE_VERSION: str = "v4"
231 # Downloadable files
232 S3_FILES_BUCKET_NAME: str = "polar-s3"
233 S3_FILES_PUBLIC_BUCKET_NAME: str = "polar-s3-public"
234 S3_FILES_PRESIGN_TTL: int = 600 # 10 minutes
235 S3_FILES_DOWNLOAD_SECRET: str = "supersecret"
236 S3_FILES_DOWNLOAD_SALT: str = "saltysalty"
237 # Override to http://127.0.0.1:9000 in .env during development
238 S3_ENDPOINT_URL: str | None = None
240 MINIO_USER: str = "polar"
241 MINIO_PWD: str = "polarpolar"
243 # Invoices
244 S3_CUSTOMER_INVOICES_BUCKET_NAME: str = "polar-customer-invoices"
245 S3_PAYOUT_INVOICES_BUCKET_NAME: str = "polar-payout-invoices"
246 INVOICES_NAME: str = "Polar Software, Inc."
247 INVOICES_ADDRESS: Address = Address(
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)"
256 PAYOUT_INVOICES_PREFIX: str = "POLAR-"
258 # Application behaviours
259 API_PAGINATION_MAX_LIMIT: int = 100
261 ACCOUNT_PAYOUT_DELAY: timedelta = timedelta(seconds=1)
262 ACCOUNT_PAYOUT_MINIMUM_BALANCE: int = 1000
264 _DEFAULT_ACCOUNT_PAYOUT_MINIMUM_BALANCE: int = 1000
265 ACCOUNT_PAYOUT_MINIMUM_BALANCE_PER_PAYOUT_CURRENCY: dict[str, int] = {
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
299 PLATFORM_FEE_FIXED: int = 40
301 ORGANIZATION_SLUG_RESERVED_KEYWORDS: list[str] = [
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
335 # Dunning Configuration
336 DUNNING_RETRY_INTERVALS: list[timedelta] = [
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(
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
352 def redis_url(self) -> str:
353 return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
355 def get_postgres_dsn(self, driver: Literal["asyncpg", "psycopg2"]) -> str:
356 return str(
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:
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(
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:
396 return self.ENV in environments
398 def is_development(self) -> bool:
399 return self.is_environment({Environment.development})
401 def is_testing(self) -> bool:
402 return self.is_environment({Environment.testing})
404 def is_sandbox(self) -> bool:
405 return self.is_environment({Environment.sandbox})
407 def is_production(self) -> bool:
408 return self.is_environment({Environment.production})
410 def generate_external_url(self, path: str) -> str:
411 return f"{self.BASE_URL}{path}"
413 def generate_frontend_url(self, path: str) -> str:
414 return f"{self.FRONTEND_BASE_URL}{path}"
416 def generate_backoffice_url(self, path: str) -> str:
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
422 def stripe_descriptor_suffix_max_length(self) -> int:
423 return 22 - len("* ") - len(self.STRIPE_STATEMENT_DESCRIPTOR)
425 def get_minimum_payout_for_currency(self, currency: str) -> int:
426 return self.ACCOUNT_PAYOUT_MINIMUM_BALANCE_PER_PAYOUT_CURRENCY.get(
427 currency.lower(), self._DEFAULT_ACCOUNT_PAYOUT_MINIMUM_BALANCE
428 )
431settings = Settings()