forked from vhaudiquet/weekly-activity
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user