From 5f4487f3ade49ff0abecb119cad9ed5400ca73a5 Mon Sep 17 00:00:00 2001 From: Kosmos Date: Wed, 26 Aug 2026 22:53:17 +0000 Subject: [PATCH] feat(config): menu-driven multi-account wizard, v2 schema, config-time Launchpad OAuth Address review of PR #1 (Valentin): replace the linear config walk-through and single-section schema. - weekly-activity config becomes a menu loop: Current sources (masked) -> Add a new source (pick kind, repeatable) / Remove a source / Exit; the file is written only when something changed - new versioned config layout (version = 2): [[kind.accounts]] arrays so several accounts or instances per provider coexist; optional display labels; strict unknown-key/type validation kept (errors carry account position, e.g. [github.accounts[1]]) - legacy v1 files (no version key, one section per source) migrate transparently in memory on load; saving from the wizard persists v2; the file itself is never rewritten by load_config - non-anonymous Launchpad accounts now authenticate at config time: constructing LaunchpadSource runs launchpadlib's browser OAuth and the token lands in the system keyring before any report; an explicit credentials_file stays supported as an additive opt-out - report aggregates every configured account in stable order with failure isolation intact; headings add the account label when it disambiguates (source_label shared between aggregate and wizard) - GitHub accounts gain optional token_env resolved lazily against the environment before falling back to GITHUB_TOKEN / `gh auth token` --- README.md | 49 +++-- src/weekly_activity/aggregate.py | 120 ++++++++--- src/weekly_activity/config.py | 257 ++++++++++++++++------- src/weekly_activity/wizard.py | 338 ++++++++++++++++++++++--------- tests/test_aggregate.py | 94 +++++++-- tests/test_config.py | 204 +++++++++++++++---- 6 files changed, 801 insertions(+), 261 deletions(-) diff --git a/README.md b/README.md index 1b812ee..6951bda 100644 --- a/README.md +++ b/README.md @@ -67,35 +67,54 @@ Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (IS ## 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 ` (also accepted by `report`) to keep configs elsewhere. +`weekly-activity config` opens a small menu-driven editor: it lists your +current sources (secrets masked), then lets you **Add a new source** +(choose the kind, repeat as often as you like), **Remove a source**, or +**Exit** (writes the file only when something changed). Several accounts +of the same provider can live side by side. Tokens are stored as provided +(plaintext), but the file is created with owner-only permissions (`0600`) +and secrets are never echoed back. Use `--config ` (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` +version = 2 -[launchpad] +[[launchpad.accounts]] username = "jane" -mode = "credentials" # "anonymous" (public data only) | "credentials" -credentials_file = "" # empty -> system keyring; OAuth runs at report time +mode = "credentials" # "anonymous" (public data only) | "credentials" +# credentials_file = "~/lp-creds.json" # omit -> system keyring -[gitlab] +[[github.accounts]] +name = "personal" # optional display label +username = "octocat" +token = "" # empty -> $token_env / GITHUB_TOKEN / `gh auth token` + +[[github.accounts]] +name = "work" +username = "acme-jane" +token_env = "WORK_GH_TOKEN" + +[[gitlab.accounts]] +name = "salsa" url = "https://salsa.debian.org" username = "jane" token_env = "SALSA_TOKEN" -[bts] +[[bts.accounts]] 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. +An absent section disables that source; an empty one is not written. +When a non-anonymous Launchpad account is added without a credentials +file, the wizard runs launchpadlib's OAuth right there — a browser opens +once and the token is kept in your system keyring, so `report` never has +to authenticate. + +Legacy configs without the `version` key (one single-account section per +source) keep working: they are migrated transparently when loaded and +rewritten in the v2 shape the next time the wizard saves. ## Architecture diff --git a/src/weekly_activity/aggregate.py b/src/weekly_activity/aggregate.py index 1333281..0886344 100644 --- a/src/weekly_activity/aggregate.py +++ b/src/weekly_activity/aggregate.py @@ -1,16 +1,16 @@ -"""Combined report — build every configured source and render one text output. +"""Combined report — build every configured account and render one text output. -Each enabled source runs independently: a failure (bad token, unreachable -API, unknown user) is recorded as a ``[skipped: …]`` block instead -of aborting the whole report. Per-source warnings surfaced by ``collect`` -are rendered inside the source's block by ``format_report``. +Each configured account runs independently: a failure (bad token, +unreachable API, unknown user) is recorded as a ``[skipped: …]`` +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 dataclasses import dataclass, replace from datetime import datetime, timedelta from weekly_activity.config import ActivityConfig @@ -27,7 +27,12 @@ _RULE = "─" * 60 @dataclass(frozen=True, slots=True) class SourceSpec: - """One enabled source: display name, identity to query, deferred constructor.""" + """One configured account: display name, identity to query, deferred constructor. + + ``name`` carries the report-heading label: the source kind plus the + account's ``name`` (or identity) whenever more than one account of that + kind exists and disambiguation helps. + """ name: str username: str @@ -41,49 +46,101 @@ class Collected: error: str | None = None +def source_label(kind: str, name: str, identity: str, ambiguous: bool) -> str: + """Heading label for an account: the source kind, disambiguated when needed. + + An explicit ``name`` label always shows; otherwise sibling accounts of + the same kind force the identity (username/email) into the label. Shared + with the wizard so menus and report headings never drift apart. + """ + if name: + return f"{kind}/{name}" + if ambiguous and identity: + return f"{kind}/{identity}" + return kind + + def build_specs(config: ActivityConfig) -> list[SourceSpec]: - """Map configured sections onto constructible sources (network deferred).""" + """Map every configured account onto a constructible source (network deferred). + + Accounts appear in listing order within each source kind, kinds in + stable order: launchpad, github, gitlab, bts. + """ + ambiguous = { + "launchpad": len(config.launchpad) > 1, + "github": len(config.github) > 1, + "gitlab": len(config.gitlab) > 1, + "bts": len(config.bts) > 1, + } specs: list[SourceSpec] = [] - if (lp := config.launchpad) is not None: - anonymous = lp.mode == "anonymous" - credentials_file = os.path.expanduser(lp.credentials_file) + for lp in config.launchpad: specs.append( SourceSpec( - name="launchpad", + name=source_label("launchpad", lp.name, lp.username, ambiguous["launchpad"]), username=lp.username, - make=lambda: LaunchpadSource( - service=lp.service, - anonymous=anonymous, - credentials_file=credentials_file or None, - ), + make=_make_launchpad(lp.service, lp.mode == "anonymous", lp.credentials_file), ) ) - if (gh := config.github) is not None: - token = gh.token + for gh in config.github: specs.append( SourceSpec( - name="github", + name=source_label("github", gh.name, gh.username, ambiguous["github"]), username=gh.username, - make=lambda: GitHubSource(token=token or None), + make=_make_github(gh.token, gh.token_env), ) ) - if (gl := config.gitlab) is not None: - url, token, token_env = gl.url, gl.token, gl.token_env + for gl in config.gitlab: specs.append( SourceSpec( - name="gitlab", + name=source_label("gitlab", gl.name, gl.username, ambiguous["gitlab"]), username=gl.username, - make=lambda: GitLabSource( - url=url, token=token or None, token_env=token_env or None - ), + make=_make_gitlab(gl.url, gl.token, gl.token_env), + ) + ) + for bts_acct in config.bts: + email = bts_acct.email + specs.append( + SourceSpec( + name=source_label("bts", bts_acct.name, email, ambiguous["bts"]), + username=email, + make=DebianBtsSource, ) ) - if (bts := config.bts) is not None: - email = bts.email - specs.append(SourceSpec(name="bts", username=email, make=DebianBtsSource)) return specs +def _make_launchpad( + service: str, anonymous: bool, credentials_file: str +) -> Callable[[], ActivitySource]: + expanded = os.path.expanduser(credentials_file) + + def make() -> ActivitySource: + return LaunchpadSource( + service=service, + anonymous=anonymous, + credentials_file=expanded or None, + ) + + return make + + +def _make_github(token: str, token_env: str) -> Callable[[], ActivitySource]: + def make() -> ActivitySource: + # No stored token: explicit env var if named, else GitHubSource's own + # GITHUB_TOKEN / `gh auth token` discovery. + resolved = token or (os.environ.get(token_env, "") if token_env else "") + return GitHubSource(token=resolved or None) + + return make + + +def _make_gitlab(url: str, token: str, token_env: str) -> Callable[[], ActivitySource]: + def make() -> ActivitySource: + return GitLabSource(url=url, token=token or None, token_env=token_env or None) + + return make + + 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] = [] @@ -94,7 +151,8 @@ def collect_specs(specs: list[SourceSpec], since: datetime, until: datetime) -> 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)) + # Headings must show the account label, not just the source kind. + collected.append(Collected(spec, report=replace(report, source=spec.name))) return collected diff --git a/src/weekly_activity/config.py b/src/weekly_activity/config.py index ce32c3f..e78d890 100644 --- a/src/weekly_activity/config.py +++ b/src/weekly_activity/config.py @@ -1,8 +1,17 @@ -"""Config file: schema, discovery, loading, and persistence. +"""Config file: schema, discovery, loading, migration, 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. +(override with ``--config``). + +Version 2 layout (``version = 2``): one section per source kind, each +holding an array of accounts (``[[github.accounts]]``, …) so several +accounts or instances of the same kind can be configured side by side. +An absent section means that source is not enabled. + +Legacy v1 files (no ``version`` key, one single-account section per +source) are transparently migrated to the account-list shape when loaded, +in memory only — the file on disk is never rewritten by ``load_config``; +the next save from the wizard persists the migrated v2 shape. 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. @@ -12,14 +21,20 @@ from __future__ import annotations import os import tomllib -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path +from typing import TypeVar CONFIG_FILE_NAME = "weekly-activity.toml" +CONFIG_VERSION = 2 +_SOURCE_KEYS = ("launchpad", "github", "gitlab", "bts") _LAUNCHPAD_MODES = frozenset({"anonymous", "credentials"}) _LAUNCHPAD_SERVICES = frozenset({"production", "staging"}) +_T = TypeVar("_T") + class ConfigError(Exception): """The config file exists but is malformed or has unknown entries.""" @@ -27,49 +42,67 @@ class ConfigError(Exception): @dataclass(slots=True) class GithubSettings: + """One GitHub account.""" + username: str - token: str = "" # empty -> fall back to GITHUB_TOKEN / `gh auth token` + token: str = "" # empty -> $token_env / GITHUB_TOKEN / `gh auth token` + token_env: str = "" # optional named var, takes precedence in discovery + name: str = "" # optional display label @dataclass(slots=True) class LaunchpadSettings: + """One Launchpad account. + + In credentials mode an empty ``credentials_file`` means launchpadlib + uses its system-keyring credential store; config-time OAuth in the + wizard populates that keyring so ``report`` never needs to authenticate. + """ + username: str mode: str = "anonymous" # "anonymous" | "credentials" credentials_file: str = "" # used in "credentials" mode; empty -> keyring service: str = "production" + name: str = "" @dataclass(slots=True) class GitlabSettings: + """One GitLab instance/account.""" + username: str url: str = "https://gitlab.com" token: str = "" # empty -> env discovery (_TOKEN, GITLAB_TOKEN) token_env: str = "" # optional named var, takes precedence in discovery + name: str = "" @dataclass(slots=True) class BtsSettings: + """One Debian BTS identity (an email address).""" + email: str + name: str = "" @dataclass(slots=True) class ActivityConfig: - """All configured sources; ``None`` section = source disabled.""" + """All configured accounts; an empty list = source disabled.""" - launchpad: LaunchpadSettings | None = None - github: GithubSettings | None = None - gitlab: GitlabSettings | None = None - bts: BtsSettings | None = None + launchpad: list[LaunchpadSettings] = field(default_factory=list) + github: list[GithubSettings] = field(default_factory=list) + gitlab: list[GitlabSettings] = field(default_factory=list) + bts: list[BtsSettings] = field(default_factory=list) 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] + ordered = ( + ("launchpad", self.launchpad), + ("github", self.github), + ("gitlab", self.gitlab), + ("bts", self.bts), + ) + return [name for name, accounts in ordered if accounts] def default_config_path() -> Path: @@ -84,6 +117,11 @@ def default_config_path() -> Path: def load_config(path: Path) -> ActivityConfig: """Parse the TOML config at ``path``. + Files written at ``version = 2`` carry arrays of accounts per source. + Legacy files without a ``version`` key are migrated to that shape in + memory (one unnamed account per configured section); nothing is + rewritten on disk here — saving from the wizard persists v2. + Raises ``FileNotFoundError`` when the file does not exist and ``ConfigError`` when it cannot be parsed or contains unknown entries. """ @@ -100,20 +138,75 @@ def load_config(path: Path) -> ActivityConfig: where = str(path) _reject_unknown_top_level(data, where) + + if "version" in data: + _check_version(data["version"], where) + return ActivityConfig( + launchpad=_account_list(data.get("launchpad"), "launchpad", _parse_launchpad), + github=_account_list(data.get("github"), "github", _parse_github), + gitlab=_account_list(data.get("gitlab"), "gitlab", _parse_gitlab), + bts=_account_list(data.get("bts"), "bts", _parse_bts), + ) + + if _looks_like_v2_without_version(data): + raise ConfigError( + f"{where}: account-array config without a version header; " + f'add "version = {CONFIG_VERSION}" at the top' + ) + # Legacy v1 layout: migrate one single-table section to one unnamed account. 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, + launchpad=_legacy_accounts(data.get("launchpad"), "launchpad", _parse_launchpad), + github=_legacy_accounts(data.get("github"), "github", _parse_github), + gitlab=_legacy_accounts(data.get("gitlab"), "gitlab", _parse_gitlab), + bts=_legacy_accounts(data.get("bts"), "bts", _parse_bts), ) +def _check_version(version: object, where: str) -> None: + if isinstance(version, bool) or not isinstance(version, int): + raise ConfigError(f"{where}: version must be an integer") + if version != CONFIG_VERSION: + raise ConfigError( + f"{where}: unsupported config version {version} " + f"(this release understands version {CONFIG_VERSION})" + ) + + def _reject_unknown_top_level(data: dict[str, object], where: str) -> None: - unknown = sorted(set(data) - {"launchpad", "github", "gitlab", "bts"}) + unknown = sorted(set(data) - {"version", *_SOURCE_KEYS}) if unknown: raise ConfigError(f"{where}: unknown config section(s): {', '.join(unknown)}") +def _looks_like_v2_without_version(data: dict[str, object]) -> bool: + for key in _SOURCE_KEYS: + section = data.get(key) + if isinstance(section, dict) and "accounts" in section: + return True + return False + + +def _account_list(raw: object, section: str, parse: Callable[[object, str], _T]) -> list[_T]: + """Parse ``[[
.accounts]]`` tables via the given account parser.""" + if raw is None: + return [] + table = _as_table(raw, section) + _check_keys(table, {"accounts"}, section) + accounts = table["accounts"] + if not isinstance(accounts, list): + raise ConfigError( + f"[{section}] accounts must be an array of tables ([[{section}.accounts]])" + ) + return [parse(item, f"{section}.accounts[{index}]") for index, item in enumerate(accounts)] + + +def _legacy_accounts(raw: object, section: str, parse: Callable[[object, str], _T]) -> list[_T]: + """Migrate one v1 single-table section into a single-account list.""" + if raw is None: + return [] + return [parse(raw, section)] + + def _check_keys(table: dict[str, object], names: set[str], section: str) -> None: unknown = sorted(set(table) - names) if unknown: @@ -134,6 +227,10 @@ def _optional_str(table: dict[str, object], key: str, section: str) -> str: return value +def _optional_default_str(table: dict[str, object], key: str, section: str) -> str: + return _optional_str(table, key, section) if key in table else "" + + def _one_of(value: str, allowed: frozenset[str], section: str, key: str) -> str: if value not in allowed: choices = ", ".join(sorted(allowed)) @@ -141,61 +238,60 @@ def _one_of(value: str, allowed: frozenset[str], section: str, key: str) -> str: return value -def _parse_github(raw: object, where: str) -> GithubSettings: - del where - table = _as_table(raw, "github") - _check_keys(table, {"username", "token"}, "github") +def _parse_github(raw: object, section: str) -> GithubSettings: + table = _as_table(raw, section) + _check_keys(table, {"name", "username", "token", "token_env"}, section) return GithubSettings( - username=_required_str(table, "username", "github"), - token=_optional_str(table, "token", "github") if "token" in table else "", + username=_required_str(table, "username", section), + token=_optional_default_str(table, "token", section), + token_env=_optional_default_str(table, "token_env", section), + name=_optional_default_str(table, "name", section), ) -def _parse_launchpad(raw: object, where: str) -> LaunchpadSettings: - del where - table = _as_table(raw, "launchpad") - _check_keys(table, {"username", "mode", "credentials_file", "service"}, "launchpad") +def _parse_launchpad(raw: object, section: str) -> LaunchpadSettings: + table = _as_table(raw, section) + _check_keys(table, {"name", "username", "mode", "credentials_file", "service"}, section) mode = _one_of( - _optional_str(table, "mode", "launchpad") if "mode" in table else "anonymous", + _optional_default_str(table, "mode", section) or "anonymous", _LAUNCHPAD_MODES, - "launchpad", + section, "mode", ) service = _one_of( - _optional_str(table, "service", "launchpad") if "service" in table else "production", + _optional_default_str(table, "service", section) or "production", _LAUNCHPAD_SERVICES, - "launchpad", + section, "service", ) return LaunchpadSettings( - username=_required_str(table, "username", "launchpad"), + username=_required_str(table, "username", section), mode=mode, - credentials_file=( - _optional_str(table, "credentials_file", "launchpad") - if "credentials_file" in table - else "" - ), + credentials_file=_optional_default_str(table, "credentials_file", section), service=service, + name=_optional_default_str(table, "name", section), ) -def _parse_gitlab(raw: object, where: str) -> GitlabSettings: - del where - table = _as_table(raw, "gitlab") - _check_keys(table, {"username", "url", "token", "token_env"}, "gitlab") +def _parse_gitlab(raw: object, section: str) -> GitlabSettings: + table = _as_table(raw, section) + _check_keys(table, {"name", "username", "url", "token", "token_env"}, section) 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 "", + username=_required_str(table, "username", section), + url=_optional_default_str(table, "url", section) or "https://gitlab.com", + token=_optional_default_str(table, "token", section), + token_env=_optional_default_str(table, "token_env", section), + name=_optional_default_str(table, "name", section), ) -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 _parse_bts(raw: object, section: str) -> BtsSettings: + table = _as_table(raw, section) + _check_keys(table, {"name", "email"}, section) + return BtsSettings( + email=_required_str(table, "email", section), + name=_optional_default_str(table, "name", section), + ) def _as_table(raw: object, section: str) -> dict[str, object]: @@ -222,32 +318,47 @@ def format_toml(config: ActivityConfig) -> str: "# 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.", + "", + f"version = {CONFIG_VERSION}", ] - if config.launchpad is not None: - lp = config.launchpad - lines += ["", "[launchpad]", f"username = {_toml(lp.username)}"] + + def emit(section: str, entry_lines: list[str]) -> None: + lines.append("") + lines.append(f"[[{section}.accounts]]") + lines.extend(entry_lines) + + for lp in config.launchpad: + entry = [f"name = {_toml(lp.name)}"] if lp.name else [] + entry.append(f"username = {_toml(lp.username)}") if lp.mode != "anonymous": - lines.append(f"mode = {_toml(lp.mode)}") + entry.append(f"mode = {_toml(lp.mode)}") if lp.credentials_file: - lines.append(f"credentials_file = {_toml(lp.credentials_file)}") + entry.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)}"] + entry.append(f"service = {_toml(lp.service)}") + emit("launchpad", entry) + for gh in config.github: + entry = [f"name = {_toml(gh.name)}"] if gh.name else [] + entry.append(f"username = {_toml(gh.username)}") + if gh.token_env: + entry.append(f"token_env = {_toml(gh.token_env)}") 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)}"] + entry.append(f"token = {_toml(gh.token)}") + emit("github", entry) + for gl in config.gitlab: + entry = [f"name = {_toml(gl.name)}"] if gl.name else [] + entry.append(f"username = {_toml(gl.username)}") if gl.url != "https://gitlab.com": - lines.append(f"url = {_toml(gl.url)}") + entry.append(f"url = {_toml(gl.url)}") if gl.token: - lines.append(f"token = {_toml(gl.token)}") + entry.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)}"] + entry.append(f"token_env = {_toml(gl.token_env)}") + emit("gitlab", entry) + for bts in config.bts: + entry = [f"name = {_toml(bts.name)}"] if bts.name else [] + entry.append(f"email = {_toml(bts.email)}") + emit("bts", entry) return "\n".join(lines) + "\n" diff --git a/src/weekly_activity/wizard.py b/src/weekly_activity/wizard.py index 2ac353f..6b97e72 100644 --- a/src/weekly_activity/wizard.py +++ b/src/weekly_activity/wizard.py @@ -1,17 +1,25 @@ """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). +A small menu-driven editor for the account-list config: it shows the +current sources (secrets masked), then offers Add a new source / +Remove a source / Exit, supporting several accounts per provider. + +Adding a non-anonymous Launchpad account runs launchpadlib's interactive +OAuth flow right here (the browser opens once and the token lands in the +system keyring) so `weekly-activity report` never has to authenticate; +supplying a credentials file skips that flow. Token values are never +echoed back. """ from __future__ import annotations import getpass +import os import sys +from collections.abc import Callable, Sequence from pathlib import Path +from weekly_activity.aggregate import source_label from weekly_activity.config import ( ActivityConfig, BtsSettings, @@ -22,38 +30,76 @@ from weekly_activity.config import ( load_config, save_config, ) +from weekly_activity.sources.launchpad import LaunchpadSource + +_APPLICATION_NAME = "weekly-activity" + +# Stable processing order, shared by menus and summaries: (config attr, menu text). +_KINDS: tuple[tuple[str, str], ...] = ( + ("launchpad", "Launchpad account"), + ("github", "GitHub account"), + ("gitlab", "GitLab instance/account"), + ("bts", "Debian BTS email"), +) 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 + """Edit the source configuration interactively; write when changed.""" + try: + config = _load_existing(config_path) + original = _snapshot(config) + print() + print("Welcome! This wizard manages the sources aggregated by") + print("`weekly-activity report`. Enter a menu number, accept [defaults]") + print("with Enter, and pick Exit to save; Ctrl-C aborts without writing.") + try: + _menu_loop(config) + except EOFError: + print() + print("End of input; applying current selections.") + except KeyboardInterrupt: + raise SystemExit(130) from None # parity with secret-prompt interruption + _finish(config, original, config_path) - 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), +def _snapshot(config: ActivityConfig) -> ActivityConfig: + """Copy the lists so edits can be compared against the starting point.""" + return ActivityConfig( + launchpad=list(config.launchpad), + github=list(config.github), + gitlab=list(config.gitlab), + bts=list(config.bts), ) - enabled = config.enabled_sources() - if not enabled: - raise SystemExit("warning: no source selected; nothing was written.") +def _menu_loop(config: ActivityConfig) -> None: + while True: + print() + print("Current sources:") + print(_describe(config)) + action = _menu("What do you want to do?", ["Add a new source", "Remove a source", "Exit"]) + if action == 0: + _add_source_flow(config) + elif action == 1: + _remove_source_flow(config) + else: + if not config.enabled_sources() and not _confirm( + "No sources are configured; write an empty configuration anyway?", default=False + ): + continue + return + + +def _finish(config: ActivityConfig, original: ActivityConfig, config_path: Path) -> None: + if config == original: + print() + print("No changes made; configuration left untouched.") + return save_config(config, config_path) + enabled = ", ".join(config.enabled_sources()) print() print(f"Configuration written to {config_path}.") - print(f"Enabled sources: {', '.join(enabled)}.") + print(f"Enabled sources: {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.") @@ -70,65 +116,197 @@ def _load_existing(path: Path) -> ActivityConfig: return ActivityConfig() -# --- Per-source prompts --- +# --- Add / remove flows --- -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, +def _add_source_flow(config: ActivityConfig) -> None: + chosen = _menu( + "Which source do you want to add?", [text for _, text in _KINDS], cancellable=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 + if chosen is None: + return + kind = _KINDS[chosen][0] + account = _ADDERS[kind]() + if account is None: + print("Cancelled; nothing was added.") + return + accounts = getattr(config, kind) + accounts.append(account) + label = source_label(kind, _display_name(account), _identity(account), len(accounts) > 1) + print(f"Added: {label} ({_identity(account)}).") -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 _remove_source_flow(config: ActivityConfig) -> None: + entries: list[tuple[str, int]] = [] + options: list[str] = [] + for kind, _ in _KINDS: + accounts = getattr(config, kind) + for index, account in enumerate(accounts): + entries.append((kind, index)) + options.append(_account_line(kind, account, len(accounts) > 1)) + if not entries: + print("There are no sources to remove yet.") + return + picked = _menu("Which account do you want to remove?", options, cancellable=True) + if picked is None or not _confirm(f"Remove {options[picked]}?", default=False): + return + kind, index = entries[picked] + del getattr(config, kind)[index] + print(f"Removed: {options[picked]}.") -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 _add_github() -> GithubSettings | None: + print("GitHub identity is the profile @handle.") + username = _ask_required("GitHub username") + name = _ask("Display name (optional label)") + print("Leave both token fields empty to fall back to $GITHUB_TOKEN or `gh auth token`.") + token_env = _ask("Env var holding the token (optional, e.g. GITHUB_TOKEN)") + token = _ask_secret("GitHub token (input hidden)") + return GithubSettings(username=username, token=token, token_env=token_env, name=name) -def _configure_bts(prev: BtsSettings | None) -> BtsSettings | None: - if not _confirm("Add Debian BTS?", default=False): - return None +def _add_gitlab() -> GitlabSettings | None: + print("GitLab needs the instance URL plus your username there.") + username = _ask_required("GitLab username") + url = _ask("Instance URL", "https://gitlab.com") + name = _ask("Display name (optional label)") + print("Leave both token fields empty to discover the token from the environment.") + token = _ask_secret("API token (input hidden)") + token_env = _ask("Env var holding the token (optional, e.g. SALSA_TOKEN)") + return GitlabSettings(username=username, url=url, token=token, token_env=token_env, name=name) + + +def _add_bts() -> BtsSettings | 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) + email = _ask_required("Email address on Debian bugs") + name = _ask("Display name (optional label)") + return BtsSettings(email=email, name=name) + + +def _add_launchpad() -> LaunchpadSettings | None: + print("Launchpad identity is the ~name shown on your profile page.") + username = _ask_required("Launchpad username") + name = _ask("Display name (optional label)") + anonymous = _confirm("Anonymous mode (public data only, no OAuth)?", default=True) + if anonymous: + return LaunchpadSettings(username=username, mode="anonymous", name=name) + + credentials_file = _ask("Credentials file path (empty to authenticate right now)") + if credentials_file: + expanded = os.path.expanduser(credentials_file) + if not os.path.isfile(expanded): + print(f"note: {expanded} does not exist yet; it will be read at report time.") + print(f"OAuth credentials will be read from {credentials_file} at report time.") + return LaunchpadSettings( + username=username, mode="credentials", credentials_file=credentials_file, name=name + ) + + # Real config-time OAuth: construct LaunchpadSource (this runs + # launchpadlib's browser flow) and keep only its side effect — the token + # in the system keyring keyed to the application name. + while True: + print("A browser window will open to authorize weekly-activity with Launchpad.") + try: + LaunchpadSource( + service="production", + anonymous=False, + application_name=_APPLICATION_NAME, + credentials_file=None, + ) + except Exception as exc: # noqa: BLE001 - network/user errors here are recoverable + print(f"warning: Launchpad authentication failed: {exc}") + if not _confirm("Retry authentication?", default=False): + return None + else: + break + print("Launchpad authorization complete: the token is stored in your system keyring;") + print("`weekly-activity report` will reuse it automatically.") + return LaunchpadSettings(username=username, mode="credentials", name=name) + + +_ADDERS: dict[str, Callable[[], object | None]] = { + "launchpad": _add_launchpad, + "github": _add_github, + "gitlab": _add_gitlab, + "bts": _add_bts, +} + + +# --- Display helpers (secrets masked) --- + + +def _describe(config: ActivityConfig, indent: str = " ") -> str: + """Human-readable summary masking every token value.""" + lines: list[str] = [] + for kind, _ in _KINDS: + accounts = getattr(config, kind) + for account in accounts: + lines.append(indent + _account_line(kind, account, len(accounts) > 1)) + return "\n".join(lines) if lines else f"{indent}(no sources configured)" + + +def _account_line(kind: str, account: object, ambiguous: bool) -> str: + identity = _identity(account) + prefix = f"{source_label(kind, _display_name(account), identity, ambiguous)}: " + if kind == "launchpad" and isinstance(account, LaunchpadSettings): + auth = ( + "anonymous (public data only)" + if account.mode == "anonymous" + else f"OAuth via {account.credentials_file or 'system keyring'}" + ) + return f"{prefix}{identity} — {auth}" + if kind == "github" and isinstance(account, GithubSettings): + fallback = account.token_env or "GITHUB_TOKEN / gh" + token_desc = "stored" if account.token else f"not stored (uses {fallback})" + return f"{prefix}{identity} — token {token_desc}" + if kind == "gitlab" and isinstance(account, GitlabSettings): + fallback = account.token_env or "_TOKEN / GITLAB_TOKEN env" + token_desc = "stored" if account.token else f"not stored ({fallback})" + return f"{prefix}{identity} @ {account.url} — token {token_desc}" + if kind == "bts" and isinstance(account, BtsSettings): + return f"{prefix}{identity}" + return f"{prefix}{identity}" # pragma: no cover - exhaustive over known kinds + + +def _identity(account: object) -> str: + if isinstance(account, BtsSettings): + return account.email + if isinstance(account, (GithubSettings, GitlabSettings, LaunchpadSettings)): + return account.username + return "" # pragma: no cover - exhaustive over known kinds + + +def _display_name(account: object) -> str: + if isinstance(account, (GithubSettings, GitlabSettings, LaunchpadSettings, BtsSettings)): + return account.name + return "" # pragma: no cover - exhaustive over known kinds # --- Prompt helpers --- +def _menu(title: str, options: Sequence[str], *, cancellable: bool = False) -> int | None: + """Numbered-choice menu: selected index, or None when backed out.""" + print(title) + for number, option in enumerate(options, 1): + print(f" {number}) {option}") + back_number = len(options) + 1 if cancellable else 0 + if cancellable: + print(f" {back_number}) Back") + while True: + answer = input("Enter choice: ").strip() + if not answer and cancellable: + return None + if answer.isdigit(): + number = int(answer) + if 1 <= number <= len(options): + return number - 1 + if cancellable and number == back_number: + return None + valid = back_number or len(options) + print(f"Please enter a number between 1 and {valid}.") + + def _ask(prompt: str, default: str = "") -> str: suffix = f" [{default}]" if default else "" answer = input(f"{prompt}{suffix}: ").strip() @@ -162,25 +340,3 @@ def _confirm(prompt: str, *, default: bool = False) -> bool: 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 _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)" diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index 509f882..1b6e732 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -48,29 +48,90 @@ def window() -> tuple[datetime, datetime]: 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"), + 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_multiple_accounts_per_kind_in_listing_order(self) -> None: + config = ActivityConfig( + launchpad=[ + LaunchpadSettings(username="lp-a"), + LaunchpadSettings(username="lp-b", mode="credentials"), + ], + github=[ + GithubSettings(username="gh-a", name="work"), + GithubSettings(username="gh-b"), + ], + bts=[ + BtsSettings(email="one@x.org"), + BtsSettings(email="two@x.org", name="two"), + ], + ) + specs = build_specs(config) + # All launchpad accounts first, then github, then bts; listing order kept. + self.assertEqual([s.username for s in specs], ["lp-a", "lp-b", "gh-a", "gh-b", + "one@x.org", "two@x.org"]) + self.assertEqual( + [s.name for s in specs], + [ + "launchpad/lp-a", # siblings force identity into the label + "launchpad/lp-b", + "github/work", # explicit label wins even among siblings + "github/gh-b", + "bts/one@x.org", + "bts/two", + ], + ) + + def test_single_unnamed_account_keeps_plain_kind_label(self) -> None: + config = ActivityConfig(github=[GithubSettings(username="only-one")]) + specs = build_specs(config) + self.assertEqual(specs[0].name, "github") + 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"]) + config = ActivityConfig(bts=[BtsSettings(email="only@one")]) + self.assertEqual([s.name for s in build_specs(config)], ["bts"]) 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] + config = ActivityConfig(launchpad=[LaunchpadSettings(username="anon")]) + config.launchpad.append(LaunchpadSettings(mode="credentials", username="u", + credentials_file="~/creds.json")) + specs = build_specs(config) with mock.patch("weekly_activity.aggregate.LaunchpadSource") as fake_source: + for spec in specs: + spec.make() + anon_kwargs, cred_kwargs = (call.kwargs for call in fake_source.call_args_list) + self.assertTrue(anon_kwargs["anonymous"]) + self.assertIsNone(anon_kwargs["credentials_file"]) + self.assertFalse(cred_kwargs["anonymous"]) + self.assertEqual(cred_kwargs["credentials_file"], os.path.expanduser("~") + "/creds.json") + # The two accounts must not share one late-bound factory. + self.assertNotEqual(anon_kwargs["credentials_file"], cred_kwargs["credentials_file"]) + + def test_github_token_env_resolved_lazily(self) -> None: + config = ActivityConfig(github=[GithubSettings(username="u", token_env="MY_GH_TOKEN")]) + spec = build_specs(config)[0] + with ( + mock.patch("weekly_activity.aggregate.GitHubSource") as fake_source, + mock.patch.dict(os.environ, {"MY_GH_TOKEN": "env-token"}), + ): spec.make() - kwargs = fake_source.call_args.kwargs - self.assertEqual(kwargs["credentials_file"], os.path.expanduser("~") + "/creds.json") - self.assertFalse(kwargs["anonymous"]) + self.assertEqual(fake_source.call_args.kwargs["token"], "env-token") + + def test_github_stored_token_takes_precedence_over_env(self) -> None: + config = ActivityConfig(github=[GithubSettings(username="u", token="tok")]) + spec = build_specs(config)[0] + with ( + mock.patch("weekly_activity.aggregate.GitHubSource") as fake_source, + mock.patch.dict(os.environ, {"MY_GH_TOKEN": "env-token"}), + ): + spec.make() + self.assertEqual(fake_source.call_args.kwargs["token"], "tok") class CollectAndFormatTest(unittest.TestCase): @@ -89,6 +150,13 @@ class CollectAndFormatTest(unittest.TestCase): self.assertIn("Activity report — alpha / ~a", text) self.assertIn("[skipped: beta — RuntimeError: kaput]", text) + def test_heading_uses_account_label_not_bare_source_name(self) -> None: + since, until = window() + specs = [SourceSpec(name="github/work", username="acme-jane", + make=lambda: StubSource("github"))] + text = format_combined(collect_specs(specs, since, until), since, until) + self.assertIn("Activity report — github/work / ~acme-jane", text) + def test_warning_surfaced_inside_block(self) -> None: since, until = window() specs = [SourceSpec(name="gamma", username="g", make=lambda: StubSource("gamma"))] diff --git a/tests/test_config.py b/tests/test_config.py index 6de67df..4622586 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,54 +21,93 @@ from weekly_activity.config import ( ) -class ConfigRoundTripTest(unittest.TestCase): - def test_round_trip_preserves_all_sections(self) -> None: - config = ActivityConfig( - launchpad=LaunchpadSettings( +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) + + +def sample_v2_config() -> ActivityConfig: + return ActivityConfig( + launchpad=[ + LaunchpadSettings( username="lp-user", mode="credentials", credentials_file="/tmp/lp-creds.json", service="staging", + name="work", ), - github=GithubSettings(username='gh"user', token="tok\\en"), - gitlab=GitlabSettings( + LaunchpadSettings(username="anon-lp"), + ], + github=[ + GithubSettings(username='gh"user', token="tok\\en"), + GithubSettings( + username="acme-jane", token_env="WORK_GH_TOKEN", name="work" + ), + ], + gitlab=[ + GitlabSettings( username="gl-user", url="https://salsa.debian.org", token_env="SALSA_TOKEN", - ), - bts=BtsSettings(email="dev@example.org"), - ) + name="salsa", + ) + ], + bts=[BtsSettings(email="dev@example.org")], + ) + + +class ConfigRoundTripTest(unittest.TestCase): + def test_round_trip_preserves_all_accounts(self) -> None: + config = sample_v2_config() with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "weekly-activity.toml" save_config(config, path) self.assertEqual(load_config(path), config) + def test_save_writes_v2_version_header(self) -> None: + text = format_toml(ActivityConfig(github=[GithubSettings(username="g")])) + self.assertIn("version = 2", text) + self.assertIn("[[github.accounts]]", text) + def test_save_creates_owner_only_file(self) -> None: - config = ActivityConfig(bts=BtsSettings(email="x@example.org")) + 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_config_emits_header_only(self) -> None: + text = format_toml(ActivityConfig()) + self.assertEqual(load_config_from_text(text), ActivityConfig()) + self.assertNotIn("[[", text) + def test_empty_optionals_round_trip_to_defaults(self) -> None: config = ActivityConfig( - github=GithubSettings(username="octocat"), - bts=BtsSettings(email="a@b.c"), + 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) + reparsed = load_config_from_text(format_toml(config)) + self.assertEqual(reparsed.github, [GithubSettings(username="octocat")]) + self.assertEqual(reparsed.bts, [BtsSettings(email="a@b.c")]) + self.assertEqual(reparsed.launchpad, []) def test_escapes_quotes_backslashes_and_newlines(self) -> None: - config = ActivityConfig(github=GithubSettings(username='a"b\\c\nd')) + 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') + self.assertEqual(reparsed.github[0].username, 'a"b\\c\nd') + + def test_load_does_not_rewrite_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "weekly-activity.toml" + original = "[github]\nusername = 'octo'\n" + path.write_text(original, encoding="utf-8") + before = path.read_bytes() + config = load_config(path) + self.assertEqual(config.github, [GithubSettings(username="octo")]) + self.assertEqual(path.read_bytes(), before) class ConfigLoadingTest(unittest.TestCase): @@ -78,26 +117,123 @@ class ConfigLoadingTest(unittest.TestCase): def test_unknown_section_rejected(self) -> None: with self.assertRaises(ConfigError): - load_config_from_text("[gitea]\nusername = 'x'\n") + load_config_from_text('version = 2\n\n[[gitea.accounts]]\nusername = "x"\n') - def test_unknown_key_rejected(self) -> None: + def test_unknown_top_level_key_rejected(self) -> None: with self.assertRaises(ConfigError): - load_config_from_text('[github]\nusername = "x"\ntokken = "t"\n') + load_config_from_text('version = 2\nsources = 3\n') + + def test_unknown_account_key_rejected(self) -> None: + with self.assertRaises(ConfigError): + load_config_from_text( + '[github]\naccounts = [{username = "x", tokken = "t"}]\n' + ) + + def test_section_must_hold_only_accounts_table(self) -> None: + with self.assertRaises(ConfigError): + load_config_from_text('[version]\nwrong = "shape"\n') # wrong type below + with self.assertRaises(ConfigError): + load_config_from_text( + 'version = 2\n\n[github]\nusername = "legacy"\n' # v2 without .accounts + ) + + def test_accounts_must_be_list_of_tables(self) -> None: + with self.assertRaises(ConfigError): + load_config_from_text('version = 2\n\n[github]\naccounts = "nope"\n') + with self.assertRaises(ConfigError): + load_config_from_text('version = 2\n\n[gitlab]\naccounts = [42]\n') def test_non_string_value_rejected(self) -> None: with self.assertRaises(ConfigError): - load_config_from_text("[github]\nusername = 42\n") + load_config_from_text( + 'version = 2\n\n[[github.accounts]]\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') + load_config_from_text( + 'version = 2\n\n[[launchpad.accounts]]\nusername = "u"\nmode = "oauth2"\n' + ) + + def test_bad_launchpad_service_rejected(self) -> None: + with self.assertRaises(ConfigError): + load_config_from_text( + 'version = 2\n\n[[launchpad.accounts]]\nusername = "u"\nservice = "qa"\n' + ) + + def test_v2_section_without_accounts_table_rejected(self) -> None: + # A version-tagged file must use the [[kind.accounts]] shape. + with self.assertRaises(ConfigError): + load_config_from_text('version = 2\n\n[github]\nusername = "legacy"\n') + + def test_account_errors_carry_position(self) -> None: + text = ( + "version = 2\n" + "\n[[launchpad.accounts]]\nusername = \"ok\"\n" + "\n[[launchpad.accounts]]\nusername = \"bad\"\nservice = \"oops\"\n" + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "weekly-activity.toml" + path.write_text(text, encoding="utf-8") + with self.assertRaisesRegex(ConfigError, r"launchpad\.accounts\[1\]"): + load_config(path) def test_enabled_sources_stable_order(self) -> None: - config = load_config_from_text( - '[bts]\nemail = "e"\n\n[github]\nusername = "g"\n' + config = ActivityConfig( + bts=[BtsSettings(email="e")], + github=[GithubSettings(username="g")], ) self.assertEqual(config.enabled_sources(), ["github", "bts"]) + +class ConfigMigrationTest(unittest.TestCase): + V1_TEXT = """\ +[launchpad] +username = "lp-user" +mode = "credentials" +credentials_file = "~/creds.json" + +[github] +username = "octo" +token = "t0k" + +[gitlab] +url = "https://salsa.debian.org" +username = "gl" +token_env = "SALSA_TOKEN" + +[bts] +email = "dev@example.org" +""" + + def test_v1_file_migrates_to_single_unnamed_accounts_in_memory(self) -> None: + config = load_config_from_text(self.V1_TEXT) + self.assertEqual(config.enabled_sources(), ["launchpad", "github", "gitlab", "bts"]) + self.assertEqual(config.launchpad, [LaunchpadSettings( + username="lp-user", mode="credentials", credentials_file="~/creds.json", + )]) + self.assertEqual(config.github, [GithubSettings(username="octo", token="t0k")]) + self.assertEqual(config.gitlab, [GitlabSettings( + url="https://salsa.debian.org", username="gl", token_env="SALSA_TOKEN", + )]) + self.assertEqual(config.bts, [BtsSettings(email="dev@example.org")]) + + def test_saving_migrated_config_persists_v2_shape(self) -> None: + config = load_config_from_text(self.V1_TEXT) + text = format_toml(config) + self.assertIn("version = 2", text) + self.assertEqual(load_config_from_text(text), config) + + def test_v1_unknown_keys_still_strict(self) -> None: + with self.assertRaises(ConfigError): + load_config_from_text('[github]\nusername = "u"\ntokken = "t"\n') + + def test_v2_shaped_file_without_version_rejected(self) -> None: + with self.assertRaisesRegex(ConfigError, "version"): + load_config_from_text('[[github.accounts]]\nusername = "u"\n') + + +class ConfigPathTest(unittest.TestCase): def test_default_path_uses_xdg_or_home(self) -> None: old = os.environ.get("XDG_CONFIG_HOME") try: @@ -110,13 +246,5 @@ class ConfigLoadingTest(unittest.TestCase): 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()