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
`weekly-activity config` walks through each known source and writes a TOML
file. Tokens are stored as provided (plaintext), but the file is created with
owner-only permissions (`0600`) and secrets are never echoed back. Use
`--config <path>` (also accepted by `report`) to keep configs elsewhere.
`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 <path>` (also accepted by
`report`) to keep configs elsewhere.
Default location: `${XDG_CONFIG_HOME:-~/.config}/weekly-activity.toml`.
```toml
[github]
username = "octocat"
token = "" # empty -> fall back to $GITHUB_TOKEN / `gh auth token`
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
+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
API, unknown user) is recorded as a ``[skipped: <source> …]`` block instead
of aborting the whole report. Per-source warnings surfaced by ``collect``
are rendered inside the source's block by ``format_report``.
Each configured account runs independently: a failure (bad token,
unreachable API, unknown user) is recorded as a ``[skipped: <source> …]``
block instead of aborting the whole report. Per-source warnings surfaced by
``collect`` are rendered inside the source's block by ``format_report``.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from 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
+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``
(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 (<HOST>_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 ``[[<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:
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"
+247 -91
View File
@@ -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 "<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 ---
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 <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):
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"))]
+166 -38
View File
@@ -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()