Files
weekly-activity/src/weekly_activity/wizard.py
T
vhaudiquet 453a03c10a Add config onboarding wizard and aggregated report
- `weekly-activity config`: interactive wizard writing
  ~/.config/weekly-activity.toml (XDG-aware, --config override), one
  section per source, 0600 permissions, tokens never echoed; re-runs show
  current settings and pre-fill defaults
- `weekly-activity report` (and bare invocation): loads enabled sources,
  collects each independently, joins blocks under one header; a failing
  source renders as [skipped: <source> — error] without aborting
- config.py: typed schema, tomllib reader with strict validation,
  hand-formatted TOML writer (stdlib-only, no new dependency)
- aggregate.py: source spec building + failure-isolated collection +
  combined rendering
- README: document config/report usage and config schema
- tests/: 16 stdlib-unittest cases (config round-trip/escaping/validation,
  aggregation mapping/isolation)
2026-08-26 20:18:41 +00:00

187 lines
6.9 KiB
Python

"""Interactive onboarding wizard behind `weekly-activity config`.
Asks which sources to enable and the identity/auth details for each, then
writes the TOML config. Never echoes token values back to the console; does
not run any OAuth flow — for Launchpad credentials mode it only records the
choice (launchpadlib's keyring flow runs at report time).
"""
from __future__ import annotations
import getpass
import sys
from pathlib import Path
from weekly_activity.config import (
ActivityConfig,
BtsSettings,
ConfigError,
GithubSettings,
GitlabSettings,
LaunchpadSettings,
load_config,
save_config,
)
def run_wizard(config_path: Path) -> None:
"""Walk through source setup and write ``config_path``."""
existing = _load_existing(config_path)
if existing.enabled_sources():
print(f"Found an existing configuration at {config_path}:")
print(_describe(existing, indent=" "))
if not _confirm("Re-run setup?"):
print("Keeping the current configuration.")
return
print()
print("Welcome! This wizard sets up the sources aggregated by")
print("`weekly-activity report`. Answer with Enter to accept [defaults],")
print("or Ctrl-C to abort without writing anything.")
config = ActivityConfig(
launchpad=_configure_launchpad(existing.launchpad),
github=_configure_github(existing.github),
gitlab=_configure_gitlab(existing.gitlab),
bts=_configure_bts(existing.bts),
)
enabled = config.enabled_sources()
if not enabled:
raise SystemExit("warning: no source selected; nothing was written.")
save_config(config, config_path)
print()
print(f"Configuration written to {config_path}.")
print(f"Enabled sources: {', '.join(enabled)}.")
print(_describe(config, indent=""))
print("Tokens are never printed back. Run `weekly-activity report` to")
print("aggregate activity across all of them in one output.")
def _load_existing(path: Path) -> ActivityConfig:
try:
return load_config(path)
except FileNotFoundError:
return ActivityConfig()
except ConfigError as exc:
print(f"warning: ignoring unusable existing config: {exc}", file=sys.stderr)
print(f"warning: {path} will be replaced by a fresh configuration.", file=sys.stderr)
return ActivityConfig()
# --- Per-source prompts ---
def _configure_launchpad(prev: LaunchpadSettings | None) -> LaunchpadSettings | None:
if not _confirm("Add Launchpad?", default=False):
return None
print("Launchpad identity is the ~name shown on your profile page.")
username = _ask_required("Launchpad username", prev.username if prev else "")
anonymous = _confirm(
"Anonymous mode (public data only, no OAuth)?",
default=prev.mode == "anonymous" if prev else True,
)
settings = LaunchpadSettings(
username=username, mode="anonymous" if anonymous else "credentials"
)
if not anonymous:
print(
"OAuth is obtained at report time: launchpadlib opens a browser and\n"
"stores the token in your system keyring, or reads it from a file."
)
settings.credentials_file = _ask(
"Credentials file (empty to use the system keyring)",
prev.credentials_file if prev else "",
)
return settings
def _configure_github(prev: GithubSettings | None) -> GithubSettings | None:
if not _confirm("Add GitHub?", default=False):
return None
username = _ask_required("GitHub username", prev.username if prev else "")
print("Leave the token empty to fall back to $GITHUB_TOKEN or `gh auth token`.")
token = _ask_secret("GitHub token (input hidden)", prev.token if prev else "")
return GithubSettings(username=username, token=token)
def _configure_gitlab(prev: GitlabSettings | None) -> GitlabSettings | None:
if not _confirm("Add GitLab?", default=False):
return None
defaults = prev or GitlabSettings(username="")
username = _ask_required("GitLab username", defaults.username)
url = _ask("Instance URL", defaults.url or "https://gitlab.com")
print("Leave the token empty to discover it from the environment instead.")
token = _ask_secret("API token (input hidden)", defaults.token)
token_env = _ask("Env var holding the token (optional, e.g. SALSA_TOKEN)", defaults.token_env)
return GitlabSettings(username=username, url=url, token=token, token_env=token_env)
def _configure_bts(prev: BtsSettings | None) -> BtsSettings | None:
if not _confirm("Add Debian BTS?", default=False):
return None
print("The BTS identifies people by the email used on bugs (submitter/owner).")
email = _ask_required("Email address on Debian bugs", prev.email if prev else "")
return BtsSettings(email=email)
# --- Prompt helpers ---
def _ask(prompt: str, default: str = "") -> str:
suffix = f" [{default}]" if default else ""
answer = input(f"{prompt}{suffix}: ").strip()
return answer or default
def _ask_required(prompt: str, default: str = "") -> str:
while True:
answer = _ask(prompt, default)
if answer:
return answer
print("A value is required here.")
def _ask_secret(prompt: str, default: str = "") -> str:
"""Read a secret without echo; keep the stored one when input is empty."""
try:
entered = getpass.getpass(f"{prompt}: ")
except (EOFError, KeyboardInterrupt):
raise SystemExit(130) from None
return entered.strip() or default
def _confirm(prompt: str, *, default: bool = False) -> bool:
hint = "[Y/n]" if default else "[y/N]"
while True:
answer = input(f"{prompt} {hint}: ").strip().lower()
if not answer:
return default
if answer in {"y", "yes"}:
return True
if answer in {"n", "no"}:
return False
def _describe(config: ActivityConfig, indent: str = " ") -> str:
"""Human-readable summary that masks every token value."""
lines: list[str] = []
if (lp := config.launchpad) is not None:
auth = (
"anonymous (public data only)"
if lp.mode == "anonymous"
else f"OAuth via {lp.credentials_file or 'system keyring'}"
)
lines.append(f"{indent}launchpad: {lp.username}{auth}")
if (gh := config.github) is not None:
token_desc = "stored" if gh.token else "not stored (uses GITHUB_TOKEN / gh)"
lines.append(f"{indent}github: {gh.username} — token {token_desc}")
if (gl := config.gitlab) is not None:
fallback = gl.token_env or "uses <HOST>_TOKEN / GITLAB_TOKEN env"
token_desc = "stored" if gl.token else f"not stored ({fallback})"
lines.append(f"{indent}gitlab: {gl.username} @ {gl.url} — token {token_desc}")
if (bts := config.bts) is not None:
lines.append(f"{indent}bts: {bts.email}")
return "\n".join(lines) if lines else f"{indent}(no sources configured)"