gitpulse-analytics 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitpulse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Local GitHub repository analytics."""
2
+
3
+ __version__ = "0.2.0"
gitpulse/cli.py ADDED
@@ -0,0 +1,367 @@
1
+ """Command-line interface for GitPulse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.progress import (
14
+ BarColumn,
15
+ MofNCompleteColumn,
16
+ Progress,
17
+ SpinnerColumn,
18
+ TextColumn,
19
+ TimeElapsedColumn,
20
+ )
21
+ from rich.table import Table
22
+
23
+ from gitpulse import __version__
24
+ from gitpulse.github_api import GitHubAPIError, GitHubClient
25
+ from gitpulse.org import OrgReport, collect_org
26
+ from gitpulse.report import render_html
27
+ from gitpulse.storage import SnapshotStore
28
+
29
+ app = typer.Typer(
30
+ name="gitpulse",
31
+ help="Local GitHub repository traffic and release analytics.",
32
+ no_args_is_help=True,
33
+ )
34
+ console = Console()
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ console.print(f"gitpulse {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: bool = typer.Option(
46
+ False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
47
+ ),
48
+ ) -> None:
49
+ """Local GitHub repository traffic and release analytics."""
50
+
51
+
52
+ def _client() -> GitHubClient:
53
+ try:
54
+ return GitHubClient()
55
+ except GitHubAPIError as exc:
56
+ console.print(f"[red]Error:[/red] {exc}")
57
+ raise typer.Exit(code=1) from exc
58
+
59
+
60
+ def _run(action: Any) -> Any:
61
+ try:
62
+ return action()
63
+ except GitHubAPIError as exc:
64
+ console.print(f"[red]Error:[/red] {exc}")
65
+ raise typer.Exit(code=1) from exc
66
+
67
+
68
+ def _repo_parts(repository: str) -> tuple[str, str]:
69
+ parts = repository.strip("/").split("/")
70
+ if len(parts) != 2 or not all(parts):
71
+ raise typer.BadParameter("Use the OWNER/REPO format, for example octocat/Hello-World")
72
+ return parts[0], parts[1]
73
+
74
+
75
+ def _traffic_table(owner: str, repo: str, metric: str, data: dict[str, Any]) -> Table:
76
+ table = Table(title=f"{owner}/{repo} · {metric}", header_style="bold cyan")
77
+ if metric in {"views", "clones"}:
78
+ table.add_column("Date")
79
+ table.add_column("Count", justify="right")
80
+ table.add_column("Unique", justify="right")
81
+ for item in data.get("views", data.get("clones", [])):
82
+ table.add_row(str(item.get("timestamp", ""))[:10], str(item.get("count", 0)), str(item.get("uniques", 0)))
83
+ if not table.row_count:
84
+ table.add_row("No recent data", "0", "0")
85
+ elif metric == "referrers":
86
+ table.add_column("Referrer")
87
+ table.add_column("Views", justify="right")
88
+ table.add_column("Unique", justify="right")
89
+ for item in data.get("referrers", []):
90
+ table.add_row(str(item.get("referrer", "")), str(item.get("count", 0)), str(item.get("uniques", 0)))
91
+ if not table.row_count:
92
+ table.add_row("No recent data", "0", "0")
93
+ else:
94
+ table.add_column("Path")
95
+ table.add_column("Title")
96
+ table.add_column("Views", justify="right")
97
+ table.add_column("Unique", justify="right")
98
+ for item in data.get("popular/paths", []):
99
+ table.add_row(str(item.get("path", "")), str(item.get("title", "")), str(item.get("count", 0)), str(item.get("uniques", 0)))
100
+ if not table.row_count:
101
+ table.add_row("No recent data", "", "0", "0")
102
+ return table
103
+
104
+
105
+ @app.command("repos")
106
+ def list_repos(
107
+ all_repos: bool = typer.Option(False, "--all", help="Include forks and archived repositories."),
108
+ ) -> None:
109
+ """List repositories the authenticated account can access."""
110
+ repos = _run(_client().list_repositories)
111
+ if not all_repos:
112
+ repos = [repo for repo in repos if not repo.get("fork") and not repo.get("archived")]
113
+ table = Table(title="Your repositories", header_style="bold cyan")
114
+ table.add_column("Repository")
115
+ table.add_column("Visibility")
116
+ table.add_column("Stars", justify="right")
117
+ table.add_column("Forks", justify="right")
118
+ table.add_column("Updated")
119
+ for repo in repos:
120
+ table.add_row(
121
+ repo.get("full_name", ""),
122
+ "private" if repo.get("private") else "public",
123
+ str(repo.get("stargazers_count", 0)),
124
+ str(repo.get("forks_count", 0)),
125
+ str(repo.get("updated_at", ""))[:10],
126
+ )
127
+ console.print(table)
128
+ console.print(f"[dim]{len(repos)} repositories[/dim]")
129
+
130
+
131
+ @app.command()
132
+ def traffic(
133
+ repository: str = typer.Argument(..., help="Repository in OWNER/REPO format."),
134
+ metric: str = typer.Option("views", "--metric", "-m", help="views, clones, referrers, or popular/paths"),
135
+ save: bool = typer.Option(False, "--save", help="Save this response to local history."),
136
+ ) -> None:
137
+ """Show recent views, clones, referrers, or popular paths."""
138
+ owner, repo = _repo_parts(repository)
139
+ if metric not in {"views", "clones", "referrers", "popular/paths"}:
140
+ raise typer.BadParameter("Choose views, clones, referrers, or popular/paths")
141
+ client = _client()
142
+ data = _run(lambda: client.traffic(owner, repo, metric))
143
+ console.print(_traffic_table(owner, repo, metric, data))
144
+ if save and metric in {"views", "clones"}:
145
+ SnapshotStore().save(owner, repo, metric, data)
146
+ console.print("[green]Snapshot saved locally.[/green]")
147
+ console.print("[dim]GitHub provides aggregate traffic data, not visitor identities; traffic history is typically limited to 14 days.[/dim]")
148
+
149
+
150
+ @app.command()
151
+ def snapshot(
152
+ repository: str = typer.Argument(..., help="Repository in OWNER/REPO format."),
153
+ ) -> None:
154
+ """Fetch and save today's views and clone traffic snapshots."""
155
+ owner, repo = _repo_parts(repository)
156
+ client = _client()
157
+ store = SnapshotStore()
158
+ for metric in ("views", "clones"):
159
+ data = _run(lambda metric=metric: client.traffic(owner, repo, metric))
160
+ store.save(owner, repo, metric, data)
161
+ console.print(_traffic_table(owner, repo, metric, data))
162
+ console.print("[green]Snapshots saved. Run this regularly to build longer-term history.[/green]")
163
+
164
+
165
+ @app.command()
166
+ def history(
167
+ repository: str = typer.Argument(..., help="Repository in OWNER/REPO format."),
168
+ metric: str = typer.Option("views", "--metric", "-m", help="views or clones"),
169
+ limit: int = typer.Option(30, min=1, max=1000, help="Number of saved snapshots to show."),
170
+ ) -> None:
171
+ """Show snapshots collected on this machine."""
172
+ owner, repo = _repo_parts(repository)
173
+ if metric not in {"views", "clones"}:
174
+ raise typer.BadParameter("History is available for views or clones")
175
+ rows = SnapshotStore().history(owner, repo, metric, limit)
176
+ table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="bold cyan")
177
+ table.add_column("Collected at")
178
+ table.add_column("14-day total", justify="right")
179
+ table.add_column("Sum of daily uniques*", justify="right")
180
+ series_key = metric
181
+ found = False
182
+ for row in rows:
183
+ series = row.get(series_key, [])
184
+ total = sum(item.get("count", 0) for item in series)
185
+ uniques = sum(item.get("uniques", 0) for item in series)
186
+ table.add_row(row.get("fetched_at", ""), str(total), str(uniques))
187
+ found = True
188
+ if not found:
189
+ console.print("No snapshots yet. Run `gitpulse snapshot OWNER/REPO` to start collecting history.")
190
+ else:
191
+ console.print(table)
192
+ console.print("[dim]*Daily unique counts can overlap; this is not a distinct-visitor total.[/dim]")
193
+
194
+
195
+ @app.command("downloads")
196
+ def release_downloads(
197
+ repository: str = typer.Argument(..., help="Repository in OWNER/REPO format."),
198
+ ) -> None:
199
+ """Show GitHub release asset download counts (not Homebrew install counts)."""
200
+ owner, repo = _repo_parts(repository)
201
+ release_count, assets = _run(lambda: _client().release_downloads(owner, repo))
202
+ table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="bold cyan")
203
+ table.add_column("Release")
204
+ table.add_column("Asset")
205
+ table.add_column("Downloads", justify="right")
206
+ for asset in assets:
207
+ table.add_row(asset["release"], asset["name"], str(asset["download_count"]))
208
+ if not release_count:
209
+ console.print("No releases found.")
210
+ elif not assets:
211
+ console.print(
212
+ f"Found {release_count} release(s), but none have uploaded assets. GitHub does not "
213
+ "report download counts for the automatic source code archives."
214
+ )
215
+ else:
216
+ console.print(table)
217
+ console.print("[dim]Release asset counts do not indicate Homebrew formula installs or identify downloaders.[/dim]")
218
+
219
+
220
+ SPARK_CHARS = "▁▂▃▄▅▆▇█"
221
+
222
+
223
+ def _spark(values: list[int]) -> str:
224
+ peak = max(values, default=0)
225
+ if not peak:
226
+ return "[dim]" + "·" * len(values) + "[/dim]"
227
+ # Scale min..max so day-to-day movement shows even when traffic never drops to zero.
228
+ low = min(values)
229
+ span = (peak - low) or 1
230
+ return "".join(SPARK_CHARS[round((v - low) / span * (len(SPARK_CHARS) - 1))] for v in values)
231
+
232
+
233
+ def _org_summary(report: OrgReport) -> None:
234
+ views = report.daily("views_daily")
235
+ days = [day for day, _ in views]
236
+ window = f"{days[0]} → {days[-1]}" if days else "no traffic data"
237
+ name = report.profile.get("name") or report.owner
238
+ console.print(
239
+ Panel.fit(
240
+ f"[bold]{name}[/bold] [dim]@{report.owner}[/dim]\n"
241
+ f"{len(report.repos)} repositories · traffic {window}",
242
+ border_style="cyan",
243
+ )
244
+ )
245
+
246
+ def tile(label: str, value: int, style: str = "bold") -> Panel:
247
+ return Panel(f"[{style}]{value:,}[/{style}]\n[dim]{label}[/dim]", expand=True, border_style="dim")
248
+
249
+ tiles = [
250
+ tile("views · 14d", report.total("views"), "bold cyan"),
251
+ tile("unique visitors*", report.total("view_uniques")),
252
+ tile("clones · 14d", report.total("clones"), "bold magenta"),
253
+ tile("stars", report.total("stars"), "bold yellow"),
254
+ tile("forks", report.total("forks")),
255
+ tile("release downloads", report.total("downloads"), "bold green"),
256
+ ]
257
+ grid = Table.grid(expand=True, padding=(0, 1))
258
+ for _ in range(3):
259
+ grid.add_column(ratio=1)
260
+ grid.add_row(*tiles[:3])
261
+ grid.add_row(*tiles[3:])
262
+ console.print(grid)
263
+ if views:
264
+ console.print(f" [dim]daily views[/dim] [cyan]{_spark([c for _, c in views])}[/cyan]")
265
+ clones = [c for _, c in report.daily("clones_daily")]
266
+ console.print(f" [dim]daily clones[/dim] [magenta]{_spark(clones)}[/magenta]\n")
267
+
268
+ table = Table(header_style="bold cyan", box=box.SIMPLE_HEAD, expand=True)
269
+ table.add_column("Repository", overflow="fold")
270
+ table.add_column("Lang", style="dim")
271
+ for column in ("Stars", "Forks", "Views", "Unique", "Clones", "Downloads"):
272
+ table.add_column(column, justify="right")
273
+ table.add_column("14-day views")
274
+ for repo in report.repos:
275
+ ok = repo.traffic_error is None
276
+ na = "[dim]n/a[/dim]"
277
+ table.add_row(
278
+ repo.name + (" [dim](private)[/dim]" if repo.private else ""),
279
+ repo.language or "—",
280
+ f"{repo.stars:,}",
281
+ f"{repo.forks:,}",
282
+ f"{repo.views:,}" if ok else na,
283
+ f"{repo.view_uniques:,}" if ok else na,
284
+ f"{repo.clones:,}" if ok else na,
285
+ f"{repo.downloads:,}" if repo.releases_error is None else na,
286
+ f"[cyan]{_spark([repo.views_daily.get(d, 0) for d in days])}[/cyan]" if ok and days else "",
287
+ )
288
+ console.print(table)
289
+
290
+ referrers = report.referrers(limit=5)
291
+ if referrers:
292
+ ref_table = Table(title="Top referrers", header_style="bold cyan", box=box.SIMPLE_HEAD)
293
+ ref_table.add_column("Source")
294
+ ref_table.add_column("Views", justify="right")
295
+ ref_table.add_column("Unique*", justify="right")
296
+ for source, count, uniques in referrers:
297
+ ref_table.add_row(source, f"{count:,}", f"{uniques:,}")
298
+ console.print(ref_table)
299
+
300
+ missing = [repo for repo in report.repos if repo.traffic_error]
301
+ if missing:
302
+ console.print(
303
+ f"[yellow]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/yellow] "
304
+ "[dim](the token needs Administration: Read or push access). See the dashboard for details.[/dim]"
305
+ )
306
+ console.print("[dim]*Unique counts are summed per repository, so one person can be counted more than once.[/dim]")
307
+
308
+
309
+ @app.command("org")
310
+ def org_dashboard(
311
+ owner: str = typer.Argument(..., help="Organization or user login."),
312
+ output: str = typer.Option(
313
+ "", "--output", "-o", help="HTML file to write. Defaults to gitpulse-OWNER.html."
314
+ ),
315
+ all_repos: bool = typer.Option(False, "--all", help="Include forks and archived repositories."),
316
+ open_browser: bool = typer.Option(False, "--open", help="Open the dashboard in your browser."),
317
+ save: bool = typer.Option(False, "--save", help="Also save views and clones snapshots to local history."),
318
+ workers: int = typer.Option(8, min=1, max=32, help="Repositories to fetch in parallel."),
319
+ ) -> None:
320
+ """Build an HTML dashboard of traffic, stars, and downloads across an organization."""
321
+ client = _client()
322
+ with Progress(
323
+ SpinnerColumn(),
324
+ TextColumn("[progress.description]{task.description}"),
325
+ BarColumn(),
326
+ MofNCompleteColumn(),
327
+ TimeElapsedColumn(),
328
+ console=console,
329
+ transient=True,
330
+ ) as progress:
331
+ task = progress.add_task(f"Listing repositories for {owner}", total=None)
332
+ report = _run(
333
+ lambda: collect_org(
334
+ client,
335
+ owner,
336
+ include_all=all_repos,
337
+ workers=workers,
338
+ on_start=lambda count: progress.update(
339
+ task, total=count, description=f"Fetching stats for {owner}"
340
+ ),
341
+ on_repo=lambda repo: progress.update(task, advance=1, description=repo.name),
342
+ )
343
+ )
344
+ if not report.repos:
345
+ console.print(f"No repositories found for [bold]{owner}[/bold].")
346
+ raise typer.Exit(code=1)
347
+
348
+ _org_summary(report)
349
+
350
+ if save:
351
+ store = SnapshotStore()
352
+ saved = 0
353
+ for repo in report.traffic_repos:
354
+ for metric, payload in repo.raw_traffic.items():
355
+ store.save(repo.owner, repo.name, metric, payload)
356
+ saved += 1
357
+ console.print(f"[green]Saved snapshots for {saved} repositories.[/green]")
358
+
359
+ path = Path(output or f"gitpulse-{report.owner}.html").expanduser().resolve()
360
+ path.write_text(render_html(report), encoding="utf-8")
361
+ console.print(f"\n[bold green]✓[/bold green] Dashboard written to [link={path.as_uri()}]{path}[/link]")
362
+ if open_browser:
363
+ webbrowser.open(path.as_uri())
364
+
365
+
366
+ if __name__ == "__main__":
367
+ app()
gitpulse/github_api.py ADDED
@@ -0,0 +1,162 @@
1
+ """Small GitHub REST API client for GitPulse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+ from gitpulse import __version__
11
+
12
+
13
+ class GitHubAPIError(RuntimeError):
14
+ """An error returned by the GitHub API or the local HTTP client."""
15
+
16
+ def __init__(self, message: str, status: int | None = None) -> None:
17
+ super().__init__(message)
18
+ self.status = status
19
+
20
+
21
+ class GitHubClient:
22
+ """Authenticated client for GitHub's REST API."""
23
+
24
+ def __init__(self, token: str | None = None, base_url: str = "https://api.github.com") -> None:
25
+ self.token = token or os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
26
+ if not self.token:
27
+ raise GitHubAPIError(
28
+ "No GitHub token found. Set GITHUB_TOKEN or GH_TOKEN to a personal access token."
29
+ )
30
+ self.base_url = base_url.rstrip("/")
31
+ self.session = requests.Session()
32
+ self.session.headers.update(
33
+ {
34
+ "Accept": "application/vnd.github+json",
35
+ "Authorization": f"Bearer {self.token}",
36
+ "X-GitHub-Api-Version": "2022-11-28",
37
+ "User-Agent": f"gitpulse/{__version__}",
38
+ }
39
+ )
40
+
41
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
42
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
43
+ try:
44
+ response = self.session.get(url, params=params, timeout=20)
45
+ except requests.RequestException as exc:
46
+ raise GitHubAPIError(f"Could not connect to GitHub: {exc}") from exc
47
+
48
+ if response.status_code == 403 and response.headers.get("X-RateLimit-Remaining") == "0":
49
+ reset = response.headers.get("X-RateLimit-Reset")
50
+ hint = f" Rate limit resets at Unix time {reset}." if reset else ""
51
+ raise GitHubAPIError(f"GitHub API rate limit exceeded.{hint}")
52
+ detail = ""
53
+ if not response.ok:
54
+ try:
55
+ detail = response.json().get("message", "")
56
+ except (ValueError, AttributeError):
57
+ pass
58
+ suffix = f" GitHub said: {detail}" if detail else ""
59
+ is_traffic = "/traffic/" in url
60
+
61
+ if response.status_code == 404:
62
+ hint = (
63
+ "your token may lack permission to view its traffic data."
64
+ if is_traffic
65
+ else "your token may not have access to it."
66
+ )
67
+ raise GitHubAPIError(
68
+ f"GitHub returned 404. The repository may not exist, or {hint}{suffix}",
69
+ status=404,
70
+ )
71
+ if response.status_code == 403:
72
+ sso = response.headers.get("X-GitHub-SSO")
73
+ if sso:
74
+ raise GitHubAPIError(
75
+ "GitHub denied this request because the organization requires SAML SSO. "
76
+ f"Authorize your token for the organization ({sso}).{suffix}",
77
+ status=403,
78
+ )
79
+ hint = (
80
+ "Traffic endpoints need a token with Administration: Read on this repository "
81
+ "(classic tokens: the repo scope and push or admin access)."
82
+ if is_traffic
83
+ else "Check that your token includes this repository and has Contents: Read "
84
+ "(classic tokens: the repo scope)."
85
+ )
86
+ raise GitHubAPIError(f"GitHub denied this request. {hint}{suffix}", status=403)
87
+ if not response.ok:
88
+ raise GitHubAPIError(
89
+ f"GitHub API returned HTTP {response.status_code}.{suffix}",
90
+ status=response.status_code,
91
+ )
92
+ try:
93
+ return response.json()
94
+ except ValueError as exc:
95
+ raise GitHubAPIError("GitHub returned an invalid JSON response.") from exc
96
+
97
+ def _paginate(self, path: str, params: dict[str, Any]) -> list[dict[str, Any]]:
98
+ items: list[dict[str, Any]] = []
99
+ page = 1
100
+ while True:
101
+ batch = self.get(path, params={**params, "per_page": 100, "page": page})
102
+ if not isinstance(batch, list) or not batch:
103
+ break
104
+ items.extend(batch)
105
+ if len(batch) < 100:
106
+ break
107
+ page += 1
108
+ return items
109
+
110
+ def list_repositories(self) -> list[dict[str, Any]]:
111
+ return self._paginate(
112
+ "/user/repos",
113
+ {"sort": "updated", "affiliation": "owner,collaborator,organization_member"},
114
+ )
115
+
116
+ def owner_profile(self, owner: str) -> dict[str, Any]:
117
+ """Return the organization or user profile for OWNER."""
118
+ result = self.get(f"/users/{owner}")
119
+ return result if isinstance(result, dict) else {}
120
+
121
+ def list_owner_repositories(self, owner: str) -> list[dict[str, Any]]:
122
+ """List every repository owned by an organization or user that the token can see."""
123
+ try:
124
+ return self._paginate(f"/orgs/{owner}/repos", {"type": "all", "sort": "pushed"})
125
+ except GitHubAPIError as exc:
126
+ if exc.status != 404:
127
+ raise
128
+ # Not an organization. For the authenticated user, /user/repos includes private repos.
129
+ me = self.get("/user")
130
+ if isinstance(me, dict) and str(me.get("login", "")).lower() == owner.lower():
131
+ repos = self._paginate("/user/repos", {"affiliation": "owner", "sort": "pushed"})
132
+ return [r for r in repos if r.get("owner", {}).get("login", "").lower() == owner.lower()]
133
+ return self._paginate(f"/users/{owner}/repos", {"type": "owner", "sort": "pushed"})
134
+
135
+ def traffic(self, owner: str, repo: str, metric: str) -> dict[str, Any]:
136
+ if metric not in {"views", "clones", "popular/paths", "referrers"}:
137
+ raise ValueError(f"Unsupported traffic metric: {metric}")
138
+ # GitHub serves referrers under traffic/popular/referrers.
139
+ path = "popular/referrers" if metric == "referrers" else metric
140
+ result = self.get(f"/repos/{owner}/{repo}/traffic/{path}")
141
+ if isinstance(result, list):
142
+ key = "popular/paths" if metric == "popular/paths" else "referrers"
143
+ return {key: result}
144
+ return result if isinstance(result, dict) else {}
145
+
146
+ def release_downloads(self, owner: str, repo: str) -> tuple[int, list[dict[str, Any]]]:
147
+ """Return the number of releases and a row per uploaded release asset."""
148
+ releases = self.get(f"/repos/{owner}/{repo}/releases", params={"per_page": 100})
149
+ if not isinstance(releases, list):
150
+ releases = []
151
+ results: list[dict[str, Any]] = []
152
+ for release in releases:
153
+ for asset in release.get("assets", []):
154
+ results.append(
155
+ {
156
+ "release": release.get("tag_name", ""),
157
+ "name": asset.get("name", ""),
158
+ "download_count": asset.get("download_count", 0),
159
+ "created_at": asset.get("created_at", ""),
160
+ }
161
+ )
162
+ return len(releases), results