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
+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)"