forked from vhaudiquet/weekly-activity
Restructure to multi-source architecture; fix GitHub repo timeline filter
- Rename package launchpad_weekly_activity -> weekly_activity - Split into model.py (ActivityReport, Section), report.py (source-agnostic formatter), cli.py (subcommand dispatch), sources/ (per-source modules) - Move Launchpad code to sources/launchpad.py implementing ActivitySource - Add GitHub source (sources/github.py) using httpx: PRs, issues, repos, commits - Fix GitHub 'Repositories modified' filtering on pushed_at not updated_at (metadata changes were showing stale repos with no recent code pushes) - Add --credentials-file for Launchpad OAuth (bypass keyring for headless) - Add keyring dep (launchpadlib optional dep; bare import crashes without it) - Graceful per-query error handling: degraded sections + concise warnings - Rolling 7-day window (was previous calendar week)
This commit is contained in:
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "launchpad-weekly-activity"
|
name = "weekly-activity"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Weekly Launchpad activity report (merge proposals, branches, bugs) for a user."
|
description = "Weekly activity report (merge proposals, pull requests, bugs, issues) for Launchpad and GitHub."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["launchpadlib>=2.1.0", "keyring>=25"]
|
dependencies = ["launchpadlib>=2.1.0", "keyring>=25", "httpx>=0.27"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
launchpad-weekly-activity = "launchpad_weekly_activity.cli:main"
|
weekly-activity = "weekly_activity.cli:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -16,7 +16,7 @@ build-backend = "hatchling.build"
|
|||||||
dev = ["ruff>=0.6", "ty>=0.0.1"]
|
dev = ["ruff>=0.6", "ty>=0.0.1"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/launchpad_weekly_activity"]
|
packages = ["src/weekly_activity"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
"""Weekly Launchpad activity report generator."""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""Command-line entry point: parse args, fetch activity, print the report."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from launchpad_weekly_activity.api import (
|
|
||||||
LaunchpadClient,
|
|
||||||
MergeProposal,
|
|
||||||
WeeklyActivity,
|
|
||||||
last_week_window,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="launchpad-weekly-activity",
|
|
||||||
description="Summarize a Launchpad user's activity for last week (or a custom range).",
|
|
||||||
)
|
|
||||||
parser.add_argument("username", help="Launchpad username (the ~name).")
|
|
||||||
parser.add_argument(
|
|
||||||
"--since",
|
|
||||||
help="Inclusive start as an ISO date/datetime (UTC). Default: last Monday 00:00 UTC.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--until",
|
|
||||||
help="Exclusive end as an ISO date/datetime (UTC). Default: this Monday 00:00 UTC.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--service",
|
|
||||||
choices=["production", "staging"],
|
|
||||||
default="production",
|
|
||||||
help="Launchpad service root (default: production).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--anonymous",
|
|
||||||
action="store_true",
|
|
||||||
help="Anonymous access: public data only, no OAuth. Default uses OAuth.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--credentials-file",
|
|
||||||
default=None,
|
|
||||||
help="OAuth token file path (bypasses keyring; useful headless). Default: system keyring.",
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date(dt: datetime | None) -> str:
|
|
||||||
return dt.strftime("%Y-%m-%d") if dt is not None else "—"
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_mp(mp: MergeProposal) -> str:
|
|
||||||
message = (mp.commit_message or "(no commit message)").strip().splitlines()[0]
|
|
||||||
return f"[{mp.queue_status}] {message}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_section(title: str, lines: list[str]) -> str:
|
|
||||||
if not lines:
|
|
||||||
return f"{title} (0)\n (none)"
|
|
||||||
return f"{title} ({len(lines)})\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def format_report(activity: WeeklyActivity) -> str:
|
|
||||||
out: list[str] = []
|
|
||||||
out.append(f"Launchpad weekly activity — ~{activity.username}")
|
|
||||||
out.append(
|
|
||||||
f"Period: {activity.since.strftime('%Y-%m-%d')} .. "
|
|
||||||
f"{(activity.until - timedelta(days=1)).strftime('%Y-%m-%d')} (UTC, inclusive)"
|
|
||||||
)
|
|
||||||
out.append("")
|
|
||||||
|
|
||||||
mp_lines: list[str] = []
|
|
||||||
for mp in activity.authored_mps:
|
|
||||||
mp_lines.append(
|
|
||||||
f" {_fmt_mp(mp)}\n"
|
|
||||||
f" created {_fmt_date(mp.date_created)} · "
|
|
||||||
f"merged {_fmt_date(mp.date_merged)}\n"
|
|
||||||
f" {mp.web_link}"
|
|
||||||
)
|
|
||||||
out.append(_format_section("Merge proposals authored", mp_lines))
|
|
||||||
|
|
||||||
review_lines: list[str] = []
|
|
||||||
for mp in activity.new_review_requests:
|
|
||||||
review_lines.append(
|
|
||||||
f" {_fmt_mp(mp)}\n"
|
|
||||||
f" requested {_fmt_date(mp.date_review_requested)}\n"
|
|
||||||
f" {mp.web_link}"
|
|
||||||
)
|
|
||||||
out.append("")
|
|
||||||
out.append(_format_section("New review requests received", review_lines))
|
|
||||||
|
|
||||||
perf_lines: list[str] = []
|
|
||||||
for mp in activity.performed_reviews:
|
|
||||||
perf_lines.append(
|
|
||||||
f" {_fmt_mp(mp)}\n reviewed {_fmt_date(mp.date_reviewed)}\n {mp.web_link}"
|
|
||||||
)
|
|
||||||
out.append("")
|
|
||||||
out.append(_format_section("Reviews performed", perf_lines))
|
|
||||||
repo_lines: list[str] = []
|
|
||||||
for repo in activity.git_repositories:
|
|
||||||
name = repo.display_name or repo.name or repo.web_link
|
|
||||||
branch = repo.default_branch or "—"
|
|
||||||
repo_lines.append(
|
|
||||||
f" {name}\n"
|
|
||||||
f" default branch {branch} · "
|
|
||||||
f"last modified {_fmt_date(repo.date_last_modified)}\n"
|
|
||||||
f" {repo.web_link}"
|
|
||||||
)
|
|
||||||
out.append("")
|
|
||||||
out.append(_format_section("Repositories modified", repo_lines))
|
|
||||||
|
|
||||||
bug_lines: list[str] = []
|
|
||||||
for bug in activity.bugs:
|
|
||||||
roles = "/".join(sorted(bug.roles)) or "?"
|
|
||||||
bug_id = f"#{bug.bug_id}" if bug.bug_id is not None else "#?"
|
|
||||||
bug_lines.append(
|
|
||||||
f" [{bug_id}] {bug.title}\n"
|
|
||||||
f" {roles} · created {_fmt_date(bug.date_created)} · "
|
|
||||||
f"updated {_fmt_date(bug.date_last_updated)}\n"
|
|
||||||
f" {bug.web_link}"
|
|
||||||
)
|
|
||||||
out.append("")
|
|
||||||
out.append(_format_section("Bugs", bug_lines))
|
|
||||||
|
|
||||||
if activity.warnings:
|
|
||||||
out.append("")
|
|
||||||
out.append("Warnings (partial data — some queries failed):")
|
|
||||||
for w in activity.warnings:
|
|
||||||
out.append(f" {w}")
|
|
||||||
out.append("Note: commit-level history is not exposed by the Launchpad REST API;")
|
|
||||||
out.append("read the branch directly (bzr/git) for per-commit activity.")
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
args = parse_args()
|
|
||||||
since, until = resolve_period(args)
|
|
||||||
client = LaunchpadClient(
|
|
||||||
service=args.service,
|
|
||||||
anonymous=args.anonymous,
|
|
||||||
application_name="launchpad-weekly-activity",
|
|
||||||
credentials_file=args.credentials_file,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
activity = client.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(activity))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Weekly activity report generator for multiple platforms."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""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 weekly_activity.model import last_week_window
|
||||||
|
from weekly_activity.report import format_report
|
||||||
|
from weekly_activity.sources.github import GitHubSource
|
||||||
|
from weekly_activity.sources.launchpad import LaunchpadSource
|
||||||
|
|
||||||
|
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="weekly-activity",
|
||||||
|
description="Summarize a user's activity for last week (or a custom range).",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="source", required=True)
|
||||||
|
|
||||||
|
lp = subparsers.add_parser("launchpad", parents=[base], 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], 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).")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
since, until = resolve_period(args)
|
||||||
|
|
||||||
|
if args.source == "launchpad":
|
||||||
|
source = LaunchpadSource(
|
||||||
|
service=args.service,
|
||||||
|
anonymous=args.anonymous,
|
||||||
|
credentials_file=args.credentials_file,
|
||||||
|
)
|
||||||
|
elif args.source == "github":
|
||||||
|
source = GitHubSource(token=args.token)
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"error: unknown source {args.source!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))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Shared data model for weekly activity reports from multiple sources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Section:
|
||||||
|
"""A titled group of formatted entries in the report."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
entries: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActivityReport:
|
||||||
|
"""A weekly activity report produced by an ActivitySource."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
username: str
|
||||||
|
since: datetime
|
||||||
|
until: datetime
|
||||||
|
sections: list[Section]
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def last_week_window(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
|
"""Return the last 7 days ending today (rolling week), naive UTC.
|
||||||
|
|
||||||
|
The range is half-open: ``since <= event < until``. ``until`` is midnight
|
||||||
|
tomorrow, so all of today is included.
|
||||||
|
"""
|
||||||
|
today = (
|
||||||
|
(now or datetime.now(UTC))
|
||||||
|
.astimezone(UTC)
|
||||||
|
.replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
)
|
||||||
|
since = today - timedelta(days=7)
|
||||||
|
until = today + timedelta(days=1)
|
||||||
|
return since, until
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Source-agnostic report formatter — renders ActivityReport sections to text."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from weekly_activity.model import ActivityReport, Section
|
||||||
|
|
||||||
|
|
||||||
|
def _format_section(section: Section) -> str:
|
||||||
|
if not section.entries:
|
||||||
|
return f"{section.title} (0)\n (none)"
|
||||||
|
return f"{section.title} ({len(section.entries)})\n" + "\n".join(section.entries)
|
||||||
|
|
||||||
|
|
||||||
|
def format_report(report: ActivityReport) -> str:
|
||||||
|
out: list[str] = []
|
||||||
|
out.append(f"Activity report — {report.source} / ~{report.username}")
|
||||||
|
out.append(
|
||||||
|
f"Period: {report.since.strftime('%Y-%m-%d')} .. "
|
||||||
|
f"{(report.until - timedelta(days=1)).strftime('%Y-%m-%d')} (UTC, inclusive)"
|
||||||
|
)
|
||||||
|
for section in report.sections:
|
||||||
|
out.append("")
|
||||||
|
out.append(_format_section(section))
|
||||||
|
if report.warnings:
|
||||||
|
out.append("")
|
||||||
|
out.append("Warnings (partial data — some queries failed):")
|
||||||
|
for w in report.warnings:
|
||||||
|
out.append(f" {w}")
|
||||||
|
if report.note:
|
||||||
|
out.append("")
|
||||||
|
out.append(report.note)
|
||||||
|
return "\n".join(out)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Activity source protocol — each source implements this interface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from weekly_activity.model import ActivityReport
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ActivitySource(Protocol):
|
||||||
|
"""A source of weekly activity data (Launchpad, GitHub, etc.)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport: ...
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""GitHub activity source — pull requests, issues, repositories, and commits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from weekly_activity.model import ActivityReport, Section
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_token() -> str | None:
|
||||||
|
"""Discover a GitHub token from GITHUB_TOKEN env var or `gh auth token`."""
|
||||||
|
token = os.environ.get("GITHUB_TOKEN")
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["gh", "auth", "token"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gh_date(s: str | None) -> datetime | None:
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_date(dt: datetime | None) -> str:
|
||||||
|
return dt.strftime("%Y-%m-%d") if dt is not None else "—"
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubSource:
|
||||||
|
"""GitHub activity source implementing ActivitySource."""
|
||||||
|
|
||||||
|
name = "github"
|
||||||
|
|
||||||
|
def __init__(self, *, token: str | None = None) -> None:
|
||||||
|
self._token = token or _discover_token()
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"User-Agent": "weekly-activity",
|
||||||
|
}
|
||||||
|
if self._token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._token}"
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url="https://api.github.com",
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _search_issues(self, query: str) -> list[dict[str, Any]]:
|
||||||
|
response = self._client.get(
|
||||||
|
"/search/issues",
|
||||||
|
params={"q": query, "per_page": 100},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("items", [])
|
||||||
|
|
||||||
|
def _get_user_repos(self, username: str) -> list[dict[str, Any]]:
|
||||||
|
response = self._client.get(
|
||||||
|
f"/users/{username}/repos",
|
||||||
|
params={"sort": "updated", "per_page": 100},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _get_commits(
|
||||||
|
self, owner: str, repo: str, author: str, since_iso: str, until_iso: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
response = self._client.get(
|
||||||
|
f"/repos/{owner}/{repo}/commits",
|
||||||
|
params={"author": author, "since": since_iso, "until": until_iso, "per_page": 100},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport:
|
||||||
|
since_date = since.strftime("%Y-%m-%d")
|
||||||
|
until_date = until.strftime("%Y-%m-%d")
|
||||||
|
iso_since = since.isoformat() + "Z"
|
||||||
|
iso_until = until.isoformat() + "Z"
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
def safe(label: str, fn: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
msg = f"HTTP {exc.response.status_code}"
|
||||||
|
remaining = exc.response.headers.get("x-ratelimit-remaining")
|
||||||
|
if remaining == "0":
|
||||||
|
msg += " (rate limited)"
|
||||||
|
warnings.append(f"{label}: {msg}")
|
||||||
|
return None
|
||||||
|
except Exception as exc: # noqa: BLE001 - degrade section, keep report
|
||||||
|
warnings.append(f"{label}: {type(exc).__name__}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Pull requests authored ---
|
||||||
|
pr_items = safe(
|
||||||
|
"PRs authored",
|
||||||
|
lambda: self._search_issues(
|
||||||
|
f"author:{username} type:pr created:{since_date}..{until_date}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pr_entries: list[str] = []
|
||||||
|
if pr_items:
|
||||||
|
for item in pr_items:
|
||||||
|
state = item.get("state", "unknown")
|
||||||
|
merged_at = item.get("pull_request", {}).get("merged_at")
|
||||||
|
if merged_at:
|
||||||
|
state = "merged"
|
||||||
|
created = _parse_gh_date(item.get("created_at"))
|
||||||
|
merged = _parse_gh_date(merged_at)
|
||||||
|
pr_entries.append(
|
||||||
|
f" [{state}] {item.get('title', '(untitled)')}\n"
|
||||||
|
f" created {_fmt_date(created)} · merged {_fmt_date(merged)}\n"
|
||||||
|
f" {item.get('html_url', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Pull requests reviewed ---
|
||||||
|
review_items = safe(
|
||||||
|
"PRs reviewed",
|
||||||
|
lambda: self._search_issues(
|
||||||
|
f"reviewed-by:{username} type:pr updated:{since_date}..{until_date}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
review_entries: list[str] = []
|
||||||
|
if review_items:
|
||||||
|
for item in review_items:
|
||||||
|
state = item.get("state", "unknown")
|
||||||
|
updated = _parse_gh_date(item.get("updated_at"))
|
||||||
|
review_entries.append(
|
||||||
|
f" [{state}] {item.get('title', '(untitled)')}\n"
|
||||||
|
f" updated {_fmt_date(updated)}\n"
|
||||||
|
f" {item.get('html_url', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Issues (reported/commented/assigned) ---
|
||||||
|
issues: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def add_issue(item: dict[str, Any], role: str) -> None:
|
||||||
|
url = item.get("html_url", "")
|
||||||
|
if url in issues:
|
||||||
|
issues[url]["roles"].add(role)
|
||||||
|
else:
|
||||||
|
issues[url] = {
|
||||||
|
"number": item.get("number"),
|
||||||
|
"title": item.get("title", "(untitled)"),
|
||||||
|
"url": url,
|
||||||
|
"created": _parse_gh_date(item.get("created_at")),
|
||||||
|
"updated": _parse_gh_date(item.get("updated_at")),
|
||||||
|
"roles": {role},
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_queries: list[tuple[str, str, str]] = [
|
||||||
|
("reported", "author", "created"),
|
||||||
|
("commented", "commenter", "updated"),
|
||||||
|
("assigned", "assignee", "updated"),
|
||||||
|
]
|
||||||
|
for role, qualifier, date_field in issue_queries:
|
||||||
|
items = safe(
|
||||||
|
f"issues ({role})",
|
||||||
|
lambda r=role, q=qualifier, d=date_field: self._search_issues(
|
||||||
|
f"{q}:{username} type:issue {d}:{since_date}..{until_date}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if items:
|
||||||
|
for item in items:
|
||||||
|
add_issue(item, role)
|
||||||
|
|
||||||
|
issue_entries = [
|
||||||
|
f" [#{info['number']}] {info['title']}\n"
|
||||||
|
f" {'/'.join(sorted(info['roles']))} · "
|
||||||
|
f"created {_fmt_date(info['created'])} · "
|
||||||
|
f"updated {_fmt_date(info['updated'])}\n"
|
||||||
|
f" {info['url']}"
|
||||||
|
for info in issues.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Repositories modified ---
|
||||||
|
repo_items = safe("repositories", lambda: self._get_user_repos(username))
|
||||||
|
repo_entries: list[str] = []
|
||||||
|
active_repos: list[dict[str, Any]] = []
|
||||||
|
if repo_items:
|
||||||
|
for repo in repo_items:
|
||||||
|
pushed = _parse_gh_date(repo.get("pushed_at"))
|
||||||
|
if pushed is not None and since <= pushed < until:
|
||||||
|
full_name = repo.get("full_name", repo.get("name", "(unknown)"))
|
||||||
|
default_branch = repo.get("default_branch", "—")
|
||||||
|
repo_entries.append(
|
||||||
|
f" {full_name}\n"
|
||||||
|
f" default branch {default_branch} · pushed {_fmt_date(pushed)}\n"
|
||||||
|
f" {repo.get('html_url', '')}"
|
||||||
|
)
|
||||||
|
active_repos.append(repo)
|
||||||
|
|
||||||
|
# --- Commits pushed ---
|
||||||
|
all_commits: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
for repo in active_repos[:20]: # cap to avoid rate-limiting
|
||||||
|
full_name = repo.get("full_name", "")
|
||||||
|
if not full_name:
|
||||||
|
continue
|
||||||
|
owner, _, name = full_name.partition("/")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
commits = safe(
|
||||||
|
f"commits ({full_name})",
|
||||||
|
lambda o=owner, n=name: self._get_commits(o, n, username, iso_since, iso_until),
|
||||||
|
)
|
||||||
|
if commits:
|
||||||
|
for c in commits:
|
||||||
|
all_commits.append((full_name, c))
|
||||||
|
|
||||||
|
def commit_date(c: dict[str, Any]) -> datetime | None:
|
||||||
|
return _parse_gh_date(c.get("commit", {}).get("author", {}).get("date"))
|
||||||
|
|
||||||
|
all_commits.sort(key=lambda x: commit_date(x[1]) or datetime.min, reverse=True)
|
||||||
|
|
||||||
|
commit_entries: list[str] = []
|
||||||
|
for full_name, c in all_commits:
|
||||||
|
message = c.get("commit", {}).get("message", "")
|
||||||
|
first_line = message.splitlines()[0] if message else "(no message)"
|
||||||
|
sha = c.get("sha", "")[:7]
|
||||||
|
date = commit_date(c)
|
||||||
|
commit_entries.append(
|
||||||
|
f" {full_name}: {first_line}\n"
|
||||||
|
f" {sha} · {_fmt_date(date)}\n"
|
||||||
|
f" {c.get('html_url', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._token:
|
||||||
|
warnings.insert(
|
||||||
|
0,
|
||||||
|
"no GitHub token found (set GITHUB_TOKEN or run `gh auth login`); "
|
||||||
|
"anonymous rate limit is 60 req/hour",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ActivityReport(
|
||||||
|
source=self.name,
|
||||||
|
username=username,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
sections=[
|
||||||
|
Section("Pull requests authored", pr_entries),
|
||||||
|
Section("Pull requests reviewed", review_entries),
|
||||||
|
Section("Issues", issue_entries),
|
||||||
|
Section("Repositories modified", repo_entries),
|
||||||
|
Section("Commits pushed", commit_entries),
|
||||||
|
],
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
"""Typed facade over the (dynamic) launchpadlib web service client.
|
"""Launchpad activity source — merge proposals, git repositories, and bugs."""
|
||||||
|
|
||||||
launchpadlib exposes ``ws.op`` operations as runtime-bound methods, so the
|
|
||||||
Launchpad entry objects are deliberately typed as :class:`typing.Any`. Every
|
|
||||||
public function here returns plain dataclasses, keeping the untyped boundary
|
|
||||||
confined to this module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from launchpadlib.launchpad import Launchpad
|
from launchpadlib.launchpad import Launchpad
|
||||||
|
|
||||||
|
from weekly_activity.model import ActivityReport, Section
|
||||||
|
|
||||||
# All BranchMergeProposal queue statuses, per the Launchpad web service docs.
|
# All BranchMergeProposal queue statuses, per the Launchpad web service docs.
|
||||||
MP_STATUSES: tuple[str, ...] = (
|
MP_STATUSES: tuple[str, ...] = (
|
||||||
"Work in progress",
|
"Work in progress",
|
||||||
@@ -27,6 +23,9 @@ MP_STATUSES: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Source-specific dataclasses (internal) ---
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MergeProposal:
|
class MergeProposal:
|
||||||
web_link: str
|
web_link: str
|
||||||
@@ -58,32 +57,7 @@ class BugRef:
|
|||||||
roles: frozenset[str] = field(default_factory=frozenset)
|
roles: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
# --- Helpers ---
|
||||||
class WeeklyActivity:
|
|
||||||
username: str
|
|
||||||
since: datetime
|
|
||||||
until: datetime
|
|
||||||
authored_mps: list[MergeProposal]
|
|
||||||
new_review_requests: list[MergeProposal]
|
|
||||||
performed_reviews: list[MergeProposal]
|
|
||||||
git_repositories: list[GitRepository]
|
|
||||||
bugs: list[BugRef]
|
|
||||||
warnings: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def last_week_window(now: datetime | None = None) -> tuple[datetime, datetime]:
|
|
||||||
"""Return the last 7 days ending today (rolling week), naive UTC.
|
|
||||||
|
|
||||||
The range is half-open: ``since <= event < until``. ``until`` is midnight
|
|
||||||
"""
|
|
||||||
today = (
|
|
||||||
(now or datetime.now(UTC))
|
|
||||||
.astimezone(UTC)
|
|
||||||
.replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
)
|
|
||||||
since = today - timedelta(days=7)
|
|
||||||
until = today + timedelta(days=1)
|
|
||||||
return since, until
|
|
||||||
|
|
||||||
|
|
||||||
def _to_naive_utc(value: object) -> datetime | None:
|
def _to_naive_utc(value: object) -> datetime | None:
|
||||||
@@ -91,7 +65,7 @@ def _to_naive_utc(value: object) -> datetime | None:
|
|||||||
if not isinstance(value, datetime):
|
if not isinstance(value, datetime):
|
||||||
return None
|
return None
|
||||||
if value.tzinfo is not None:
|
if value.tzinfo is not None:
|
||||||
return value.astimezone(UTC).replace(tzinfo=None)
|
return value.astimezone(value.tzinfo).replace(tzinfo=None)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -156,18 +130,12 @@ def _bug_from(bug: Any, role: str) -> BugRef:
|
|||||||
|
|
||||||
|
|
||||||
def _summarize_error(exc: BaseException) -> str:
|
def _summarize_error(exc: BaseException) -> str:
|
||||||
"""Produce a concise warning string from a launchpadlib exception.
|
"""Produce a concise warning string from a launchpadlib exception."""
|
||||||
|
|
||||||
launchpadlib's HTTPError.__str__ dumps the full response (headers + HTML
|
|
||||||
body), which can be thousands of characters. Extract just the status,
|
|
||||||
reason, and OOPS ID.
|
|
||||||
"""
|
|
||||||
response = getattr(exc, "response", None)
|
response = getattr(exc, "response", None)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
status = getattr(response, "status", None)
|
status = getattr(response, "status", None)
|
||||||
reason = getattr(response, "reason", "")
|
reason = getattr(response, "reason", "")
|
||||||
parts = [f"{type(exc).__name__}: HTTP {status} {reason}".rstrip()]
|
parts = [f"{type(exc).__name__}: HTTP {status} {reason}".rstrip()]
|
||||||
# Extract OOPS ID from response headers (x-lazr-oopsid).
|
|
||||||
try:
|
try:
|
||||||
oops = response.get("x-lazr-oopsid") or response.get("X-lazr-oopsid")
|
oops = response.get("x-lazr-oopsid") or response.get("X-lazr-oopsid")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -175,20 +143,33 @@ def _summarize_error(exc: BaseException) -> str:
|
|||||||
if oops:
|
if oops:
|
||||||
parts.append(str(oops) if str(oops).startswith("OOPS") else f"OOPS-{oops}")
|
parts.append(str(oops) if str(oops).startswith("OOPS") else f"OOPS-{oops}")
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
# Fallback: first line of str(exc) to avoid dumping huge payloads.
|
|
||||||
first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
|
||||||
return f"{type(exc).__name__}: {first_line}"
|
return f"{type(exc).__name__}: {first_line}"
|
||||||
|
|
||||||
|
|
||||||
class LaunchpadClient:
|
def _fmt_date(dt: datetime | None) -> str:
|
||||||
"""Read-only Launchpad client wrapping launchpadlib."""
|
return dt.strftime("%Y-%m-%d") if dt is not None else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_mp(mp: MergeProposal) -> str:
|
||||||
|
message = (mp.commit_message or "(no commit message)").strip().splitlines()[0]
|
||||||
|
return f"[{mp.queue_status}] {message}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Source ---
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchpadSource:
|
||||||
|
"""Launchpad activity source implementing ActivitySource."""
|
||||||
|
|
||||||
|
name = "launchpad"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
service: str,
|
service: str = "production",
|
||||||
anonymous: bool,
|
anonymous: bool = False,
|
||||||
application_name: str,
|
application_name: str = "weekly-activity",
|
||||||
credentials_file: str | None = None,
|
credentials_file: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._anonymous = anonymous
|
self._anonymous = anonymous
|
||||||
@@ -216,7 +197,7 @@ class LaunchpadClient:
|
|||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise LookupError(f"no Launchpad user named {username!r}") from exc
|
raise LookupError(f"no Launchpad user named {username!r}") from exc
|
||||||
|
|
||||||
def collect(self, username: str, since: datetime, until: datetime) -> WeeklyActivity:
|
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport:
|
||||||
person = self.get_person(username)
|
person = self.get_person(username)
|
||||||
person_link = _attr_str(person, "self_link")
|
person_link = _attr_str(person, "self_link")
|
||||||
iso_since = since.isoformat()
|
iso_since = since.isoformat()
|
||||||
@@ -224,13 +205,13 @@ class LaunchpadClient:
|
|||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
def safe(label: str, fn: Any) -> Any:
|
def safe(label: str, fn: Any) -> Any:
|
||||||
"""Run a query; on failure record a warning and return None."""
|
|
||||||
try:
|
try:
|
||||||
return fn()
|
return fn()
|
||||||
except Exception as exc: # noqa: BLE001 - degrade section, keep report
|
except Exception as exc: # noqa: BLE001 - degrade section, keep report
|
||||||
warnings.append(f"{label}: {_summarize_error(exc)}")
|
warnings.append(f"{label}: {_summarize_error(exc)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# --- Merge proposals authored ---
|
||||||
authored_mps: list[MergeProposal] = []
|
authored_mps: list[MergeProposal] = []
|
||||||
result = safe(
|
result = safe(
|
||||||
"merge proposals",
|
"merge proposals",
|
||||||
@@ -241,10 +222,16 @@ class LaunchpadClient:
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
authored_mps = [_mp_from(e) for e in result]
|
authored_mps = [_mp_from(e) for e in result]
|
||||||
|
|
||||||
|
mp_entries = [
|
||||||
|
f" {_fmt_mp(mp)}\n"
|
||||||
|
f" created {_fmt_date(mp.date_created)} · merged {_fmt_date(mp.date_merged)}\n"
|
||||||
|
f" {mp.web_link}"
|
||||||
|
for mp in authored_mps
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Review requests + performed reviews ---
|
||||||
new_review_requests: list[MergeProposal] = []
|
new_review_requests: list[MergeProposal] = []
|
||||||
performed_reviews: list[MergeProposal] = []
|
performed_reviews: list[MergeProposal] = []
|
||||||
# getRequestedReviews has no server-side date filter — it fetches all
|
|
||||||
# review requests for the person, which is expensive and may 503.
|
|
||||||
result = safe(
|
result = safe(
|
||||||
"review requests",
|
"review requests",
|
||||||
lambda: list(person.getRequestedReviews(status=list(MP_STATUSES))),
|
lambda: list(person.getRequestedReviews(status=list(MP_STATUSES))),
|
||||||
@@ -257,8 +244,20 @@ class LaunchpadClient:
|
|||||||
if _in_window(mp.date_reviewed, since, until):
|
if _in_window(mp.date_reviewed, since, until):
|
||||||
performed_reviews.append(mp)
|
performed_reviews.append(mp)
|
||||||
|
|
||||||
|
review_entries = [
|
||||||
|
f" {_fmt_mp(mp)}\n"
|
||||||
|
f" requested {_fmt_date(mp.date_review_requested)}\n"
|
||||||
|
f" {mp.web_link}"
|
||||||
|
for mp in new_review_requests
|
||||||
|
]
|
||||||
|
|
||||||
|
perf_entries = [
|
||||||
|
f" {_fmt_mp(mp)}\n reviewed {_fmt_date(mp.date_reviewed)}\n {mp.web_link}"
|
||||||
|
for mp in performed_reviews
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Git repositories ---
|
||||||
git_repositories: list[GitRepository] = []
|
git_repositories: list[GitRepository] = []
|
||||||
# Query git repositories by owner + modified_since_date.
|
|
||||||
result = safe(
|
result = safe(
|
||||||
"git repositories",
|
"git repositories",
|
||||||
lambda: list(
|
lambda: list(
|
||||||
@@ -275,6 +274,15 @@ class LaunchpadClient:
|
|||||||
):
|
):
|
||||||
git_repositories.append(repo)
|
git_repositories.append(repo)
|
||||||
|
|
||||||
|
repo_entries = [
|
||||||
|
f" {repo.display_name or repo.name or repo.web_link}\n"
|
||||||
|
f" default branch {repo.default_branch or '—'} · "
|
||||||
|
f"last modified {_fmt_date(repo.date_last_modified)}\n"
|
||||||
|
f" {repo.web_link}"
|
||||||
|
for repo in git_repositories
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Bugs ---
|
||||||
bugs: dict[str, BugRef] = {}
|
bugs: dict[str, BugRef] = {}
|
||||||
|
|
||||||
def add_bug(task: Any, role: str) -> None:
|
def add_bug(task: Any, role: str) -> None:
|
||||||
@@ -305,14 +313,28 @@ class LaunchpadClient:
|
|||||||
collect_bug_tasks("commented", bug_commenter=person_link, modified_since=iso_since)
|
collect_bug_tasks("commented", bug_commenter=person_link, modified_since=iso_since)
|
||||||
collect_bug_tasks("assigned", assignee=person_link, modified_since=iso_since)
|
collect_bug_tasks("assigned", assignee=person_link, modified_since=iso_since)
|
||||||
|
|
||||||
return WeeklyActivity(
|
bug_entries = [
|
||||||
|
f" [#{bug.bug_id}] {bug.title}\n"
|
||||||
|
f" {'/'.join(sorted(bug.roles)) or '?'} · "
|
||||||
|
f"created {_fmt_date(bug.date_created)} · "
|
||||||
|
f"updated {_fmt_date(bug.date_last_updated)}\n"
|
||||||
|
f" {bug.web_link}"
|
||||||
|
for bug in bugs.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
return ActivityReport(
|
||||||
|
source=self.name,
|
||||||
username=username,
|
username=username,
|
||||||
since=since,
|
since=since,
|
||||||
until=until,
|
until=until,
|
||||||
authored_mps=authored_mps,
|
sections=[
|
||||||
new_review_requests=new_review_requests,
|
Section("Merge proposals authored", mp_entries),
|
||||||
performed_reviews=performed_reviews,
|
Section("New review requests received", review_entries),
|
||||||
git_repositories=git_repositories,
|
Section("Reviews performed", perf_entries),
|
||||||
bugs=list(bugs.values()),
|
Section("Repositories modified", repo_entries),
|
||||||
|
Section("Bugs", bug_entries),
|
||||||
|
],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
|
note="Note: commit-level history is not exposed by the Launchpad REST API; "
|
||||||
|
"read the branch directly (bzr/git) for per-commit activity.",
|
||||||
)
|
)
|
||||||
@@ -2,6 +2,19 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backports-tarfile"
|
name = "backports-tarfile"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -11,6 +24,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -131,6 +153,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httplib2"
|
name = "httplib2"
|
||||||
version = "0.32.0"
|
version = "0.32.0"
|
||||||
@@ -143,6 +187,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "9.0.0"
|
version = "9.0.0"
|
||||||
@@ -218,33 +286,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "launchpad-weekly-activity"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "keyring" },
|
|
||||||
{ name = "launchpadlib" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "ruff" },
|
|
||||||
{ name = "ty" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "keyring", specifier = ">=25" },
|
|
||||||
{ name = "launchpadlib", specifier = ">=2.1.0" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
|
||||||
dev = [
|
|
||||||
{ name = "ruff", specifier = ">=0.6" },
|
|
||||||
{ name = "ty", specifier = ">=0.0.1" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "launchpadlib"
|
name = "launchpadlib"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -391,6 +432,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wadllib"
|
name = "wadllib"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -403,6 +453,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/00b61e720165f73c6ef3c7a6ea732314c8ca1ba95b9ed1545fd788d9af9b/wadllib-2.1.0-py3-none-any.whl", hash = "sha256:41b58db0bb5fb21e188c7452281ee7b364b2690a4f6c388afea0ceaeafed1132", size = 61997, upload-time = "2026-07-01T10:48:01.37Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/f3/00b61e720165f73c6ef3c7a6ea732314c8ca1ba95b9ed1545fd788d9af9b/wadllib-2.1.0-py3-none-any.whl", hash = "sha256:41b58db0bb5fb21e188c7452281ee7b364b2690a4f6c388afea0ceaeafed1132", size = 61997, upload-time = "2026-07-01T10:48:01.37Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weekly-activity"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "keyring" },
|
||||||
|
{ name = "launchpadlib" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "keyring", specifier = ">=25" },
|
||||||
|
{ name = "launchpadlib", specifier = ">=2.1.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff", specifier = ">=0.6" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.1" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user