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

1"""Application configuration for the Documint V1 dogfooding stack.""" 

2 

3from __future__ import annotations 

4 

5import os 

6from pathlib import Path 

7 

8from pydantic import AliasChoices, Field, field_validator, model_validator 

9from pydantic_settings import BaseSettings, SettingsConfigDict 

10 

11 

12def _looks_like_repo_root(path: Path) -> bool: 

13 return (path / "pyproject.toml").exists() and (path / "content" / "docs").exists() 

14 

15 

16def _discover_repo_root() -> Path: 

17 env_root = os.getenv("REPO_ROOT") 

18 if env_root: 

19 return Path(env_root).expanduser().resolve() 

20 

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] 

26 

27 

28REPO_ROOT = _discover_repo_root() 

29 

30 

31class Settings(BaseSettings): 

32 """Runtime settings with sane defaults for local dogfooding.""" 

33 

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 ) 

98 

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 ) 

114 

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 ) 

129 

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 ) 

187 

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 ) 

201 

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 ) 

232 

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 ) 

358 

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() 

365 

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 

374 

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 

381 

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() 

388 

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 

396 

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() 

402 

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 

408 

409 model_config = SettingsConfigDict( 

410 extra="ignore", 

411 env_file=".env", 

412 env_file_encoding="utf-8", 

413 case_sensitive=False, 

414 ) 

415 

416 def is_local_runtime(self) -> bool: 

417 return self.deployment_environment in {"local", "test"} or ( 

418 self.deployment_provider == "local" 

419 ) 

420 

421 def is_production_runtime(self) -> bool: 

422 return not self.is_local_runtime() 

423 

424 def production_validation_errors(self) -> list[str]: 

425 if not self.is_production_runtime(): 

426 return [] 

427 

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 

460 

461 

462settings = Settings() 

463 

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} 

471 

472SECURITY_CONFIG = { 

473 "algorithm": "HS256", 

474 "access_token_expire_minutes": settings.access_token_expire_minutes, 

475 "bcrypt_rounds": 12, 

476}