AI Implementation feature(1083): Environment Configuration and Logging Baseline (#2)
This commit was merged in pull request #2.
This commit is contained in:
1 parent
33c6c76ce9
commit
207e2e6bbb
21 files changed
+1429
-77
No files matched your search
+84
-30
@@ -4,6 +4,8 @@ import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
@@ -11,27 +13,29 @@ from dotenv import dotenv_values, load_dotenv
|
||||
REQUIRED_KEYS = (
|
||||
"MASTODON_BASE_URL",
|
||||
"MASTODON_ACCESS_TOKEN",
|
||||
"VISIBILITY",
|
||||
"SITE_URL",
|
||||
"HASHTAGS",
|
||||
"THROWNBACK_PREFIX",
|
||||
"MAX_RETRIES",
|
||||
"RUN_AT",
|
||||
)
|
||||
|
||||
OPTIONAL_KEYS = (
|
||||
"MASTODON_VISIBILITY",
|
||||
"THROWBACK_PREFIX",
|
||||
"RETRY_COUNT",
|
||||
"TZ",
|
||||
)
|
||||
|
||||
OPTIONAL_KEYS: tuple[str, ...] = ()
|
||||
|
||||
DEFAULTS = {
|
||||
"MASTODON_VISIBILITY": "public",
|
||||
"THROWBACK_PREFIX": "Throwback:",
|
||||
"RETRY_COUNT": "3",
|
||||
"VISIBILITY": "public",
|
||||
"THROWNBACK_PREFIX": "Throwback:",
|
||||
"MAX_RETRIES": "3",
|
||||
"TZ": "Europe/Berlin",
|
||||
"RUN_AT": "09:00",
|
||||
}
|
||||
|
||||
|
||||
ALLOWED_VISIBILITY = frozenset({"public", "unlisted"})
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when required configuration is missing or invalid."""
|
||||
|
||||
@@ -40,11 +44,11 @@ class ConfigError(ValueError):
|
||||
class Config:
|
||||
mastodon_base_url: str
|
||||
mastodon_access_token: str
|
||||
mastodon_visibility: str
|
||||
visibility: str
|
||||
site_url: str
|
||||
hashtags: str
|
||||
throwback_prefix: str
|
||||
retry_count: int
|
||||
max_retries: int
|
||||
run_at: str
|
||||
tz: str
|
||||
data_dir: Path = field(default_factory=lambda: Path("/app/data"))
|
||||
@@ -104,11 +108,11 @@ def load_config(dotenv_path: Optional[Path] = None) -> Config:
|
||||
return Config(
|
||||
mastodon_base_url=merged["MASTODON_BASE_URL"],
|
||||
mastodon_access_token=merged["MASTODON_ACCESS_TOKEN"],
|
||||
mastodon_visibility=merged["MASTODON_VISIBILITY"],
|
||||
visibility=merged["VISIBILITY"],
|
||||
site_url=merged["SITE_URL"],
|
||||
hashtags=merged["HASHTAGS"],
|
||||
throwback_prefix=merged["THROWBACK_PREFIX"],
|
||||
retry_count=_parse_retry_count(merged["RETRY_COUNT"]),
|
||||
throwback_prefix=merged["THROWNBACK_PREFIX"],
|
||||
max_retries=_parse_max_retries(merged["MAX_RETRIES"]),
|
||||
run_at=merged["RUN_AT"],
|
||||
tz=merged["TZ"],
|
||||
data_dir=data_dir,
|
||||
@@ -122,25 +126,47 @@ def apply_defaults(values: dict[str, str]) -> None:
|
||||
|
||||
|
||||
def validate_config(values: dict[str, str]) -> None:
|
||||
missing = [k for k in REQUIRED_KEYS if not values.get(k)]
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
"missing required configuration key(s): " + ", ".join(missing)
|
||||
)
|
||||
errors: list[str] = []
|
||||
|
||||
for key in REQUIRED_KEYS:
|
||||
raw = values.get(key, "")
|
||||
if not raw:
|
||||
errors.append(f"missing required configuration key: {key}")
|
||||
|
||||
run_at = values.get("RUN_AT", "")
|
||||
if not _is_valid_hhmm(run_at):
|
||||
raise ConfigError(
|
||||
f"RUN_AT={run_at!r} must be in HH:MM (24-hour) format"
|
||||
if run_at and not _is_valid_hhmm(run_at):
|
||||
errors.append(f"RUN_AT={run_at!r} must be in HH:MM (24-hour) format")
|
||||
|
||||
base_url = values.get("MASTODON_BASE_URL", "")
|
||||
if base_url and not _is_valid_url(base_url):
|
||||
errors.append(f"MASTODON_BASE_URL={base_url!r} must be a valid http(s) URL")
|
||||
|
||||
site_url = values.get("SITE_URL", "")
|
||||
if site_url and not _is_valid_url(site_url):
|
||||
errors.append(f"SITE_URL={site_url!r} must be a valid http(s) URL")
|
||||
|
||||
visibility = values.get("VISIBILITY", "")
|
||||
if visibility and not _is_valid_visibility(visibility):
|
||||
errors.append(
|
||||
f"VISIBILITY={visibility!r} must be one of: {sorted(ALLOWED_VISIBILITY)}"
|
||||
)
|
||||
|
||||
try:
|
||||
_parse_retry_count(values.get("RETRY_COUNT", ""))
|
||||
except ConfigError as exc:
|
||||
raise ConfigError(str(exc)) from exc
|
||||
tz = values.get("TZ", "")
|
||||
if tz and not _is_valid_tz(tz):
|
||||
errors.append(f"TZ={tz!r} must be a valid IANA timezone")
|
||||
|
||||
max_retries = values.get("MAX_RETRIES", "")
|
||||
if max_retries:
|
||||
try:
|
||||
_parse_max_retries(max_retries)
|
||||
except ConfigError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if errors:
|
||||
raise ConfigError("; ".join(errors))
|
||||
|
||||
|
||||
def _is_valid_hhmm(value: str) -> bool:
|
||||
def _is_valid_hhmm(value) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
parts = value.split(":")
|
||||
@@ -156,11 +182,39 @@ def _is_valid_hhmm(value: str) -> bool:
|
||||
return 0 <= h <= 23 and 0 <= m <= 59
|
||||
|
||||
|
||||
def _parse_retry_count(value: str) -> int:
|
||||
def _is_valid_url(value: str) -> bool:
|
||||
if not isinstance(value, str) or not value:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _is_valid_visibility(value: str) -> bool:
|
||||
return isinstance(value, str) and value in ALLOWED_VISIBILITY
|
||||
|
||||
|
||||
def _is_valid_tz(value: str) -> bool:
|
||||
if not isinstance(value, str) or not value:
|
||||
return False
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _parse_max_retries(value) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ConfigError(f"MAX_RETRIES={value!r} must be a non-negative integer")
|
||||
try:
|
||||
count = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ConfigError(f"RETRY_COUNT={value!r} must be a positive integer")
|
||||
raise ConfigError(f"MAX_RETRIES={value!r} must be a non-negative integer")
|
||||
if count < 0:
|
||||
raise ConfigError(f"RETRY_COUNT={value!r} must be >= 0")
|
||||
raise ConfigError(f"MAX_RETRIES={value!r} must be >= 0")
|
||||
return count
|
||||
Reference in new issue
Block a user