Add config onboarding wizard and aggregated report
- `weekly-activity config`: interactive wizard writing ~/.config/weekly-activity.toml (XDG-aware, --config override), one section per source, 0600 permissions, tokens never echoed; re-runs show current settings and pre-fill defaults - `weekly-activity report` (and bare invocation): loads enabled sources, collects each independently, joins blocks under one header; a failing source renders as [skipped: <source> — error] without aborting - config.py: typed schema, tomllib reader with strict validation, hand-formatted TOML writer (stdlib-only, no new dependency) - aggregate.py: source spec building + failure-isolated collection + combined rendering - README: document config/report usage and config schema - tests/: 16 stdlib-unittest cases (config round-trip/escaping/validation, aggregation mapping/isolation)
This commit is contained in:
@@ -4,6 +4,17 @@ Aggregate weekly activity across development platforms (Launchpad, GitHub, GitLa
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive setup: pick sources, store identities/tokens once
|
||||||
|
uv run weekly-activity config
|
||||||
|
|
||||||
|
# Aggregated report across every configured source
|
||||||
|
uv run weekly-activity report # bare `weekly-activity` does the same
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single sources (ad-hoc, no config needed)
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Launchpad (uses system keyring for OAuth; --anonymous for public data only)
|
# Launchpad (uses system keyring for OAuth; --anonymous for public data only)
|
||||||
uv run weekly-activity launchpad <username>
|
uv run weekly-activity launchpad <username>
|
||||||
@@ -54,13 +65,49 @@ Any GitLab instance works via `--url`. Token discovery order:
|
|||||||
|
|
||||||
Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (ISO dates, UTC).
|
Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (ISO dates, UTC).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`weekly-activity config` walks through each known source and writes a TOML
|
||||||
|
file. Tokens are stored as provided (plaintext), but the file is created with
|
||||||
|
owner-only permissions (`0600`) and secrets are never echoed back. Use
|
||||||
|
`--config <path>` (also accepted by `report`) to keep configs elsewhere.
|
||||||
|
|
||||||
|
Default location: `${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[github]
|
||||||
|
username = "octocat"
|
||||||
|
token = "" # empty -> fall back to $GITHUB_TOKEN / `gh auth token`
|
||||||
|
|
||||||
|
[launchpad]
|
||||||
|
username = "jane"
|
||||||
|
mode = "credentials" # "anonymous" (public data only) | "credentials"
|
||||||
|
credentials_file = "" # empty -> system keyring; OAuth runs at report time
|
||||||
|
|
||||||
|
[gitlab]
|
||||||
|
url = "https://salsa.debian.org"
|
||||||
|
username = "jane"
|
||||||
|
token_env = "SALSA_TOKEN"
|
||||||
|
|
||||||
|
[bts]
|
||||||
|
email = "jane@example.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
An absent `[section]` disables that source. Re-running `weekly-activity
|
||||||
|
config` shows the current settings and offers to redo the wizard with the
|
||||||
|
existing values as defaults.
|
||||||
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
src/weekly_activity/
|
src/weekly_activity/
|
||||||
model.py ActivityReport, Section, last_week_window
|
model.py ActivityReport, Section, last_week_window
|
||||||
report.py format_report() — source-agnostic text renderer
|
report.py format_report() — source-agnostic text renderer
|
||||||
cli.py subcommand dispatch (launchpad | github | gitlab | bts)
|
cli.py subcommand dispatch (launchpad | github | gitlab | bts | report | config)
|
||||||
|
aggregate.py collect_specs()/format_combined() — one text report across sources
|
||||||
|
config.py ActivityConfig schema + TOML load/save (tomllib read, hand-written)
|
||||||
|
wizard.py interactive `config` onboarding
|
||||||
sources/
|
sources/
|
||||||
__init__.py ActivitySource protocol
|
__init__.py ActivitySource protocol
|
||||||
launchpad.py LaunchpadSource (launchpadlib)
|
launchpad.py LaunchpadSource (launchpadlib)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Combined report — build every configured source and render one text output.
|
||||||
|
|
||||||
|
Each enabled source runs independently: a failure (bad token, unreachable
|
||||||
|
API, unknown user) is recorded as a ``[skipped: <source> …]`` block instead
|
||||||
|
of aborting the whole report. Per-source warnings surfaced by ``collect``
|
||||||
|
are rendered inside the source's block by ``format_report``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from weekly_activity.config import ActivityConfig
|
||||||
|
from weekly_activity.model import ActivityReport
|
||||||
|
from weekly_activity.report import format_report
|
||||||
|
from weekly_activity.sources import ActivitySource
|
||||||
|
from weekly_activity.sources.debian_bts import DebianBtsSource
|
||||||
|
from weekly_activity.sources.github import GitHubSource
|
||||||
|
from weekly_activity.sources.gitlab import GitLabSource
|
||||||
|
from weekly_activity.sources.launchpad import LaunchpadSource
|
||||||
|
|
||||||
|
_RULE = "─" * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceSpec:
|
||||||
|
"""One enabled source: display name, identity to query, deferred constructor."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
make: Callable[[], ActivitySource]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Collected:
|
||||||
|
spec: SourceSpec
|
||||||
|
report: ActivityReport | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_specs(config: ActivityConfig) -> list[SourceSpec]:
|
||||||
|
"""Map configured sections onto constructible sources (network deferred)."""
|
||||||
|
specs: list[SourceSpec] = []
|
||||||
|
if (lp := config.launchpad) is not None:
|
||||||
|
anonymous = lp.mode == "anonymous"
|
||||||
|
credentials_file = os.path.expanduser(lp.credentials_file)
|
||||||
|
specs.append(
|
||||||
|
SourceSpec(
|
||||||
|
name="launchpad",
|
||||||
|
username=lp.username,
|
||||||
|
make=lambda: LaunchpadSource(
|
||||||
|
service=lp.service,
|
||||||
|
anonymous=anonymous,
|
||||||
|
credentials_file=credentials_file or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (gh := config.github) is not None:
|
||||||
|
token = gh.token
|
||||||
|
specs.append(
|
||||||
|
SourceSpec(
|
||||||
|
name="github",
|
||||||
|
username=gh.username,
|
||||||
|
make=lambda: GitHubSource(token=token or None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (gl := config.gitlab) is not None:
|
||||||
|
url, token, token_env = gl.url, gl.token, gl.token_env
|
||||||
|
specs.append(
|
||||||
|
SourceSpec(
|
||||||
|
name="gitlab",
|
||||||
|
username=gl.username,
|
||||||
|
make=lambda: GitLabSource(
|
||||||
|
url=url, token=token or None, token_env=token_env or None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (bts := config.bts) is not None:
|
||||||
|
email = bts.email
|
||||||
|
specs.append(SourceSpec(name="bts", username=email, make=DebianBtsSource))
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def collect_specs(specs: list[SourceSpec], since: datetime, until: datetime) -> list[Collected]:
|
||||||
|
"""Run every spec, converting any per-source failure into an error record."""
|
||||||
|
collected: list[Collected] = []
|
||||||
|
for spec in specs:
|
||||||
|
try:
|
||||||
|
source = spec.make()
|
||||||
|
report = source.collect(spec.username, since, until)
|
||||||
|
except Exception as exc: # noqa: BLE001 - one bad source must not kill the rest
|
||||||
|
collected.append(Collected(spec, error=f"{type(exc).__name__}: {exc}"))
|
||||||
|
else:
|
||||||
|
collected.append(Collected(spec, report=report))
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def format_combined(collected: list[Collected], since: datetime, until: datetime) -> str:
|
||||||
|
"""Render all blocks under one header; failed sources become skip notes."""
|
||||||
|
end_date = (until - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
header = (
|
||||||
|
f"Weekly activity report — {len(collected)} source(s) — "
|
||||||
|
f"{since.strftime('%Y-%m-%d')} .. {end_date} (UTC, inclusive)"
|
||||||
|
)
|
||||||
|
blocks: list[str] = []
|
||||||
|
for item in collected:
|
||||||
|
if item.report is not None:
|
||||||
|
blocks.append(format_report(item.report))
|
||||||
|
else:
|
||||||
|
detail = item.error or "unknown error"
|
||||||
|
blocks.append(f"[skipped: {item.spec.name} — {detail}]")
|
||||||
|
return ("\n\n" + _RULE + "\n\n").join([header, *blocks])
|
||||||
+74
-11
@@ -6,13 +6,17 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from weekly_activity.aggregate import build_specs, collect_specs, format_combined
|
||||||
|
from weekly_activity.config import ConfigError, default_config_path, load_config
|
||||||
from weekly_activity.model import last_week_window
|
from weekly_activity.model import last_week_window
|
||||||
from weekly_activity.report import format_report
|
from weekly_activity.report import format_report
|
||||||
from weekly_activity.sources.debian_bts import DebianBtsSource
|
from weekly_activity.sources.debian_bts import DebianBtsSource
|
||||||
from weekly_activity.sources.github import GitHubSource
|
from weekly_activity.sources.github import GitHubSource
|
||||||
from weekly_activity.sources.gitlab import GitLabSource
|
from weekly_activity.sources.gitlab import GitLabSource
|
||||||
from weekly_activity.sources.launchpad import LaunchpadSource
|
from weekly_activity.sources.launchpad import LaunchpadSource
|
||||||
|
from weekly_activity.wizard import run_wizard
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
@@ -26,13 +30,28 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
help="Inclusive end as an ISO date/datetime (UTC). Default: today.",
|
help="Inclusive end as an ISO date/datetime (UTC). Default: today.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
common = argparse.ArgumentParser(add_help=False)
|
||||||
|
common.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="Config file location. Default: ~/.config/weekly-activity.toml.",
|
||||||
|
)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="weekly-activity",
|
prog="weekly-activity",
|
||||||
description="Summarize a user's activity for last week (or a custom range).",
|
description=(
|
||||||
|
"Summarize dev-platform activity for a week - one source at a time,"
|
||||||
|
" or aggregated across everything configured."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(
|
||||||
|
dest="command",
|
||||||
|
required=False,
|
||||||
|
help="a single source, `report` for all configured ones, `config` to set up",
|
||||||
)
|
)
|
||||||
subparsers = parser.add_subparsers(dest="source", required=True)
|
|
||||||
|
|
||||||
lp = subparsers.add_parser("launchpad", parents=[base], help="Launchpad activity.")
|
lp = subparsers.add_parser("launchpad", parents=[base, common], help="Launchpad activity.")
|
||||||
lp.add_argument("username", help="Launchpad username (the ~name).")
|
lp.add_argument("username", help="Launchpad username (the ~name).")
|
||||||
lp.add_argument(
|
lp.add_argument(
|
||||||
"--anonymous",
|
"--anonymous",
|
||||||
@@ -46,13 +65,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
default="production",
|
default="production",
|
||||||
)
|
)
|
||||||
|
|
||||||
gh = subparsers.add_parser("github", parents=[base], help="GitHub activity.")
|
gh = subparsers.add_parser("github", parents=[base, common], help="GitHub activity.")
|
||||||
gh.add_argument("username", help="GitHub username.")
|
gh.add_argument("username", help="GitHub username.")
|
||||||
gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).")
|
gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).")
|
||||||
|
|
||||||
gl = subparsers.add_parser(
|
gl = subparsers.add_parser(
|
||||||
"gitlab",
|
"gitlab",
|
||||||
parents=[base],
|
parents=[base, common],
|
||||||
help="GitLab activity (gitlab.com or any self-hosted instance, e.g. Debian Salsa).",
|
help="GitLab activity (gitlab.com or any self-hosted instance, e.g. Debian Salsa).",
|
||||||
)
|
)
|
||||||
gl.add_argument("username", help="GitLab username.")
|
gl.add_argument("username", help="GitLab username.")
|
||||||
@@ -69,9 +88,22 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
help="Env var holding the API token (see README for discovery order).",
|
help="Env var holding the API token (see README for discovery order).",
|
||||||
)
|
)
|
||||||
|
|
||||||
bts = subparsers.add_parser("bts", parents=[base], help="Debian Bug Tracking System activity.")
|
bts = subparsers.add_parser(
|
||||||
|
"bts", parents=[base, common], help="Debian Bug Tracking System activity."
|
||||||
|
)
|
||||||
bts.add_argument("username", help="Email address used on Debian bugs (submitter/owner).")
|
bts.add_argument("username", help="Email address used on Debian bugs (submitter/owner).")
|
||||||
|
|
||||||
|
subparsers.add_parser(
|
||||||
|
"report",
|
||||||
|
parents=[base, common],
|
||||||
|
help="Aggregate activity from every source in the config file.",
|
||||||
|
)
|
||||||
|
subparsers.add_parser(
|
||||||
|
"config",
|
||||||
|
parents=[common],
|
||||||
|
help="Interactive wizard that writes the config file.",
|
||||||
|
)
|
||||||
|
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,22 +130,46 @@ def resolve_period(args: argparse.Namespace) -> tuple[datetime, datetime]:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
command = args.command or "report"
|
||||||
|
|
||||||
|
if command == "config":
|
||||||
|
run_wizard(_config_path(args))
|
||||||
|
return
|
||||||
|
|
||||||
|
if command == "report":
|
||||||
|
path = _config_path(args)
|
||||||
|
try:
|
||||||
|
config = load_config(path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
f"error: no configuration found at {path}; run `weekly-activity config` first."
|
||||||
|
) from exc
|
||||||
|
except ConfigError as exc:
|
||||||
|
raise SystemExit(f"error: {exc}") from exc
|
||||||
|
specs = build_specs(config)
|
||||||
|
if not specs:
|
||||||
|
raise SystemExit(f"error: {path} enables no sources; run `weekly-activity config`.")
|
||||||
|
since, until = resolve_period(args)
|
||||||
|
collected = collect_specs(specs, since, until)
|
||||||
|
print(format_combined(collected, since, until))
|
||||||
|
return
|
||||||
|
|
||||||
since, until = resolve_period(args)
|
since, until = resolve_period(args)
|
||||||
|
|
||||||
if args.source == "launchpad":
|
if command == "launchpad":
|
||||||
source = LaunchpadSource(
|
source = LaunchpadSource(
|
||||||
service=args.service,
|
service=args.service,
|
||||||
anonymous=args.anonymous,
|
anonymous=args.anonymous,
|
||||||
credentials_file=args.credentials_file,
|
credentials_file=args.credentials_file,
|
||||||
)
|
)
|
||||||
elif args.source == "github":
|
elif command == "github":
|
||||||
source = GitHubSource(token=args.token)
|
source = GitHubSource(token=args.token)
|
||||||
elif args.source == "gitlab":
|
elif command == "gitlab":
|
||||||
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
|
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
|
||||||
elif args.source == "bts":
|
elif command == "bts":
|
||||||
source = DebianBtsSource()
|
source = DebianBtsSource()
|
||||||
else:
|
else:
|
||||||
raise SystemExit(f"error: unknown source {args.source!r}")
|
raise SystemExit(f"error: unknown source {command!r}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
report = source.collect(args.username, since, until)
|
report = source.collect(args.username, since, until)
|
||||||
@@ -126,5 +182,12 @@ def main() -> None:
|
|||||||
print(format_report(report))
|
print(format_report(report))
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path(args: argparse.Namespace) -> Path:
|
||||||
|
"""Resolve this run's config location (--config override or XDG default)."""
|
||||||
|
if args.config:
|
||||||
|
return Path(args.config).expanduser()
|
||||||
|
return default_config_path()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Config file: schema, discovery, loading, and persistence.
|
||||||
|
|
||||||
|
Stored as TOML at ``${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml``
|
||||||
|
(override with ``--config``). One section per source; an absent section
|
||||||
|
means the source is not enabled.
|
||||||
|
|
||||||
|
Tokens are stored as provided (plaintext) in the user's own local config;
|
||||||
|
the file is created with ``0600`` permissions so they are not world-readable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "weekly-activity.toml"
|
||||||
|
|
||||||
|
_LAUNCHPAD_MODES = frozenset({"anonymous", "credentials"})
|
||||||
|
_LAUNCHPAD_SERVICES = frozenset({"production", "staging"})
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""The config file exists but is malformed or has unknown entries."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GithubSettings:
|
||||||
|
username: str
|
||||||
|
token: str = "" # empty -> fall back to GITHUB_TOKEN / `gh auth token`
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LaunchpadSettings:
|
||||||
|
username: str
|
||||||
|
mode: str = "anonymous" # "anonymous" | "credentials"
|
||||||
|
credentials_file: str = "" # used in "credentials" mode; empty -> keyring
|
||||||
|
service: str = "production"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GitlabSettings:
|
||||||
|
username: str
|
||||||
|
url: str = "https://gitlab.com"
|
||||||
|
token: str = "" # empty -> env discovery (<HOST>_TOKEN, GITLAB_TOKEN)
|
||||||
|
token_env: str = "" # optional named var, takes precedence in discovery
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BtsSettings:
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ActivityConfig:
|
||||||
|
"""All configured sources; ``None`` section = source disabled."""
|
||||||
|
|
||||||
|
launchpad: LaunchpadSettings | None = None
|
||||||
|
github: GithubSettings | None = None
|
||||||
|
gitlab: GitlabSettings | None = None
|
||||||
|
bts: BtsSettings | None = None
|
||||||
|
|
||||||
|
def enabled_sources(self) -> list[str]:
|
||||||
|
"""Names of configured source sections, in stable report order."""
|
||||||
|
ordered: list[tuple[str, bool]] = [
|
||||||
|
("launchpad", self.launchpad is not None),
|
||||||
|
("github", self.github is not None),
|
||||||
|
("gitlab", self.gitlab is not None),
|
||||||
|
("bts", self.bts is not None),
|
||||||
|
]
|
||||||
|
return [name for name, on in ordered if on]
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_path() -> Path:
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||||
|
return base / CONFIG_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
# --- Loading ---
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> ActivityConfig:
|
||||||
|
"""Parse the TOML config at ``path``.
|
||||||
|
|
||||||
|
Raises ``FileNotFoundError`` when the file does not exist and
|
||||||
|
``ConfigError`` when it cannot be parsed or contains unknown entries.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
data = tomllib.loads(text)
|
||||||
|
except tomllib.TOMLDecodeError as exc:
|
||||||
|
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
||||||
|
|
||||||
|
where = str(path)
|
||||||
|
_reject_unknown_top_level(data, where)
|
||||||
|
return ActivityConfig(
|
||||||
|
launchpad=_parse_launchpad(data.get("launchpad"), where) if "launchpad" in data else None,
|
||||||
|
github=_parse_github(data.get("github"), where) if "github" in data else None,
|
||||||
|
gitlab=_parse_gitlab(data.get("gitlab"), where) if "gitlab" in data else None,
|
||||||
|
bts=_parse_bts(data.get("bts"), where) if "bts" in data else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unknown_top_level(data: dict[str, object], where: str) -> None:
|
||||||
|
unknown = sorted(set(data) - {"launchpad", "github", "gitlab", "bts"})
|
||||||
|
if unknown:
|
||||||
|
raise ConfigError(f"{where}: unknown config section(s): {', '.join(unknown)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_keys(table: dict[str, object], names: set[str], section: str) -> None:
|
||||||
|
unknown = sorted(set(table) - names)
|
||||||
|
if unknown:
|
||||||
|
raise ConfigError(f"[{section}]: unknown key(s): {', '.join(unknown)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _required_str(table: dict[str, object], key: str, section: str) -> str:
|
||||||
|
value = table[key]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigError(f"[{section}] {key} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_str(table: dict[str, object], key: str, section: str) -> str:
|
||||||
|
value = table[key]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigError(f"[{section}] {key} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _one_of(value: str, allowed: frozenset[str], section: str, key: str) -> str:
|
||||||
|
if value not in allowed:
|
||||||
|
choices = ", ".join(sorted(allowed))
|
||||||
|
raise ConfigError(f"[{section}] {key} must be one of: {choices}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_github(raw: object, where: str) -> GithubSettings:
|
||||||
|
del where
|
||||||
|
table = _as_table(raw, "github")
|
||||||
|
_check_keys(table, {"username", "token"}, "github")
|
||||||
|
return GithubSettings(
|
||||||
|
username=_required_str(table, "username", "github"),
|
||||||
|
token=_optional_str(table, "token", "github") if "token" in table else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_launchpad(raw: object, where: str) -> LaunchpadSettings:
|
||||||
|
del where
|
||||||
|
table = _as_table(raw, "launchpad")
|
||||||
|
_check_keys(table, {"username", "mode", "credentials_file", "service"}, "launchpad")
|
||||||
|
mode = _one_of(
|
||||||
|
_optional_str(table, "mode", "launchpad") if "mode" in table else "anonymous",
|
||||||
|
_LAUNCHPAD_MODES,
|
||||||
|
"launchpad",
|
||||||
|
"mode",
|
||||||
|
)
|
||||||
|
service = _one_of(
|
||||||
|
_optional_str(table, "service", "launchpad") if "service" in table else "production",
|
||||||
|
_LAUNCHPAD_SERVICES,
|
||||||
|
"launchpad",
|
||||||
|
"service",
|
||||||
|
)
|
||||||
|
return LaunchpadSettings(
|
||||||
|
username=_required_str(table, "username", "launchpad"),
|
||||||
|
mode=mode,
|
||||||
|
credentials_file=(
|
||||||
|
_optional_str(table, "credentials_file", "launchpad")
|
||||||
|
if "credentials_file" in table
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
service=service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gitlab(raw: object, where: str) -> GitlabSettings:
|
||||||
|
del where
|
||||||
|
table = _as_table(raw, "gitlab")
|
||||||
|
_check_keys(table, {"username", "url", "token", "token_env"}, "gitlab")
|
||||||
|
return GitlabSettings(
|
||||||
|
username=_required_str(table, "username", "gitlab"),
|
||||||
|
url=(_optional_str(table, "url", "gitlab") if "url" in table else "https://gitlab.com"),
|
||||||
|
token=_optional_str(table, "token", "gitlab") if "token" in table else "",
|
||||||
|
token_env=_optional_str(table, "token_env", "gitlab") if "token_env" in table else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bts(raw: object, where: str) -> BtsSettings:
|
||||||
|
del where
|
||||||
|
table = _as_table(raw, "bts")
|
||||||
|
_check_keys(table, {"email"}, "bts")
|
||||||
|
return BtsSettings(email=_required_str(table, "email", "bts"))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_table(raw: object, section: str) -> dict[str, object]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError(f"[{section}] must be a TOML table")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# --- Saving (hand-formatted: the schema is flat and string-only) ---
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: ActivityConfig, path: Path) -> None:
|
||||||
|
"""Serialize ``config`` to TOML at ``path`` with owner-only permissions."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(format_toml(config))
|
||||||
|
os.chmod(path, 0o600) # enforce even when overwriting a pre-existing file
|
||||||
|
|
||||||
|
|
||||||
|
def format_toml(config: ActivityConfig) -> str:
|
||||||
|
lines = [
|
||||||
|
"# weekly-activity configuration.",
|
||||||
|
"# Generated by `weekly-activity config`; edit by hand if you prefer.",
|
||||||
|
"# Tokens are stored in plaintext and readable only by your user (0600).",
|
||||||
|
"# Run `weekly-activity report` to aggregate every enabled source below.",
|
||||||
|
]
|
||||||
|
if config.launchpad is not None:
|
||||||
|
lp = config.launchpad
|
||||||
|
lines += ["", "[launchpad]", f"username = {_toml(lp.username)}"]
|
||||||
|
if lp.mode != "anonymous":
|
||||||
|
lines.append(f"mode = {_toml(lp.mode)}")
|
||||||
|
if lp.credentials_file:
|
||||||
|
lines.append(f"credentials_file = {_toml(lp.credentials_file)}")
|
||||||
|
if lp.service != "production":
|
||||||
|
lines.append(f"service = {_toml(lp.service)}")
|
||||||
|
if config.github is not None:
|
||||||
|
gh = config.github
|
||||||
|
lines += ["", "[github]", f"username = {_toml(gh.username)}"]
|
||||||
|
if gh.token:
|
||||||
|
lines.append(f"token = {_toml(gh.token)}")
|
||||||
|
if config.gitlab is not None:
|
||||||
|
gl = config.gitlab
|
||||||
|
lines += ["", "[gitlab]", f"username = {_toml(gl.username)}"]
|
||||||
|
if gl.url != "https://gitlab.com":
|
||||||
|
lines.append(f"url = {_toml(gl.url)}")
|
||||||
|
if gl.token:
|
||||||
|
lines.append(f"token = {_toml(gl.token)}")
|
||||||
|
if gl.token_env:
|
||||||
|
lines.append(f"token_env = {_toml(gl.token_env)}")
|
||||||
|
if config.bts is not None:
|
||||||
|
lines += ["", "[bts]", f"email = {_toml(config.bts.email)}"]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
_TOML_ESCAPES = {
|
||||||
|
"\\": "\\\\",
|
||||||
|
'"': '\\"',
|
||||||
|
"\b": "\\b",
|
||||||
|
"\t": "\\t",
|
||||||
|
"\n": "\\n",
|
||||||
|
"\f": "\\f",
|
||||||
|
"\r": "\\r",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _toml(value: str) -> str:
|
||||||
|
"""Render a TOML basic string, escaping quotes and control characters."""
|
||||||
|
chars: list[str] = []
|
||||||
|
for ch in value:
|
||||||
|
if ch in _TOML_ESCAPES:
|
||||||
|
chars.append(_TOML_ESCAPES[ch])
|
||||||
|
elif ord(ch) < 0x20 or ord(ch) == 0x7F:
|
||||||
|
chars.append(f"\\u{ord(ch):04X}")
|
||||||
|
else:
|
||||||
|
chars.append(ch)
|
||||||
|
return '"' + "".join(chars) + '"'
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Interactive onboarding wizard behind `weekly-activity config`.
|
||||||
|
|
||||||
|
Asks which sources to enable and the identity/auth details for each, then
|
||||||
|
writes the TOML config. Never echoes token values back to the console; does
|
||||||
|
not run any OAuth flow — for Launchpad credentials mode it only records the
|
||||||
|
choice (launchpadlib's keyring flow runs at report time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from weekly_activity.config import (
|
||||||
|
ActivityConfig,
|
||||||
|
BtsSettings,
|
||||||
|
ConfigError,
|
||||||
|
GithubSettings,
|
||||||
|
GitlabSettings,
|
||||||
|
LaunchpadSettings,
|
||||||
|
load_config,
|
||||||
|
save_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_wizard(config_path: Path) -> None:
|
||||||
|
"""Walk through source setup and write ``config_path``."""
|
||||||
|
existing = _load_existing(config_path)
|
||||||
|
if existing.enabled_sources():
|
||||||
|
print(f"Found an existing configuration at {config_path}:")
|
||||||
|
print(_describe(existing, indent=" "))
|
||||||
|
if not _confirm("Re-run setup?"):
|
||||||
|
print("Keeping the current configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Welcome! This wizard sets up the sources aggregated by")
|
||||||
|
print("`weekly-activity report`. Answer with Enter to accept [defaults],")
|
||||||
|
print("or Ctrl-C to abort without writing anything.")
|
||||||
|
|
||||||
|
config = ActivityConfig(
|
||||||
|
launchpad=_configure_launchpad(existing.launchpad),
|
||||||
|
github=_configure_github(existing.github),
|
||||||
|
gitlab=_configure_gitlab(existing.gitlab),
|
||||||
|
bts=_configure_bts(existing.bts),
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled = config.enabled_sources()
|
||||||
|
if not enabled:
|
||||||
|
raise SystemExit("warning: no source selected; nothing was written.")
|
||||||
|
|
||||||
|
save_config(config, config_path)
|
||||||
|
print()
|
||||||
|
print(f"Configuration written to {config_path}.")
|
||||||
|
print(f"Enabled sources: {', '.join(enabled)}.")
|
||||||
|
print(_describe(config, indent=""))
|
||||||
|
print("Tokens are never printed back. Run `weekly-activity report` to")
|
||||||
|
print("aggregate activity across all of them in one output.")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_existing(path: Path) -> ActivityConfig:
|
||||||
|
try:
|
||||||
|
return load_config(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ActivityConfig()
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"warning: ignoring unusable existing config: {exc}", file=sys.stderr)
|
||||||
|
print(f"warning: {path} will be replaced by a fresh configuration.", file=sys.stderr)
|
||||||
|
return ActivityConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Per-source prompts ---
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_launchpad(prev: LaunchpadSettings | None) -> LaunchpadSettings | None:
|
||||||
|
if not _confirm("Add Launchpad?", default=False):
|
||||||
|
return None
|
||||||
|
print("Launchpad identity is the ~name shown on your profile page.")
|
||||||
|
username = _ask_required("Launchpad username", prev.username if prev else "")
|
||||||
|
anonymous = _confirm(
|
||||||
|
"Anonymous mode (public data only, no OAuth)?",
|
||||||
|
default=prev.mode == "anonymous" if prev else True,
|
||||||
|
)
|
||||||
|
settings = LaunchpadSettings(
|
||||||
|
username=username, mode="anonymous" if anonymous else "credentials"
|
||||||
|
)
|
||||||
|
if not anonymous:
|
||||||
|
print(
|
||||||
|
"OAuth is obtained at report time: launchpadlib opens a browser and\n"
|
||||||
|
"stores the token in your system keyring, or reads it from a file."
|
||||||
|
)
|
||||||
|
settings.credentials_file = _ask(
|
||||||
|
"Credentials file (empty to use the system keyring)",
|
||||||
|
prev.credentials_file if prev else "",
|
||||||
|
)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_github(prev: GithubSettings | None) -> GithubSettings | None:
|
||||||
|
if not _confirm("Add GitHub?", default=False):
|
||||||
|
return None
|
||||||
|
username = _ask_required("GitHub username", prev.username if prev else "")
|
||||||
|
print("Leave the token empty to fall back to $GITHUB_TOKEN or `gh auth token`.")
|
||||||
|
token = _ask_secret("GitHub token (input hidden)", prev.token if prev else "")
|
||||||
|
return GithubSettings(username=username, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_gitlab(prev: GitlabSettings | None) -> GitlabSettings | None:
|
||||||
|
if not _confirm("Add GitLab?", default=False):
|
||||||
|
return None
|
||||||
|
defaults = prev or GitlabSettings(username="")
|
||||||
|
username = _ask_required("GitLab username", defaults.username)
|
||||||
|
url = _ask("Instance URL", defaults.url or "https://gitlab.com")
|
||||||
|
print("Leave the token empty to discover it from the environment instead.")
|
||||||
|
token = _ask_secret("API token (input hidden)", defaults.token)
|
||||||
|
token_env = _ask("Env var holding the token (optional, e.g. SALSA_TOKEN)", defaults.token_env)
|
||||||
|
return GitlabSettings(username=username, url=url, token=token, token_env=token_env)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_bts(prev: BtsSettings | None) -> BtsSettings | None:
|
||||||
|
if not _confirm("Add Debian BTS?", default=False):
|
||||||
|
return None
|
||||||
|
print("The BTS identifies people by the email used on bugs (submitter/owner).")
|
||||||
|
email = _ask_required("Email address on Debian bugs", prev.email if prev else "")
|
||||||
|
return BtsSettings(email=email)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prompt helpers ---
|
||||||
|
|
||||||
|
|
||||||
|
def _ask(prompt: str, default: str = "") -> str:
|
||||||
|
suffix = f" [{default}]" if default else ""
|
||||||
|
answer = input(f"{prompt}{suffix}: ").strip()
|
||||||
|
return answer or default
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_required(prompt: str, default: str = "") -> str:
|
||||||
|
while True:
|
||||||
|
answer = _ask(prompt, default)
|
||||||
|
if answer:
|
||||||
|
return answer
|
||||||
|
print("A value is required here.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_secret(prompt: str, default: str = "") -> str:
|
||||||
|
"""Read a secret without echo; keep the stored one when input is empty."""
|
||||||
|
try:
|
||||||
|
entered = getpass.getpass(f"{prompt}: ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
raise SystemExit(130) from None
|
||||||
|
return entered.strip() or default
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm(prompt: str, *, default: bool = False) -> bool:
|
||||||
|
hint = "[Y/n]" if default else "[y/N]"
|
||||||
|
while True:
|
||||||
|
answer = input(f"{prompt} {hint}: ").strip().lower()
|
||||||
|
if not answer:
|
||||||
|
return default
|
||||||
|
if answer in {"y", "yes"}:
|
||||||
|
return True
|
||||||
|
if answer in {"n", "no"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _describe(config: ActivityConfig, indent: str = " ") -> str:
|
||||||
|
"""Human-readable summary that masks every token value."""
|
||||||
|
lines: list[str] = []
|
||||||
|
if (lp := config.launchpad) is not None:
|
||||||
|
auth = (
|
||||||
|
"anonymous (public data only)"
|
||||||
|
if lp.mode == "anonymous"
|
||||||
|
else f"OAuth via {lp.credentials_file or 'system keyring'}"
|
||||||
|
)
|
||||||
|
lines.append(f"{indent}launchpad: {lp.username} — {auth}")
|
||||||
|
if (gh := config.github) is not None:
|
||||||
|
token_desc = "stored" if gh.token else "not stored (uses GITHUB_TOKEN / gh)"
|
||||||
|
lines.append(f"{indent}github: {gh.username} — token {token_desc}")
|
||||||
|
if (gl := config.gitlab) is not None:
|
||||||
|
fallback = gl.token_env or "uses <HOST>_TOKEN / GITLAB_TOKEN env"
|
||||||
|
token_desc = "stored" if gl.token else f"not stored ({fallback})"
|
||||||
|
lines.append(f"{indent}gitlab: {gl.username} @ {gl.url} — token {token_desc}")
|
||||||
|
if (bts := config.bts) is not None:
|
||||||
|
lines.append(f"{indent}bts: {bts.email}")
|
||||||
|
return "\n".join(lines) if lines else f"{indent}(no sources configured)"
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Unit tests for weekly_activity.aggregate — stubbed sources, no network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from weekly_activity.aggregate import (
|
||||||
|
SourceSpec,
|
||||||
|
build_specs,
|
||||||
|
collect_specs,
|
||||||
|
format_combined,
|
||||||
|
)
|
||||||
|
from weekly_activity.config import (
|
||||||
|
ActivityConfig,
|
||||||
|
BtsSettings,
|
||||||
|
GithubSettings,
|
||||||
|
GitlabSettings,
|
||||||
|
LaunchpadSettings,
|
||||||
|
)
|
||||||
|
from weekly_activity.model import ActivityReport, Section
|
||||||
|
|
||||||
|
|
||||||
|
class StubSource:
|
||||||
|
def __init__(self, name: str = "stub", *, fail: bool = False) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._fail = fail
|
||||||
|
|
||||||
|
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport:
|
||||||
|
if self._fail:
|
||||||
|
raise RuntimeError("kaput")
|
||||||
|
return ActivityReport(
|
||||||
|
source=self.name,
|
||||||
|
username=username,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
sections=[Section("Things", ["did a thing"])],
|
||||||
|
warnings=["some query failed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def window() -> tuple[datetime, datetime]:
|
||||||
|
return datetime(2026, 8, 19), datetime(2026, 8, 26)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildSpecsTest(unittest.TestCase):
|
||||||
|
def test_maps_every_section_in_order(self) -> None:
|
||||||
|
config = ActivityConfig(
|
||||||
|
launchpad=LaunchpadSettings(username="lp"),
|
||||||
|
github=GithubSettings(username="gh"),
|
||||||
|
gitlab=GitlabSettings(username="gl"),
|
||||||
|
bts=BtsSettings(email="bts@mail"),
|
||||||
|
)
|
||||||
|
specs = build_specs(config)
|
||||||
|
self.assertEqual([s.name for s in specs], ["launchpad", "github", "gitlab", "bts"])
|
||||||
|
self.assertEqual([s.username for s in specs], ["lp", "gh", "gl", "bts@mail"])
|
||||||
|
|
||||||
|
def test_disabled_sections_are_skipped(self) -> None:
|
||||||
|
config = ActivityConfig(github=GithubSettings(username="only-one"))
|
||||||
|
self.assertEqual([s.name for s in build_specs(config)], ["github"])
|
||||||
|
|
||||||
|
def test_launchpad_spec_expands_tilde_credentials_file(self) -> None:
|
||||||
|
settings = LaunchpadSettings(
|
||||||
|
username="u", mode="credentials", credentials_file="~/creds.json"
|
||||||
|
)
|
||||||
|
spec = build_specs(ActivityConfig(launchpad=settings))[0]
|
||||||
|
with mock.patch("weekly_activity.aggregate.LaunchpadSource") as fake_source:
|
||||||
|
spec.make()
|
||||||
|
kwargs = fake_source.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["credentials_file"], os.path.expanduser("~") + "/creds.json")
|
||||||
|
self.assertFalse(kwargs["anonymous"])
|
||||||
|
|
||||||
|
|
||||||
|
class CollectAndFormatTest(unittest.TestCase):
|
||||||
|
def test_failure_isolated_per_source(self) -> None:
|
||||||
|
since, until = window()
|
||||||
|
specs = [
|
||||||
|
SourceSpec(name="alpha", username="a", make=lambda: StubSource("alpha")),
|
||||||
|
SourceSpec(name="beta", username="b", make=lambda: StubSource("beta", fail=True)),
|
||||||
|
]
|
||||||
|
collected = collect_specs(specs, since, until)
|
||||||
|
self.assertIsNotNone(collected[0].report)
|
||||||
|
self.assertIn("RuntimeError: kaput", collected[1].error or "")
|
||||||
|
|
||||||
|
text = format_combined(collected, since, until)
|
||||||
|
self.assertIn("Weekly activity report — 2 source(s)", text)
|
||||||
|
self.assertIn("Activity report — alpha / ~a", text)
|
||||||
|
self.assertIn("[skipped: beta — RuntimeError: kaput]", text)
|
||||||
|
|
||||||
|
def test_warning_surfaced_inside_block(self) -> None:
|
||||||
|
since, until = window()
|
||||||
|
specs = [SourceSpec(name="gamma", username="g", make=lambda: StubSource("gamma"))]
|
||||||
|
text = format_combined(collect_specs(specs, since, until), since, until)
|
||||||
|
self.assertIn("Warnings (partial data — some queries failed):", text)
|
||||||
|
self.assertIn("some query failed", text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Unit tests for weekly_activity.config — pure logic, no network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from weekly_activity.config import (
|
||||||
|
ActivityConfig,
|
||||||
|
BtsSettings,
|
||||||
|
ConfigError,
|
||||||
|
GithubSettings,
|
||||||
|
GitlabSettings,
|
||||||
|
LaunchpadSettings,
|
||||||
|
default_config_path,
|
||||||
|
format_toml,
|
||||||
|
load_config,
|
||||||
|
save_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRoundTripTest(unittest.TestCase):
|
||||||
|
def test_round_trip_preserves_all_sections(self) -> None:
|
||||||
|
config = ActivityConfig(
|
||||||
|
launchpad=LaunchpadSettings(
|
||||||
|
username="lp-user",
|
||||||
|
mode="credentials",
|
||||||
|
credentials_file="/tmp/lp-creds.json",
|
||||||
|
service="staging",
|
||||||
|
),
|
||||||
|
github=GithubSettings(username='gh"user', token="tok\\en"),
|
||||||
|
gitlab=GitlabSettings(
|
||||||
|
username="gl-user",
|
||||||
|
url="https://salsa.debian.org",
|
||||||
|
token_env="SALSA_TOKEN",
|
||||||
|
),
|
||||||
|
bts=BtsSettings(email="dev@example.org"),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "weekly-activity.toml"
|
||||||
|
save_config(config, path)
|
||||||
|
self.assertEqual(load_config(path), config)
|
||||||
|
|
||||||
|
def test_save_creates_owner_only_file(self) -> None:
|
||||||
|
config = ActivityConfig(bts=BtsSettings(email="x@example.org"))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "nested" / "cfg.toml"
|
||||||
|
save_config(config, path)
|
||||||
|
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_empty_optionals_round_trip_to_defaults(self) -> None:
|
||||||
|
config = ActivityConfig(
|
||||||
|
github=GithubSettings(username="octocat"),
|
||||||
|
bts=BtsSettings(email="a@b.c"),
|
||||||
|
)
|
||||||
|
text = format_toml(config)
|
||||||
|
reparsed = load_config_from_text(text)
|
||||||
|
github = reparsed.github
|
||||||
|
assert github is not None
|
||||||
|
self.assertEqual(github.username, "octocat")
|
||||||
|
self.assertEqual(github.token, "")
|
||||||
|
self.assertIsNone(reparsed.launchpad)
|
||||||
|
|
||||||
|
def test_escapes_quotes_backslashes_and_newlines(self) -> None:
|
||||||
|
config = ActivityConfig(github=GithubSettings(username='a"b\\c\nd'))
|
||||||
|
reparsed = load_config_from_text(format_toml(config))
|
||||||
|
github = reparsed.github
|
||||||
|
assert github is not None
|
||||||
|
self.assertEqual(github.username, 'a"b\\c\nd')
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoadingTest(unittest.TestCase):
|
||||||
|
def test_missing_file_raises_filenotfound(self) -> None:
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
load_config(Path("/nonexistent/weekly-activity.toml"))
|
||||||
|
|
||||||
|
def test_unknown_section_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
load_config_from_text("[gitea]\nusername = 'x'\n")
|
||||||
|
|
||||||
|
def test_unknown_key_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
load_config_from_text('[github]\nusername = "x"\ntokken = "t"\n')
|
||||||
|
|
||||||
|
def test_non_string_value_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
load_config_from_text("[github]\nusername = 42\n")
|
||||||
|
|
||||||
|
def test_bad_launchpad_mode_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
load_config_from_text('[launchpad]\nusername = "u"\nmode = "oauth2"\n')
|
||||||
|
|
||||||
|
def test_enabled_sources_stable_order(self) -> None:
|
||||||
|
config = load_config_from_text(
|
||||||
|
'[bts]\nemail = "e"\n\n[github]\nusername = "g"\n'
|
||||||
|
)
|
||||||
|
self.assertEqual(config.enabled_sources(), ["github", "bts"])
|
||||||
|
|
||||||
|
def test_default_path_uses_xdg_or_home(self) -> None:
|
||||||
|
old = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
try:
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = "/custom/cfg"
|
||||||
|
self.assertEqual(default_config_path(), Path("/custom/cfg/weekly-activity.toml"))
|
||||||
|
del os.environ["XDG_CONFIG_HOME"]
|
||||||
|
self.assertTrue(str(default_config_path()).endswith("/.config/weekly-activity.toml"))
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = old
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_from_text(text: str) -> ActivityConfig:
|
||||||
|
"""Helper: parse TOML through the public loader without touching disk."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "weekly-activity.toml"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return load_config(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user