AI Implementation feature(1082): Project Scaffold and Docker Packaging (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""10Backward — Mastodon daily-throwback bot."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def clone_or_update(site_url: str, dest: Path) -> Path:
|
||||
"""Clone or update the source blog git repository under `dest`.
|
||||
|
||||
Stubbed for the scaffold (Job 1082). Future jobs will implement this
|
||||
using GitPython over HTTPS.
|
||||
"""
|
||||
parsed = urlparse(site_url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError(f"unsupported SITE_URL scheme: {parsed.scheme!r}")
|
||||
raise NotImplementedError(
|
||||
"blog clone/update lands in a follow-up job (relies on GitPython over HTTPS)"
|
||||
)
|
||||
|
||||
|
||||
def blog_dir(data_dir: Path) -> Path:
|
||||
return Path(data_dir) / "blog"
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
|
||||
REQUIRED_KEYS = (
|
||||
"MASTODON_BASE_URL",
|
||||
"MASTODON_ACCESS_TOKEN",
|
||||
"SITE_URL",
|
||||
"HASHTAGS",
|
||||
"RUN_AT",
|
||||
)
|
||||
|
||||
OPTIONAL_KEYS = (
|
||||
"MASTODON_VISIBILITY",
|
||||
"THROWBACK_PREFIX",
|
||||
"RETRY_COUNT",
|
||||
"TZ",
|
||||
)
|
||||
|
||||
DEFAULTS = {
|
||||
"MASTODON_VISIBILITY": "public",
|
||||
"THROWBACK_PREFIX": "Throwback:",
|
||||
"RETRY_COUNT": "3",
|
||||
"TZ": "Europe/Berlin",
|
||||
"RUN_AT": "09:00",
|
||||
}
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when required configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
mastodon_base_url: str
|
||||
mastodon_access_token: str
|
||||
mastodon_visibility: str
|
||||
site_url: str
|
||||
hashtags: str
|
||||
throwback_prefix: str
|
||||
retry_count: int
|
||||
run_at: str
|
||||
tz: str
|
||||
data_dir: Path = field(default_factory=lambda: Path("/app/data"))
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def cron_minute(self) -> str:
|
||||
return self.run_at.split(":", 1)[0]
|
||||
|
||||
@property
|
||||
def cron_hour(self) -> str:
|
||||
return self.run_at.split(":", 1)[1]
|
||||
|
||||
|
||||
def _read_dotenv(dotenv_path: Optional[Path]) -> dict[str, str]:
|
||||
if dotenv_path is None:
|
||||
return {}
|
||||
if not dotenv_path.exists():
|
||||
return {}
|
||||
values = dotenv_values(dotenv_path=str(dotenv_path))
|
||||
return {k: v for k, v in values.items() if v is not None}
|
||||
|
||||
|
||||
def _values_from_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for key in REQUIRED_KEYS + OPTIONAL_KEYS:
|
||||
raw = os.environ.get(key)
|
||||
if raw is not None and raw != "":
|
||||
values[key] = raw
|
||||
return values
|
||||
|
||||
|
||||
def load_config(dotenv_path: Optional[Path] = None) -> Config:
|
||||
"""Load configuration from a dotenv file and the process environment.
|
||||
|
||||
Environment variables take precedence over the dotenv file so a mounted
|
||||
`.env` can be supplemented by Compose-level overrides.
|
||||
"""
|
||||
if dotenv_path is not None:
|
||||
load_dotenv(dotenv_path=str(dotenv_path), override=False)
|
||||
|
||||
file_values = _read_dotenv(dotenv_path)
|
||||
env_values = _values_from_env()
|
||||
|
||||
merged: dict[str, str] = {}
|
||||
merged.update(file_values)
|
||||
merged.update(env_values)
|
||||
|
||||
apply_defaults(merged)
|
||||
|
||||
validate_config(merged)
|
||||
|
||||
data_dir = Path(os.environ.get("DATA_DIR", "/app/data")).resolve()
|
||||
|
||||
extra = {k: v for k, v in merged.items() if k not in REQUIRED_KEYS + OPTIONAL_KEYS}
|
||||
|
||||
return Config(
|
||||
mastodon_base_url=merged["MASTODON_BASE_URL"],
|
||||
mastodon_access_token=merged["MASTODON_ACCESS_TOKEN"],
|
||||
mastodon_visibility=merged["MASTODON_VISIBILITY"],
|
||||
site_url=merged["SITE_URL"],
|
||||
hashtags=merged["HASHTAGS"],
|
||||
throwback_prefix=merged["THROWBACK_PREFIX"],
|
||||
retry_count=_parse_retry_count(merged["RETRY_COUNT"]),
|
||||
run_at=merged["RUN_AT"],
|
||||
tz=merged["TZ"],
|
||||
data_dir=data_dir,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def apply_defaults(values: dict[str, str]) -> None:
|
||||
for key, default in DEFAULTS.items():
|
||||
values.setdefault(key, default)
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
try:
|
||||
_parse_retry_count(values.get("RETRY_COUNT", ""))
|
||||
except ConfigError as exc:
|
||||
raise ConfigError(str(exc)) from exc
|
||||
|
||||
|
||||
def _is_valid_hhmm(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
parts = value.split(":")
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
hour, minute = parts
|
||||
if len(hour) != 2 or len(minute) != 2:
|
||||
return False
|
||||
if not hour.isdigit() or not minute.isdigit():
|
||||
return False
|
||||
h = int(hour)
|
||||
m = int(minute)
|
||||
return 0 <= h <= 23 and 0 <= m <= 59
|
||||
|
||||
|
||||
def _parse_retry_count(value: str) -> int:
|
||||
try:
|
||||
count = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ConfigError(f"RETRY_COUNT={value!r} must be a positive integer")
|
||||
if count < 0:
|
||||
raise ConfigError(f"RETRY_COUNT={value!r} must be >= 0")
|
||||
return count
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .config import ConfigError, load_config
|
||||
from .state import load_posted, save_posted
|
||||
|
||||
|
||||
log = logging.getLogger("tenbackward")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
except ConfigError as exc:
|
||||
log.error("configuration error: %s", exc)
|
||||
return 2
|
||||
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state = load_posted(config.data_dir)
|
||||
save_posted(config.data_dir, state)
|
||||
|
||||
log.info(
|
||||
"10backward v%s ready (site=%s, run_at=%s, tz=%s, hashtags=%s, prefix=%r)",
|
||||
__version__,
|
||||
config.site_url,
|
||||
config.run_at,
|
||||
config.tz,
|
||||
config.hashtags,
|
||||
config.throwback_prefix,
|
||||
)
|
||||
|
||||
log.warning(
|
||||
"post pipeline is not yet implemented; this run only validates the scaffold. "
|
||||
"Future job will clone the blog and post a throwback via Mastodon.py."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def posted_path(data_dir: Path) -> Path:
|
||||
return Path(data_dir) / "posted.json"
|
||||
|
||||
|
||||
def load_posted(data_dir: Path) -> dict[str, Any]:
|
||||
"""Return the posted-state map, creating an empty one if missing."""
|
||||
path = posted_path(data_dir)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def save_posted(data_dir: Path, state: dict[str, Any]) -> None:
|
||||
"""Persist the posted-state map atomically-ish."""
|
||||
path = posted_path(data_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as fh:
|
||||
json.dump(state, fh, sort_keys=True, indent=2)
|
||||
tmp.replace(path)
|
||||
Reference in New Issue
Block a user