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`
170 lines
6.8 KiB
Python
170 lines
6.8 KiB
Python
"""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_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(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:
|
|
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()
|
|
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):
|
|
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_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"))]
|
|
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()
|