Files
weekly-activity/src/weekly_activity/cli.py
T
kosmos 85ddfb4a71 Add arrow-key menus and report loading status
wizard: when stdin and stdout are both TTYs, menus render inline and
are driven by keys: up/down/j/k/Home/End move the bold '>' highlight,
Enter accepts, Esc/Ctrl-C/q back out of cancellable menus while Ctrl-C
on fixed menus still aborts. Rows redraw with erase+CR across the
option block only and clear before returning so prompts stay aligned.
Non-TTY stdio keeps the original numbered prompt verbatim.

report: each configured source shows a transient "# Generating
report: pulling <name> data..." line that is erased on completion,
only when stdout is a TTY; piped output remains exactly the report.

tests: cover the numbered fallback, row rendering, status suppression
and show+clear parity; reformat stray test files to satisfy ruff.
2026-08-27 07:46:48 +00:00

236 lines
7.6 KiB
Python

"""Command-line entry point: parse args, dispatch to source, print report."""
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from weekly_activity.aggregate import (
Collected,
SourceSpec,
build_specs,
collect_specs,
format_combined,
)
from weekly_activity.config import ConfigError, default_config_path, load_config
from weekly_activity.model import last_week_window
from weekly_activity.report import format_report
from weekly_activity.sources.debian_bts import DebianBtsSource
from weekly_activity.sources.github import GitHubSource
from weekly_activity.sources.gitlab import GitLabSource
from weekly_activity.sources.launchpad import LaunchpadSource
from weekly_activity.wizard import run_wizard
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
base = argparse.ArgumentParser(add_help=False)
base.add_argument(
"--since",
help="Inclusive start as an ISO date/datetime (UTC). Default: 7 days ago.",
)
base.add_argument(
"--until",
help="Inclusive end as an ISO date/datetime (UTC). Default: today.",
)
common = argparse.ArgumentParser(add_help=False)
common.add_argument(
"--config",
default=None,
metavar="PATH",
help="Config file location. Default: ~/.config/weekly-activity.toml.",
)
parser = argparse.ArgumentParser(
prog="weekly-activity",
description=(
"Summarize dev-platform activity for a week - one source at a time,"
" or aggregated across everything configured."
),
)
subparsers = parser.add_subparsers(
dest="command",
required=False,
help="a single source, `report` for all configured ones, `config` to set up",
)
lp = subparsers.add_parser("launchpad", parents=[base, common], help="Launchpad activity.")
lp.add_argument("username", help="Launchpad username (the ~name).")
lp.add_argument(
"--anonymous",
action="store_true",
help="Anonymous access: public data only, no OAuth.",
)
lp.add_argument("--credentials-file", default=None, help="OAuth token file path.")
lp.add_argument(
"--service",
choices=["production", "staging"],
default="production",
)
gh = subparsers.add_parser("github", parents=[base, common], help="GitHub activity.")
gh.add_argument("username", help="GitHub username.")
gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).")
gl = subparsers.add_parser(
"gitlab",
parents=[base, common],
help="GitLab activity (gitlab.com or any self-hosted instance, e.g. Debian Salsa).",
)
gl.add_argument("username", help="GitLab username.")
gl.add_argument(
"--url",
default="https://gitlab.com",
help="Base URL of the GitLab instance (default: https://gitlab.com).",
)
gl.add_argument("--token", default=None, help="API token (overrides env discovery).")
gl.add_argument(
"--token-env",
default=None,
metavar="VAR",
help="Env var holding the API token (see README for discovery order).",
)
bts = subparsers.add_parser(
"bts", parents=[base, common], help="Debian Bug Tracking System activity."
)
bts.add_argument("username", help="Email address used on Debian bugs (submitter/owner).")
subparsers.add_parser(
"report",
parents=[base, common],
help="Aggregate activity from every source in the config file.",
)
subparsers.add_parser(
"config",
parents=[common],
help="Interactive wizard that writes the config file.",
)
return parser.parse_args(argv)
def _parse_dt(value: str | None) -> datetime | None:
if value is None:
return None
dt = datetime.fromisoformat(value)
if dt.tzinfo is not None:
dt = dt.astimezone(UTC).replace(tzinfo=None)
return dt
def resolve_period(args: argparse.Namespace) -> tuple[datetime, datetime]:
if args.since or args.until:
since = _parse_dt(args.since)
until = _parse_dt(args.until)
if since is None or until is None:
raise SystemExit("error: --since and --until must be provided together as ISO dates.")
if since >= until:
raise SystemExit("error: --since must be earlier than --until.")
return since, until
return last_week_window()
_STATUS_PREFIX = "# Generating report"
_STATUS_ERASE = "\x1b[2K\r"
def _report_status_active() -> bool:
"""Transient progress lines only make sense on an interactive terminal."""
return sys.stdout.isatty()
def _show_report_status(name: str) -> None:
"""Erase-then-overwrite in place; no newline keeps updates on one row."""
sys.stdout.write(f"{_STATUS_ERASE}{_STATUS_PREFIX}: pulling {name} data...")
sys.stdout.flush()
def _hide_report_status() -> None:
"""Erase the status row so nothing of it survives into the final output."""
sys.stdout.write(_STATUS_ERASE)
sys.stdout.flush()
def _collect_report_sources(
specs: list[SourceSpec], since: datetime, until: datetime
) -> list[Collected]:
"""Run collect_specs per source, showing a TTY-only progress line each."""
show = _report_status_active()
collected: list[Collected] = []
for spec in specs:
if show:
_show_report_status(spec.name)
collected.extend(collect_specs([spec], since, until))
if show:
_hide_report_status()
return collected
def main() -> None:
args = parse_args()
command = args.command or "report"
if command == "config":
run_wizard(_config_path(args))
return
if command == "report":
path = _config_path(args)
try:
config = load_config(path)
except FileNotFoundError as exc:
raise SystemExit(
f"error: no configuration found at {path}; run `weekly-activity config` first."
) from exc
except ConfigError as exc:
raise SystemExit(f"error: {exc}") from exc
specs = build_specs(config)
if not specs:
raise SystemExit(f"error: {path} enables no sources; run `weekly-activity config`.")
since, until = resolve_period(args)
collected = _collect_report_sources(specs, since, until)
print(format_combined(collected, since, until))
return
since, until = resolve_period(args)
if command == "launchpad":
source = LaunchpadSource(
service=args.service,
anonymous=args.anonymous,
credentials_file=args.credentials_file,
)
elif command == "github":
source = GitHubSource(token=args.token)
elif command == "gitlab":
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
elif command == "bts":
source = DebianBtsSource()
else:
raise SystemExit(f"error: unknown source {command!r}")
try:
report = source.collect(args.username, since, until)
except LookupError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(2)
except Exception as exc: # noqa: BLE001 - surface API/network failures cleanly
print(f"error: failed to fetch activity: {exc}", file=sys.stderr)
sys.exit(1)
print(format_report(report))
def _config_path(args: argparse.Namespace) -> Path:
"""Resolve this run's config location (--config override or XDG default)."""
if args.config:
return Path(args.config).expanduser()
return default_config_path()
if __name__ == "__main__":
main()