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.
236 lines
7.6 KiB
Python
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()
|