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)
This commit is contained in:
2026-08-26 20:18:41 +00:00
committed by vhaudiquet
parent cbca729693
commit 6d8cc3d817
7 changed files with 921 additions and 12 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Unit tests for weekly_activity.aggregate — stubbed sources, no network."""
from __future__ import annotations
import os
import unittest
from datetime import datetime
from unittest import mock
from weekly_activity.aggregate import (
SourceSpec,
build_specs,
collect_specs,
format_combined,
)
from weekly_activity.config import (
ActivityConfig,
BtsSettings,
GithubSettings,
GitlabSettings,
LaunchpadSettings,
)
from weekly_activity.model import ActivityReport, Section
class StubSource:
def __init__(self, name: str = "stub", *, fail: bool = False) -> None:
self.name = name
self._fail = fail
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport:
if self._fail:
raise RuntimeError("kaput")
return ActivityReport(
source=self.name,
username=username,
since=since,
until=until,
sections=[Section("Things", ["did a thing"])],
warnings=["some query failed"],
)
def window() -> tuple[datetime, datetime]:
return datetime(2026, 8, 19), datetime(2026, 8, 26)
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"),
)
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_disabled_sections_are_skipped(self) -> None:
config = ActivityConfig(github=GithubSettings(username="only-one"))
self.assertEqual([s.name for s in build_specs(config)], ["github"])
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]
with mock.patch("weekly_activity.aggregate.LaunchpadSource") as fake_source:
spec.make()
kwargs = fake_source.call_args.kwargs
self.assertEqual(kwargs["credentials_file"], os.path.expanduser("~") + "/creds.json")
self.assertFalse(kwargs["anonymous"])
class CollectAndFormatTest(unittest.TestCase):
def test_failure_isolated_per_source(self) -> None:
since, until = window()
specs = [
SourceSpec(name="alpha", username="a", make=lambda: StubSource("alpha")),
SourceSpec(name="beta", username="b", make=lambda: StubSource("beta", fail=True)),
]
collected = collect_specs(specs, since, until)
self.assertIsNotNone(collected[0].report)
self.assertIn("RuntimeError: kaput", collected[1].error or "")
text = format_combined(collected, since, until)
self.assertIn("Weekly activity report — 2 source(s)", text)
self.assertIn("Activity report — alpha / ~a", text)
self.assertIn("[skipped: beta — RuntimeError: kaput]", text)
def test_warning_surfaced_inside_block(self) -> None:
since, until = window()
specs = [SourceSpec(name="gamma", username="g", make=lambda: StubSource("gamma"))]
text = format_combined(collect_specs(specs, since, until), since, until)
self.assertIn("Warnings (partial data — some queries failed):", text)
self.assertIn("some query failed", text)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Unit tests for weekly_activity.config — pure logic, no network."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from weekly_activity.config import (
ActivityConfig,
BtsSettings,
ConfigError,
GithubSettings,
GitlabSettings,
LaunchpadSettings,
default_config_path,
format_toml,
load_config,
save_config,
)
class ConfigRoundTripTest(unittest.TestCase):
def test_round_trip_preserves_all_sections(self) -> None:
config = ActivityConfig(
launchpad=LaunchpadSettings(
username="lp-user",
mode="credentials",
credentials_file="/tmp/lp-creds.json",
service="staging",
),
github=GithubSettings(username='gh"user', token="tok\\en"),
gitlab=GitlabSettings(
username="gl-user",
url="https://salsa.debian.org",
token_env="SALSA_TOKEN",
),
bts=BtsSettings(email="dev@example.org"),
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "weekly-activity.toml"
save_config(config, path)
self.assertEqual(load_config(path), config)
def test_save_creates_owner_only_file(self) -> None:
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_optionals_round_trip_to_defaults(self) -> None:
config = ActivityConfig(
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)
def test_escapes_quotes_backslashes_and_newlines(self) -> None:
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')
class ConfigLoadingTest(unittest.TestCase):
def test_missing_file_raises_filenotfound(self) -> None:
with self.assertRaises(FileNotFoundError):
load_config(Path("/nonexistent/weekly-activity.toml"))
def test_unknown_section_rejected(self) -> None:
with self.assertRaises(ConfigError):
load_config_from_text("[gitea]\nusername = 'x'\n")
def test_unknown_key_rejected(self) -> None:
with self.assertRaises(ConfigError):
load_config_from_text('[github]\nusername = "x"\ntokken = "t"\n')
def test_non_string_value_rejected(self) -> None:
with self.assertRaises(ConfigError):
load_config_from_text("[github]\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')
def test_enabled_sources_stable_order(self) -> None:
config = load_config_from_text(
'[bts]\nemail = "e"\n\n[github]\nusername = "g"\n'
)
self.assertEqual(config.enabled_sources(), ["github", "bts"])
def test_default_path_uses_xdg_or_home(self) -> None:
old = os.environ.get("XDG_CONFIG_HOME")
try:
os.environ["XDG_CONFIG_HOME"] = "/custom/cfg"
self.assertEqual(default_config_path(), Path("/custom/cfg/weekly-activity.toml"))
del os.environ["XDG_CONFIG_HOME"]
self.assertTrue(str(default_config_path()).endswith("/.config/weekly-activity.toml"))
finally:
if old is not None:
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()