Coverage for src / documint_mcp / config.py: 0%
162 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 22:30 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 22:30 -0400
1"""Application configuration for the Documint V1 dogfooding stack."""
3from __future__ import annotations
5import os
6from pathlib import Path
8from pydantic import AliasChoices, Field, field_validator, model_validator
9from pydantic_settings import BaseSettings, SettingsConfigDict
12def _looks_like_repo_root(path: Path) -> bool:
13 return (path / "pyproject.toml").exists() and (path / "content" / "docs").exists()
16def _discover_repo_root() -> Path:
17 env_root = os.getenv("REPO_ROOT")
18 if env_root:
19 return Path(env_root).expanduser().resolve()
21 candidates = [Path.cwd().resolve(), *Path(__file__).resolve().parents]
22 for candidate in candidates:
23 if _looks_like_repo_root(candidate):
24 return candidate
25 return Path(__file__).resolve().parents[2]
28REPO_ROOT = _discover_repo_root()
31class Settings(BaseSettings):
32 """Runtime settings with sane defaults for local dogfooding."""
34 host: str = Field(default="127.0.0.1", description="API host")
35 port: int = Field(default=8000, ge=1, le=65535, description="API port")
36 debug: bool = Field(default=False, description="Enable debug-only behaviors")
37 auto_create_schema: bool = Field(
38 default=(
39 os.getenv("DOCUMINT_AUTO_CREATE_SCHEMA", "").lower() in {"1", "true", "yes"}
40 if os.getenv("DOCUMINT_AUTO_CREATE_SCHEMA") is not None
41 else (
42 os.getenv("PYTEST_CURRENT_TEST") is not None
43 or os.getenv("DOCUMINT_ENVIRONMENT") in {"local", "test"}
44 or (
45 os.getenv("RAILWAY_ENVIRONMENT_NAME") is None
46 and os.getenv("VERCEL_ENV") is None
47 )
48 )
49 ),
50 description="Create database tables automatically for local development and tests.",
51 validation_alias=AliasChoices(
52 "DOCUMINT_AUTO_CREATE_SCHEMA",
53 "DOCUMINT_DB_AUTO_CREATE",
54 ),
55 )
56 auto_bootstrap_defaults: bool = Field(
57 default=(
58 os.getenv("DOCUMINT_AUTO_BOOTSTRAP_DEFAULTS", "").lower()
59 in {"1", "true", "yes"}
60 if os.getenv("DOCUMINT_AUTO_BOOTSTRAP_DEFAULTS") is not None
61 else (
62 os.getenv("PYTEST_CURRENT_TEST") is not None
63 or os.getenv("DOCUMINT_ENVIRONMENT") in {"local", "test"}
64 or (
65 os.getenv("RAILWAY_ENVIRONMENT_NAME") is None
66 and os.getenv("VERCEL_ENV") is None
67 )
68 )
69 ),
70 description="Seed the self-dogfood workspace automatically in local development and tests.",
71 validation_alias=AliasChoices(
72 "DOCUMINT_AUTO_BOOTSTRAP_DEFAULTS",
73 "DOCUMINT_AUTO_BOOTSTRAP",
74 ),
75 )
76 job_execution_mode: str = Field(
77 default=(
78 os.getenv("DOCUMINT_JOB_EXECUTION_MODE")
79 or (
80 "inline"
81 if (
82 os.getenv("PYTEST_CURRENT_TEST") is not None
83 or os.getenv("DOCUMINT_ENVIRONMENT") in {"local", "test"}
84 or (
85 os.getenv("RAILWAY_ENVIRONMENT_NAME") is None
86 and os.getenv("VERCEL_ENV") is None
87 )
88 )
89 else "queue"
90 )
91 ),
92 description="Execution mode for long-running jobs: inline or queue.",
93 validation_alias=AliasChoices(
94 "DOCUMINT_JOB_EXECUTION_MODE",
95 "DOCUMINT_JOB_MODE",
96 ),
97 )
99 secret_key: str = Field(
100 default_factory=lambda: os.urandom(32).hex(),
101 description="Secret key for session and token signing",
102 )
103 auth_token: str | None = Field(
104 default=None,
105 description="Optional bearer token for mutating API endpoints",
106 validation_alias=AliasChoices("AUTH_TOKEN", "DOCUMINT_AUTH_TOKEN"),
107 )
108 access_token_expire_minutes: int = Field(
109 default=30,
110 ge=1,
111 le=1440,
112 description="Access token expiration time in minutes",
113 )
115 redis_url: str = Field(
116 default="redis://localhost:6379/0",
117 description="Redis connection URL for cache and ephemeral state",
118 )
119 cache_ttl: int = Field(
120 default=3600,
121 ge=60,
122 le=86400,
123 description="Default cache TTL in seconds",
124 )
125 max_file_size: int = Field(
126 default=10 * 1024 * 1024,
127 description="Maximum file size supported by the API",
128 )
130 database_url: str = Field(
131 default="sqlite:///./documint.db",
132 description="Metadata database URL",
133 )
134 internal_revalidate_secret: str | None = Field(
135 default=None,
136 description="Shared secret used by trusted internal revalidation calls.",
137 validation_alias=AliasChoices(
138 "INTERNAL_REVALIDATE_SECRET",
139 "DOCUMINT_INTERNAL_REVALIDATE_SECRET",
140 ),
141 )
142 log_level: str = Field(default="INFO", description="Structured logging level")
143 rate_limit_requests: int = Field(
144 default=120,
145 ge=1,
146 description="Rate limit requests per minute",
147 )
148 deployment_environment: str = Field(
149 default=os.getenv("RAILWAY_ENVIRONMENT_NAME")
150 or os.getenv("VERCEL_ENV")
151 or os.getenv("DOCUMINT_ENVIRONMENT")
152 or "local",
153 description="Current deployment environment label",
154 )
155 deployment_provider: str = Field(
156 default=(
157 "railway"
158 if os.getenv("RAILWAY_PROJECT_ID")
159 else (
160 "vercel"
161 if os.getenv("VERCEL")
162 else os.getenv("DOCUMINT_DEPLOYMENT_PROVIDER", "local")
163 )
164 ),
165 description="Active deployment provider",
166 )
167 deploy_commit_ref: str | None = Field(
168 default=None,
169 description="Commit ref supplied by the deployment platform when git metadata is unavailable.",
170 validation_alias=AliasChoices(
171 "DOCUMINT_DEPLOY_COMMIT",
172 "RAILWAY_GIT_COMMIT_SHA",
173 "VERCEL_GIT_COMMIT_SHA",
174 "GITHUB_SHA",
175 ),
176 )
177 deploy_branch: str | None = Field(
178 default=None,
179 description="Branch or ref supplied by the deployment platform.",
180 validation_alias=AliasChoices(
181 "DOCUMINT_DEPLOY_BRANCH",
182 "RAILWAY_GIT_BRANCH",
183 "VERCEL_GIT_COMMIT_REF",
184 "GITHUB_REF_NAME",
185 ),
186 )
188 repo_root: Path = Field(default=REPO_ROOT, description="Local repository root")
189 docs_content_path: Path = Field(
190 default=REPO_ROOT / "content" / "docs",
191 description="Markdown docs used for the public site",
192 )
193 document_storage_path: Path = Field(
194 default=REPO_ROOT / ".documint",
195 description="Internal scratch space for previews and generated artifacts",
196 )
197 documint_config_filename: str = Field(
198 default="documint.config.yaml",
199 description="Repository config file name for artifact and publish configuration.",
200 )
202 project_id: str = Field(default="documint-self", description="Dogfood project id")
203 project_name: str = Field(default="Documint", description="Dogfood project name")
204 project_slug: str = Field(default="documint", description="Dogfood project slug")
205 project_owner: str = Field(default="skeehn", description="GitHub owner")
206 project_repo: str = Field(default="documint", description="GitHub repo name")
207 default_branch: str = Field(default="main", description="Repository default branch")
208 default_workspace_id: str = Field(
209 default="workspace-documint",
210 description="Bootstrap workspace id for self-dogfood mode",
211 )
212 default_workspace_name: str = Field(
213 default="Documint Lab",
214 description="Bootstrap workspace name for self-dogfood mode",
215 )
216 default_workspace_slug: str = Field(
217 default="documint-lab",
218 description="Bootstrap workspace slug for self-dogfood mode",
219 )
220 default_user_id: str = Field(
221 default="user-documint",
222 description="Bootstrap user id for self-dogfood mode",
223 )
224 default_user_name: str = Field(
225 default="Documint Operator",
226 description="Bootstrap user name for self-dogfood mode",
227 )
228 default_user_email: str = Field(
229 default="operator@documint.xyz",
230 description="Bootstrap user email for self-dogfood mode",
231 )
233 public_base_url: str = Field(
234 default="https://documint.xyz",
235 description="Public docs and marketing site URL",
236 )
237 api_base_url: str = Field(
238 default="http://127.0.0.1:8000",
239 description="Public backend base URL used by integrations and webhooks",
240 )
241 app_base_url: str = Field(
242 default="http://localhost:3000",
243 description="Dashboard base URL",
244 )
245 github_app_name: str = Field(
246 default="Documint",
247 description="GitHub App display name",
248 )
249 github_app_id: str | None = Field(
250 default=None,
251 description="GitHub App identifier used for installation metadata",
252 )
253 github_app_slug: str = Field(
254 default="documint",
255 description="GitHub App slug used to construct install URLs",
256 )
257 github_app_private_key: str | None = Field(
258 default=None,
259 description="PEM-encoded GitHub App private key used to mint installation tokens.",
260 validation_alias=AliasChoices(
261 "GITHUB_APP_PRIVATE_KEY",
262 "DOCUMINT_GITHUB_APP_PRIVATE_KEY",
263 ),
264 )
265 github_webhook_secret: str | None = Field(
266 default=None,
267 description="GitHub webhook secret for validating deliveries",
268 )
269 github_api_url: str = Field(
270 default="https://api.github.com",
271 description="GitHub REST API base URL for installation sync and PR automation.",
272 validation_alias=AliasChoices("GITHUB_API_URL", "DOCUMINT_GITHUB_API_URL"),
273 )
274 github_api_version: str = Field(
275 default="2022-11-28",
276 description="GitHub REST API version header used for app automation requests.",
277 validation_alias=AliasChoices(
278 "GITHUB_API_VERSION",
279 "DOCUMINT_GITHUB_API_VERSION",
280 ),
281 )
282 self_bootstrap_installation_id: str | None = Field(
283 default=None,
284 description="External GitHub installation id used for the self-dogfood project.",
285 validation_alias=AliasChoices(
286 "DOCUMINT_SELF_BOOTSTRAP_INSTALLATION_ID",
287 "GITHUB_SELF_INSTALLATION_ID",
288 ),
289 )
290 clerk_jwks_url: str | None = Field(
291 default=None,
292 description="Optional Clerk JWKS URL for backend JWT verification.",
293 validation_alias=AliasChoices("CLERK_JWKS_URL", "DOCUMINT_CLERK_JWKS_URL"),
294 )
295 clerk_issuer: str | None = Field(
296 default=None,
297 description="Expected Clerk token issuer.",
298 validation_alias=AliasChoices("CLERK_ISSUER", "DOCUMINT_CLERK_ISSUER"),
299 )
300 clerk_audience: str | None = Field(
301 default=None,
302 description="Expected Clerk audience for backend JWT verification.",
303 validation_alias=AliasChoices("CLERK_AUDIENCE", "DOCUMINT_CLERK_AUDIENCE"),
304 )
305 hf_api_token: str | None = Field(
306 default=None,
307 description="Hugging Face API token used for patch drafting and summarization.",
308 validation_alias=AliasChoices("HF_API_TOKEN", "HUGGINGFACEHUB_API_TOKEN"),
309 )
310 hf_primary_model: str | None = Field(
311 default=None,
312 description="Primary instruct model used for patch drafting.",
313 validation_alias=AliasChoices("HF_PRIMARY_MODEL", "DOCUMINT_HF_PRIMARY_MODEL"),
314 )
315 hf_fast_model: str | None = Field(
316 default=None,
317 description="Fast summarization model used for change triage.",
318 validation_alias=AliasChoices("HF_FAST_MODEL", "DOCUMINT_HF_FAST_MODEL"),
319 )
320 anthropic_api_key: str | None = Field(
321 default=None,
322 description="Anthropic API key for Claude patch drafting.",
323 validation_alias=AliasChoices("ANTHROPIC_API_KEY", "DOCUMINT_ANTHROPIC_API_KEY"),
324 )
325 openrouter_api_key: str | None = Field(
326 default=None,
327 description="OpenRouter API key for fallback patch drafting.",
328 validation_alias=AliasChoices("OPENROUTER_API_KEY", "DOCUMINT_OPENROUTER_API_KEY"),
329 )
330 anthropic_model: str = Field(
331 default="claude-sonnet-4-6",
332 description="Anthropic model for patch drafting.",
333 )
334 openrouter_primary_model: str = Field(
335 default="nvidia/nemotron-3-super-120b-a12b:free",
336 description="Primary OpenRouter fallback model.",
337 )
338 openrouter_secondary_model: str = Field(
339 default="nvidia/nemotron-3-nano-30b-a3b:free",
340 description="Secondary OpenRouter fallback model (lighter, faster).",
341 )
342 frontend_revalidate_url: str | None = Field(
343 default=None,
344 description="Optional web revalidation endpoint called after a publish succeeds.",
345 validation_alias=AliasChoices(
346 "DOCUMINT_FRONTEND_REVALIDATE_URL",
347 "NEXT_REVALIDATE_URL",
348 ),
349 )
350 frontend_revalidate_secret: str | None = Field(
351 default=None,
352 description="Shared secret for the web revalidation endpoint.",
353 validation_alias=AliasChoices(
354 "DOCUMINT_FRONTEND_REVALIDATE_SECRET",
355 "NEXT_REVALIDATE_SECRET",
356 ),
357 )
359 @field_validator("host")
360 @classmethod
361 def validate_host(cls, value: str) -> str:
362 if not value or not value.strip():
363 raise ValueError("Host cannot be empty")
364 return value.strip()
366 @field_validator("log_level")
367 @classmethod
368 def validate_log_level(cls, value: str) -> str:
369 valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
370 upper = value.upper()
371 if upper not in valid_levels:
372 raise ValueError(f"Log level must be one of {valid_levels}")
373 return upper
375 @field_validator("repo_root", "docs_content_path", "document_storage_path")
376 @classmethod
377 def validate_paths(cls, value: Path) -> Path:
378 resolved = value.resolve()
379 resolved.mkdir(parents=True, exist_ok=True)
380 return resolved
382 @field_validator("deployment_environment", "deployment_provider")
383 @classmethod
384 def validate_runtime_labels(cls, value: str) -> str:
385 if not value or not value.strip():
386 raise ValueError("Runtime labels cannot be empty")
387 return value.strip()
389 @field_validator("job_execution_mode")
390 @classmethod
391 def validate_job_execution_mode(cls, value: str) -> str:
392 normalized = value.strip().lower()
393 if normalized not in {"inline", "queue"}:
394 raise ValueError("job_execution_mode must be either 'inline' or 'queue'")
395 return normalized
397 @model_validator(mode="after")
398 def validate_project_paths(self) -> Settings:
399 repo_root = self.repo_root.resolve()
400 docs_root = self.docs_content_path.resolve()
401 storage_root = self.document_storage_path.resolve()
403 if not str(docs_root).startswith(str(repo_root)):
404 raise ValueError("docs_content_path must remain inside repo_root")
405 if not str(storage_root).startswith(str(repo_root)):
406 raise ValueError("document_storage_path must remain inside repo_root")
407 return self
409 model_config = SettingsConfigDict(
410 extra="ignore",
411 env_file=".env",
412 env_file_encoding="utf-8",
413 case_sensitive=False,
414 )
416 def is_local_runtime(self) -> bool:
417 return self.deployment_environment in {"local", "test"} or (
418 self.deployment_provider == "local"
419 )
421 def is_production_runtime(self) -> bool:
422 return not self.is_local_runtime()
424 def production_validation_errors(self) -> list[str]:
425 if not self.is_production_runtime():
426 return []
428 errors: list[str] = []
429 if self.debug:
430 errors.append("debug must be disabled")
431 if self.database_url.startswith("sqlite"):
432 errors.append("Postgres is required; SQLite is only supported for local/test")
433 if self.auto_create_schema:
434 errors.append("auto_create_schema must be disabled")
435 if self.auto_bootstrap_defaults:
436 errors.append("auto_bootstrap_defaults must be disabled")
437 if self.job_execution_mode != "queue":
438 errors.append("job_execution_mode must be 'queue'")
439 if not self.github_app_id:
440 errors.append("github_app_id is required")
441 if not self.github_app_slug:
442 errors.append("github_app_slug is required")
443 if not self.github_app_private_key:
444 errors.append("github_app_private_key is required")
445 if not self.github_webhook_secret:
446 errors.append("github_webhook_secret is required")
447 if not self.clerk_jwks_url:
448 errors.append("clerk_jwks_url is required")
449 if not self.clerk_issuer:
450 errors.append("clerk_issuer is required")
451 if not self.internal_revalidate_secret:
452 errors.append("internal_revalidate_secret is required")
453 if not self.frontend_revalidate_url:
454 errors.append("frontend_revalidate_url is required")
455 if not self.frontend_revalidate_secret:
456 errors.append("frontend_revalidate_secret is required")
457 if os.getenv("DOCUMINT_ENABLE_API_FALLBACK", "").lower() == "true":
458 errors.append("DOCUMINT_ENABLE_API_FALLBACK must be disabled")
459 return errors
462settings = Settings()
464CACHE_CONFIG = {
465 "default_ttl": settings.cache_ttl,
466 "max_connections": 20,
467 "retry_on_timeout": True,
468 "socket_connect_timeout": 5,
469 "socket_timeout": 5,
470}
472SECURITY_CONFIG = {
473 "algorithm": "HS256",
474 "access_token_expire_minutes": settings.access_token_expire_minutes,
475 "bcrypt_rounds": 12,
476}