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`
This commit is contained in:
2026-08-26 22:53:17 +00:00
parent 6d8cc3d817
commit 5f4487f3ad
6 changed files with 801 additions and 261 deletions
+34 -15
View File
@@ -67,35 +67,54 @@ Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (IS
## Configuration ## Configuration
`weekly-activity config` walks through each known source and writes a TOML `weekly-activity config` opens a small menu-driven editor: it lists your
file. Tokens are stored as provided (plaintext), but the file is created with current sources (secrets masked), then lets you **Add a new source**
owner-only permissions (`0600`) and secrets are never echoed back. Use (choose the kind, repeat as often as you like), **Remove a source**, or
`--config <path>` (also accepted by `report`) to keep configs elsewhere. **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 <path>` (also accepted by
`report`) to keep configs elsewhere.
Default location: `${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml`. Default location: `${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml`.
```toml ```toml
[github] version = 2
username = "octocat"
token = "" # empty -> fall back to $GITHUB_TOKEN / `gh auth token`
[launchpad] [[launchpad.accounts]]
username = "jane" username = "jane"
mode = "credentials" # "anonymous" (public data only) | "credentials" mode = "credentials" # "anonymous" (public data only) | "credentials"
credentials_file = "" # empty -> system keyring; OAuth runs at report time # 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" url = "https://salsa.debian.org"
username = "jane" username = "jane"
token_env = "SALSA_TOKEN" token_env = "SALSA_TOKEN"
[bts] [[bts.accounts]]
email = "jane@example.org" email = "jane@example.org"
``` ```
An absent `[section]` disables that source. Re-running `weekly-activity An absent section disables that source; an empty one is not written.
config` shows the current settings and offers to redo the wizard with the When a non-anonymous Launchpad account is added without a credentials
existing values as defaults. 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 ## Architecture
+89 -31
View File
@@ -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 Each configured account runs independently: a failure (bad token,
API, unknown user) is recorded as a ``[skipped: <source> …]`` block instead unreachable API, unknown user) is recorded as a ``[skipped: <source> …]``
of aborting the whole report. Per-source warnings surfaced by ``collect`` block instead of aborting the whole report. Per-source warnings surfaced by
are rendered inside the source's block by ``format_report``. ``collect`` are rendered inside the source's block by ``format_report``.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import datetime, timedelta from datetime import datetime, timedelta
from weekly_activity.config import ActivityConfig from weekly_activity.config import ActivityConfig
@@ -27,7 +27,12 @@ _RULE = "─" * 60
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SourceSpec: 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 name: str
username: str username: str
@@ -41,49 +46,101 @@ class Collected:
error: str | None = None 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]: 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] = [] specs: list[SourceSpec] = []
if (lp := config.launchpad) is not None: for lp in config.launchpad:
anonymous = lp.mode == "anonymous"
credentials_file = os.path.expanduser(lp.credentials_file)
specs.append( specs.append(
SourceSpec( SourceSpec(
name="launchpad", name=source_label("launchpad", lp.name, lp.username, ambiguous["launchpad"]),
username=lp.username, username=lp.username,
make=lambda: LaunchpadSource( make=_make_launchpad(lp.service, lp.mode == "anonymous", lp.credentials_file),
service=lp.service,
anonymous=anonymous,
credentials_file=credentials_file or None,
),
) )
) )
if (gh := config.github) is not None: for gh in config.github:
token = gh.token
specs.append( specs.append(
SourceSpec( SourceSpec(
name="github", name=source_label("github", gh.name, gh.username, ambiguous["github"]),
username=gh.username, 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: for gl in config.gitlab:
url, token, token_env = gl.url, gl.token, gl.token_env
specs.append( specs.append(
SourceSpec( SourceSpec(
name="gitlab", name=source_label("gitlab", gl.name, gl.username, ambiguous["gitlab"]),
username=gl.username, username=gl.username,
make=lambda: GitLabSource( make=_make_gitlab(gl.url, gl.token, gl.token_env),
url=url, token=token or None, token_env=token_env or None )
), )
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 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]: def collect_specs(specs: list[SourceSpec], since: datetime, until: datetime) -> list[Collected]:
"""Run every spec, converting any per-source failure into an error record.""" """Run every spec, converting any per-source failure into an error record."""
collected: list[Collected] = [] 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 except Exception as exc: # noqa: BLE001 - one bad source must not kill the rest
collected.append(Collected(spec, error=f"{type(exc).__name__}: {exc}")) collected.append(Collected(spec, error=f"{type(exc).__name__}: {exc}"))
else: 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 return collected
+184 -73
View File
@@ -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`` Stored as TOML at ``${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml``
(override with ``--config``). One section per source; an absent section (override with ``--config``).
means the source is not enabled.
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; 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. the file is created with ``0600`` permissions so they are not world-readable.
@@ -12,14 +21,20 @@ from __future__ import annotations
import os import os
import tomllib import tomllib
from dataclasses import dataclass from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TypeVar
CONFIG_FILE_NAME = "weekly-activity.toml" CONFIG_FILE_NAME = "weekly-activity.toml"
CONFIG_VERSION = 2
_SOURCE_KEYS = ("launchpad", "github", "gitlab", "bts")
_LAUNCHPAD_MODES = frozenset({"anonymous", "credentials"}) _LAUNCHPAD_MODES = frozenset({"anonymous", "credentials"})
_LAUNCHPAD_SERVICES = frozenset({"production", "staging"}) _LAUNCHPAD_SERVICES = frozenset({"production", "staging"})
_T = TypeVar("_T")
class ConfigError(Exception): class ConfigError(Exception):
"""The config file exists but is malformed or has unknown entries.""" """The config file exists but is malformed or has unknown entries."""
@@ -27,49 +42,67 @@ class ConfigError(Exception):
@dataclass(slots=True) @dataclass(slots=True)
class GithubSettings: class GithubSettings:
"""One GitHub account."""
username: str 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) @dataclass(slots=True)
class LaunchpadSettings: 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 username: str
mode: str = "anonymous" # "anonymous" | "credentials" mode: str = "anonymous" # "anonymous" | "credentials"
credentials_file: str = "" # used in "credentials" mode; empty -> keyring credentials_file: str = "" # used in "credentials" mode; empty -> keyring
service: str = "production" service: str = "production"
name: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class GitlabSettings: class GitlabSettings:
"""One GitLab instance/account."""
username: str username: str
url: str = "https://gitlab.com" url: str = "https://gitlab.com"
token: str = "" # empty -> env discovery (<HOST>_TOKEN, GITLAB_TOKEN) token: str = "" # empty -> env discovery (<HOST>_TOKEN, GITLAB_TOKEN)
token_env: str = "" # optional named var, takes precedence in discovery token_env: str = "" # optional named var, takes precedence in discovery
name: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class BtsSettings: class BtsSettings:
"""One Debian BTS identity (an email address)."""
email: str email: str
name: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class ActivityConfig: class ActivityConfig:
"""All configured sources; ``None`` section = source disabled.""" """All configured accounts; an empty list = source disabled."""
launchpad: LaunchpadSettings | None = None launchpad: list[LaunchpadSettings] = field(default_factory=list)
github: GithubSettings | None = None github: list[GithubSettings] = field(default_factory=list)
gitlab: GitlabSettings | None = None gitlab: list[GitlabSettings] = field(default_factory=list)
bts: BtsSettings | None = None bts: list[BtsSettings] = field(default_factory=list)
def enabled_sources(self) -> list[str]: def enabled_sources(self) -> list[str]:
"""Names of configured source sections, in stable report order.""" """Names of configured source sections, in stable report order."""
ordered: list[tuple[str, bool]] = [ ordered = (
("launchpad", self.launchpad is not None), ("launchpad", self.launchpad),
("github", self.github is not None), ("github", self.github),
("gitlab", self.gitlab is not None), ("gitlab", self.gitlab),
("bts", self.bts is not None), ("bts", self.bts),
] )
return [name for name, on in ordered if on] return [name for name, accounts in ordered if accounts]
def default_config_path() -> Path: def default_config_path() -> Path:
@@ -84,6 +117,11 @@ def default_config_path() -> Path:
def load_config(path: Path) -> ActivityConfig: def load_config(path: Path) -> ActivityConfig:
"""Parse the TOML config at ``path``. """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 Raises ``FileNotFoundError`` when the file does not exist and
``ConfigError`` when it cannot be parsed or contains unknown entries. ``ConfigError`` when it cannot be parsed or contains unknown entries.
""" """
@@ -100,20 +138,75 @@ def load_config(path: Path) -> ActivityConfig:
where = str(path) where = str(path)
_reject_unknown_top_level(data, where) _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( return ActivityConfig(
launchpad=_parse_launchpad(data.get("launchpad"), where) if "launchpad" in data else None, launchpad=_legacy_accounts(data.get("launchpad"), "launchpad", _parse_launchpad),
github=_parse_github(data.get("github"), where) if "github" in data else None, github=_legacy_accounts(data.get("github"), "github", _parse_github),
gitlab=_parse_gitlab(data.get("gitlab"), where) if "gitlab" in data else None, gitlab=_legacy_accounts(data.get("gitlab"), "gitlab", _parse_gitlab),
bts=_parse_bts(data.get("bts"), where) if "bts" in data else None, 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: 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: if unknown:
raise ConfigError(f"{where}: unknown config section(s): {', '.join(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 ``[[<section>.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: def _check_keys(table: dict[str, object], names: set[str], section: str) -> None:
unknown = sorted(set(table) - names) unknown = sorted(set(table) - names)
if unknown: if unknown:
@@ -134,6 +227,10 @@ def _optional_str(table: dict[str, object], key: str, section: str) -> str:
return value 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: def _one_of(value: str, allowed: frozenset[str], section: str, key: str) -> str:
if value not in allowed: if value not in allowed:
choices = ", ".join(sorted(allowed)) choices = ", ".join(sorted(allowed))
@@ -141,61 +238,60 @@ def _one_of(value: str, allowed: frozenset[str], section: str, key: str) -> str:
return value return value
def _parse_github(raw: object, where: str) -> GithubSettings: def _parse_github(raw: object, section: str) -> GithubSettings:
del where table = _as_table(raw, section)
table = _as_table(raw, "github") _check_keys(table, {"name", "username", "token", "token_env"}, section)
_check_keys(table, {"username", "token"}, "github")
return GithubSettings( return GithubSettings(
username=_required_str(table, "username", "github"), username=_required_str(table, "username", section),
token=_optional_str(table, "token", "github") if "token" in table else "", 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: def _parse_launchpad(raw: object, section: str) -> LaunchpadSettings:
del where table = _as_table(raw, section)
table = _as_table(raw, "launchpad") _check_keys(table, {"name", "username", "mode", "credentials_file", "service"}, section)
_check_keys(table, {"username", "mode", "credentials_file", "service"}, "launchpad")
mode = _one_of( 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_MODES,
"launchpad", section,
"mode", "mode",
) )
service = _one_of( 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_SERVICES,
"launchpad", section,
"service", "service",
) )
return LaunchpadSettings( return LaunchpadSettings(
username=_required_str(table, "username", "launchpad"), username=_required_str(table, "username", section),
mode=mode, mode=mode,
credentials_file=( credentials_file=_optional_default_str(table, "credentials_file", section),
_optional_str(table, "credentials_file", "launchpad")
if "credentials_file" in table
else ""
),
service=service, service=service,
name=_optional_default_str(table, "name", section),
) )
def _parse_gitlab(raw: object, where: str) -> GitlabSettings: def _parse_gitlab(raw: object, section: str) -> GitlabSettings:
del where table = _as_table(raw, section)
table = _as_table(raw, "gitlab") _check_keys(table, {"name", "username", "url", "token", "token_env"}, section)
_check_keys(table, {"username", "url", "token", "token_env"}, "gitlab")
return GitlabSettings( return GitlabSettings(
username=_required_str(table, "username", "gitlab"), username=_required_str(table, "username", section),
url=(_optional_str(table, "url", "gitlab") if "url" in table else "https://gitlab.com"), url=_optional_default_str(table, "url", section) or "https://gitlab.com",
token=_optional_str(table, "token", "gitlab") if "token" in table else "", token=_optional_default_str(table, "token", section),
token_env=_optional_str(table, "token_env", "gitlab") if "token_env" in table else "", token_env=_optional_default_str(table, "token_env", section),
name=_optional_default_str(table, "name", section),
) )
def _parse_bts(raw: object, where: str) -> BtsSettings: def _parse_bts(raw: object, section: str) -> BtsSettings:
del where table = _as_table(raw, section)
table = _as_table(raw, "bts") _check_keys(table, {"name", "email"}, section)
_check_keys(table, {"email"}, "bts") return BtsSettings(
return BtsSettings(email=_required_str(table, "email", "bts")) email=_required_str(table, "email", section),
name=_optional_default_str(table, "name", section),
)
def _as_table(raw: object, section: str) -> dict[str, object]: 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.", "# Generated by `weekly-activity config`; edit by hand if you prefer.",
"# Tokens are stored in plaintext and readable only by your user (0600).", "# Tokens are stored in plaintext and readable only by your user (0600).",
"# Run `weekly-activity report` to aggregate every enabled source below.", "# Run `weekly-activity report` to aggregate every enabled source below.",
"",
f"version = {CONFIG_VERSION}",
] ]
if config.launchpad is not None:
lp = config.launchpad def emit(section: str, entry_lines: list[str]) -> None:
lines += ["", "[launchpad]", f"username = {_toml(lp.username)}"] 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": if lp.mode != "anonymous":
lines.append(f"mode = {_toml(lp.mode)}") entry.append(f"mode = {_toml(lp.mode)}")
if lp.credentials_file: 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": if lp.service != "production":
lines.append(f"service = {_toml(lp.service)}") entry.append(f"service = {_toml(lp.service)}")
if config.github is not None: emit("launchpad", entry)
gh = config.github for gh in config.github:
lines += ["", "[github]", f"username = {_toml(gh.username)}"] 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: if gh.token:
lines.append(f"token = {_toml(gh.token)}") entry.append(f"token = {_toml(gh.token)}")
if config.gitlab is not None: emit("github", entry)
gl = config.gitlab for gl in config.gitlab:
lines += ["", "[gitlab]", f"username = {_toml(gl.username)}"] entry = [f"name = {_toml(gl.name)}"] if gl.name else []
entry.append(f"username = {_toml(gl.username)}")
if gl.url != "https://gitlab.com": if gl.url != "https://gitlab.com":
lines.append(f"url = {_toml(gl.url)}") entry.append(f"url = {_toml(gl.url)}")
if gl.token: if gl.token:
lines.append(f"token = {_toml(gl.token)}") entry.append(f"token = {_toml(gl.token)}")
if gl.token_env: if gl.token_env:
lines.append(f"token_env = {_toml(gl.token_env)}") entry.append(f"token_env = {_toml(gl.token_env)}")
if config.bts is not None: emit("gitlab", entry)
lines += ["", "[bts]", f"email = {_toml(config.bts.email)}"] 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" return "\n".join(lines) + "\n"
+247 -91
View File
@@ -1,17 +1,25 @@
"""Interactive onboarding wizard behind `weekly-activity config`. """Interactive onboarding wizard behind `weekly-activity config`.
Asks which sources to enable and the identity/auth details for each, then A small menu-driven editor for the account-list config: it shows the
writes the TOML config. Never echoes token values back to the console; does current sources (secrets masked), then offers Add a new source /
not run any OAuth flow — for Launchpad credentials mode it only records the Remove a source / Exit, supporting several accounts per provider.
choice (launchpadlib's keyring flow runs at report time).
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 from __future__ import annotations
import getpass import getpass
import os
import sys import sys
from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from weekly_activity.aggregate import source_label
from weekly_activity.config import ( from weekly_activity.config import (
ActivityConfig, ActivityConfig,
BtsSettings, BtsSettings,
@@ -22,38 +30,76 @@ from weekly_activity.config import (
load_config, load_config,
save_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: def run_wizard(config_path: Path) -> None:
"""Walk through source setup and write ``config_path``.""" """Edit the source configuration interactively; write when changed."""
existing = _load_existing(config_path) try:
if existing.enabled_sources(): config = _load_existing(config_path)
print(f"Found an existing configuration at {config_path}:") original = _snapshot(config)
print(_describe(existing, indent=" ")) print()
if not _confirm("Re-run setup?"): print("Welcome! This wizard manages the sources aggregated by")
print("Keeping the current configuration.") print("`weekly-activity report`. Enter a menu number, accept [defaults]")
return 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( def _snapshot(config: ActivityConfig) -> ActivityConfig:
launchpad=_configure_launchpad(existing.launchpad), """Copy the lists so edits can be compared against the starting point."""
github=_configure_github(existing.github), return ActivityConfig(
gitlab=_configure_gitlab(existing.gitlab), launchpad=list(config.launchpad),
bts=_configure_bts(existing.bts), 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) save_config(config, config_path)
enabled = ", ".join(config.enabled_sources())
print() print()
print(f"Configuration written to {config_path}.") print(f"Configuration written to {config_path}.")
print(f"Enabled sources: {', '.join(enabled)}.") print(f"Enabled sources: {enabled}.")
print(_describe(config, indent="")) print(_describe(config, indent=""))
print("Tokens are never printed back. Run `weekly-activity report` to") print("Tokens are never printed back. Run `weekly-activity report` to")
print("aggregate activity across all of them in one output.") print("aggregate activity across all of them in one output.")
@@ -70,65 +116,197 @@ def _load_existing(path: Path) -> ActivityConfig:
return ActivityConfig() return ActivityConfig()
# --- Per-source prompts --- # --- Add / remove flows ---
def _configure_launchpad(prev: LaunchpadSettings | None) -> LaunchpadSettings | None: def _add_source_flow(config: ActivityConfig) -> None:
if not _confirm("Add Launchpad?", default=False): chosen = _menu(
return None "Which source do you want to add?", [text for _, text in _KINDS], cancellable=True
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( if chosen is None:
username=username, mode="anonymous" if anonymous else "credentials" return
) kind = _KINDS[chosen][0]
if not anonymous: account = _ADDERS[kind]()
print( if account is None:
"OAuth is obtained at report time: launchpadlib opens a browser and\n" print("Cancelled; nothing was added.")
"stores the token in your system keyring, or reads it from a file." return
) accounts = getattr(config, kind)
settings.credentials_file = _ask( accounts.append(account)
"Credentials file (empty to use the system keyring)", label = source_label(kind, _display_name(account), _identity(account), len(accounts) > 1)
prev.credentials_file if prev else "", print(f"Added: {label} ({_identity(account)}).")
)
return settings
def _configure_github(prev: GithubSettings | None) -> GithubSettings | None: def _remove_source_flow(config: ActivityConfig) -> None:
if not _confirm("Add GitHub?", default=False): entries: list[tuple[str, int]] = []
return None options: list[str] = []
username = _ask_required("GitHub username", prev.username if prev else "") for kind, _ in _KINDS:
print("Leave the token empty to fall back to $GITHUB_TOKEN or `gh auth token`.") accounts = getattr(config, kind)
token = _ask_secret("GitHub token (input hidden)", prev.token if prev else "") for index, account in enumerate(accounts):
return GithubSettings(username=username, token=token) 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: def _add_github() -> GithubSettings | None:
if not _confirm("Add GitLab?", default=False): print("GitHub identity is the profile @handle.")
return None username = _ask_required("GitHub username")
defaults = prev or GitlabSettings(username="") name = _ask("Display name (optional label)")
username = _ask_required("GitLab username", defaults.username) print("Leave both token fields empty to fall back to $GITHUB_TOKEN or `gh auth token`.")
url = _ask("Instance URL", defaults.url or "https://gitlab.com") token_env = _ask("Env var holding the token (optional, e.g. GITHUB_TOKEN)")
print("Leave the token empty to discover it from the environment instead.") token = _ask_secret("GitHub token (input hidden)")
token = _ask_secret("API token (input hidden)", defaults.token) return GithubSettings(username=username, token=token, token_env=token_env, name=name)
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: def _add_gitlab() -> GitlabSettings | None:
if not _confirm("Add Debian BTS?", default=False): print("GitLab needs the instance URL plus your username there.")
return None 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).") 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 "") email = _ask_required("Email address on Debian bugs")
return BtsSettings(email=email) 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 "<HOST>_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 --- # --- 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: def _ask(prompt: str, default: str = "") -> str:
suffix = f" [{default}]" if default else "" suffix = f" [{default}]" if default else ""
answer = input(f"{prompt}{suffix}: ").strip() answer = input(f"{prompt}{suffix}: ").strip()
@@ -162,25 +340,3 @@ def _confirm(prompt: str, *, default: bool = False) -> bool:
return True return True
if answer in {"n", "no"}: if answer in {"n", "no"}:
return False 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)"
+81 -13
View File
@@ -48,29 +48,90 @@ def window() -> tuple[datetime, datetime]:
class BuildSpecsTest(unittest.TestCase): class BuildSpecsTest(unittest.TestCase):
def test_maps_every_section_in_order(self) -> None: def test_maps_every_section_in_order(self) -> None:
config = ActivityConfig( config = ActivityConfig(
launchpad=LaunchpadSettings(username="lp"), launchpad=[LaunchpadSettings(username="lp")],
github=GithubSettings(username="gh"), github=[GithubSettings(username="gh")],
gitlab=GitlabSettings(username="gl"), gitlab=[GitlabSettings(username="gl")],
bts=BtsSettings(email="bts@mail"), bts=[BtsSettings(email="bts@mail")],
) )
specs = build_specs(config) specs = build_specs(config)
self.assertEqual([s.name for s in specs], ["launchpad", "github", "gitlab", "bts"]) 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"]) 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: def test_disabled_sections_are_skipped(self) -> None:
config = ActivityConfig(github=GithubSettings(username="only-one")) config = ActivityConfig(bts=[BtsSettings(email="only@one")])
self.assertEqual([s.name for s in build_specs(config)], ["github"]) self.assertEqual([s.name for s in build_specs(config)], ["bts"])
def test_launchpad_spec_expands_tilde_credentials_file(self) -> None: def test_launchpad_spec_expands_tilde_credentials_file(self) -> None:
settings = LaunchpadSettings( config = ActivityConfig(launchpad=[LaunchpadSettings(username="anon")])
username="u", mode="credentials", credentials_file="~/creds.json" config.launchpad.append(LaunchpadSettings(mode="credentials", username="u",
) credentials_file="~/creds.json"))
spec = build_specs(ActivityConfig(launchpad=settings))[0] specs = build_specs(config)
with mock.patch("weekly_activity.aggregate.LaunchpadSource") as fake_source: 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() spec.make()
kwargs = fake_source.call_args.kwargs self.assertEqual(fake_source.call_args.kwargs["token"], "env-token")
self.assertEqual(kwargs["credentials_file"], os.path.expanduser("~") + "/creds.json")
self.assertFalse(kwargs["anonymous"]) 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): class CollectAndFormatTest(unittest.TestCase):
@@ -89,6 +150,13 @@ class CollectAndFormatTest(unittest.TestCase):
self.assertIn("Activity report — alpha / ~a", text) self.assertIn("Activity report — alpha / ~a", text)
self.assertIn("[skipped: beta — RuntimeError: kaput]", 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: def test_warning_surfaced_inside_block(self) -> None:
since, until = window() since, until = window()
specs = [SourceSpec(name="gamma", username="g", make=lambda: StubSource("gamma"))] specs = [SourceSpec(name="gamma", username="g", make=lambda: StubSource("gamma"))]
+166 -38
View File
@@ -21,54 +21,93 @@ from weekly_activity.config import (
) )
class ConfigRoundTripTest(unittest.TestCase): def load_config_from_text(text: str) -> ActivityConfig:
def test_round_trip_preserves_all_sections(self) -> None: """Helper: parse TOML through the public loader without touching disk."""
config = ActivityConfig( with tempfile.TemporaryDirectory() as tmp:
launchpad=LaunchpadSettings( 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", username="lp-user",
mode="credentials", mode="credentials",
credentials_file="/tmp/lp-creds.json", credentials_file="/tmp/lp-creds.json",
service="staging", service="staging",
name="work",
), ),
github=GithubSettings(username='gh"user', token="tok\\en"), LaunchpadSettings(username="anon-lp"),
gitlab=GitlabSettings( ],
github=[
GithubSettings(username='gh"user', token="tok\\en"),
GithubSettings(
username="acme-jane", token_env="WORK_GH_TOKEN", name="work"
),
],
gitlab=[
GitlabSettings(
username="gl-user", username="gl-user",
url="https://salsa.debian.org", url="https://salsa.debian.org",
token_env="SALSA_TOKEN", token_env="SALSA_TOKEN",
), name="salsa",
bts=BtsSettings(email="dev@example.org"), )
) ],
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: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "weekly-activity.toml" path = Path(tmp) / "weekly-activity.toml"
save_config(config, path) save_config(config, path)
self.assertEqual(load_config(path), config) 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: 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: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "nested" / "cfg.toml" path = Path(tmp) / "nested" / "cfg.toml"
save_config(config, path) save_config(config, path)
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600) 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: def test_empty_optionals_round_trip_to_defaults(self) -> None:
config = ActivityConfig( config = ActivityConfig(
github=GithubSettings(username="octocat"), github=[GithubSettings(username="octocat")],
bts=BtsSettings(email="a@b.c"), bts=[BtsSettings(email="a@b.c")],
) )
text = format_toml(config) reparsed = load_config_from_text(format_toml(config))
reparsed = load_config_from_text(text) self.assertEqual(reparsed.github, [GithubSettings(username="octocat")])
github = reparsed.github self.assertEqual(reparsed.bts, [BtsSettings(email="a@b.c")])
assert github is not None self.assertEqual(reparsed.launchpad, [])
self.assertEqual(github.username, "octocat")
self.assertEqual(github.token, "")
self.assertIsNone(reparsed.launchpad)
def test_escapes_quotes_backslashes_and_newlines(self) -> None: 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)) reparsed = load_config_from_text(format_toml(config))
github = reparsed.github self.assertEqual(reparsed.github[0].username, 'a"b\\c\nd')
assert github is not None
self.assertEqual(github.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): class ConfigLoadingTest(unittest.TestCase):
@@ -78,26 +117,123 @@ class ConfigLoadingTest(unittest.TestCase):
def test_unknown_section_rejected(self) -> None: def test_unknown_section_rejected(self) -> None:
with self.assertRaises(ConfigError): 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): 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: def test_non_string_value_rejected(self) -> None:
with self.assertRaises(ConfigError): 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: def test_bad_launchpad_mode_rejected(self) -> None:
with self.assertRaises(ConfigError): 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: def test_enabled_sources_stable_order(self) -> None:
config = load_config_from_text( config = ActivityConfig(
'[bts]\nemail = "e"\n\n[github]\nusername = "g"\n' bts=[BtsSettings(email="e")],
github=[GithubSettings(username="g")],
) )
self.assertEqual(config.enabled_sources(), ["github", "bts"]) 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: def test_default_path_uses_xdg_or_home(self) -> None:
old = os.environ.get("XDG_CONFIG_HOME") old = os.environ.get("XDG_CONFIG_HOME")
try: try:
@@ -110,13 +246,5 @@ class ConfigLoadingTest(unittest.TestCase):
os.environ["XDG_CONFIG_HOME"] = old 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__": if __name__ == "__main__":
unittest.main() unittest.main()