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 +3 -0
- gitpulse/cli.py +367 -0
- gitpulse/github_api.py +162 -0
- gitpulse/org.py +173 -0
- gitpulse/report.py +623 -0
- gitpulse/storage.py +61 -0
- gitpulse_analytics-0.2.0.dist-info/METADATA +173 -0
- gitpulse_analytics-0.2.0.dist-info/RECORD +11 -0
- gitpulse_analytics-0.2.0.dist-info/WHEEL +4 -0
- gitpulse_analytics-0.2.0.dist-info/entry_points.txt +2 -0
- gitpulse_analytics-0.2.0.dist-info/licenses/LICENSE +21 -0
gitpulse/org.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Collect and aggregate statistics for every repository owned by an organization or user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import date, datetime, timedelta, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gitpulse.github_api import GitHubAPIError, GitHubClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RepoStats:
|
|
17
|
+
"""Metadata, recent traffic, and release downloads for one repository."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
full_name: str
|
|
21
|
+
url: str
|
|
22
|
+
description: str = ""
|
|
23
|
+
private: bool = False
|
|
24
|
+
fork: bool = False
|
|
25
|
+
archived: bool = False
|
|
26
|
+
language: str = ""
|
|
27
|
+
stars: int = 0
|
|
28
|
+
forks: int = 0
|
|
29
|
+
open_issues: int = 0
|
|
30
|
+
pushed_at: str = ""
|
|
31
|
+
views: int = 0
|
|
32
|
+
view_uniques: int = 0
|
|
33
|
+
clones: int = 0
|
|
34
|
+
clone_uniques: int = 0
|
|
35
|
+
views_daily: dict[str, int] = field(default_factory=dict)
|
|
36
|
+
clones_daily: dict[str, int] = field(default_factory=dict)
|
|
37
|
+
referrers: list[dict[str, Any]] = field(default_factory=list)
|
|
38
|
+
releases: int = 0
|
|
39
|
+
downloads: int = 0
|
|
40
|
+
traffic_error: str | None = None
|
|
41
|
+
releases_error: str | None = None
|
|
42
|
+
raw_traffic: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def owner(self) -> str:
|
|
46
|
+
return self.full_name.split("/", 1)[0]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_api(cls, repo: dict[str, Any]) -> RepoStats:
|
|
50
|
+
return cls(
|
|
51
|
+
name=repo.get("name", ""),
|
|
52
|
+
full_name=repo.get("full_name", ""),
|
|
53
|
+
url=repo.get("html_url", ""),
|
|
54
|
+
description=repo.get("description") or "",
|
|
55
|
+
private=bool(repo.get("private")),
|
|
56
|
+
fork=bool(repo.get("fork")),
|
|
57
|
+
archived=bool(repo.get("archived")),
|
|
58
|
+
language=repo.get("language") or "",
|
|
59
|
+
stars=int(repo.get("stargazers_count") or 0),
|
|
60
|
+
forks=int(repo.get("forks_count") or 0),
|
|
61
|
+
open_issues=int(repo.get("open_issues_count") or 0),
|
|
62
|
+
pushed_at=str(repo.get("pushed_at") or ""),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class OrgReport:
|
|
68
|
+
"""Aggregated statistics across an owner's repositories."""
|
|
69
|
+
|
|
70
|
+
owner: str
|
|
71
|
+
profile: dict[str, Any]
|
|
72
|
+
repos: list[RepoStats]
|
|
73
|
+
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
74
|
+
|
|
75
|
+
def total(self, attr: str) -> int:
|
|
76
|
+
return sum(getattr(repo, attr) for repo in self.repos)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def traffic_repos(self) -> list[RepoStats]:
|
|
80
|
+
return [repo for repo in self.repos if repo.traffic_error is None]
|
|
81
|
+
|
|
82
|
+
def daily(self, attr: str) -> list[tuple[str, int]]:
|
|
83
|
+
"""Sum a daily series across repos, filling gaps with zeros."""
|
|
84
|
+
totals: dict[str, int] = defaultdict(int)
|
|
85
|
+
for repo in self.repos:
|
|
86
|
+
for day, count in getattr(repo, attr).items():
|
|
87
|
+
totals[day] += count
|
|
88
|
+
if not totals:
|
|
89
|
+
return []
|
|
90
|
+
start = date.fromisoformat(min(totals))
|
|
91
|
+
end = date.fromisoformat(max(totals))
|
|
92
|
+
days = [(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)]
|
|
93
|
+
return [(day, totals.get(day, 0)) for day in days]
|
|
94
|
+
|
|
95
|
+
def languages(self) -> list[tuple[str, int]]:
|
|
96
|
+
counts = Counter(repo.language or "Unspecified" for repo in self.repos)
|
|
97
|
+
return counts.most_common()
|
|
98
|
+
|
|
99
|
+
def referrers(self, limit: int = 10) -> list[tuple[str, int, int]]:
|
|
100
|
+
views: Counter[str] = Counter()
|
|
101
|
+
uniques: Counter[str] = Counter()
|
|
102
|
+
for repo in self.repos:
|
|
103
|
+
for item in repo.referrers:
|
|
104
|
+
name = str(item.get("referrer", ""))
|
|
105
|
+
views[name] += int(item.get("count", 0))
|
|
106
|
+
uniques[name] += int(item.get("uniques", 0))
|
|
107
|
+
return [(name, count, uniques[name]) for name, count in views.most_common(limit)]
|
|
108
|
+
|
|
109
|
+
def top(self, attr: str, limit: int = 10) -> list[RepoStats]:
|
|
110
|
+
ranked = sorted(self.repos, key=lambda repo: getattr(repo, attr), reverse=True)
|
|
111
|
+
return [repo for repo in ranked[:limit] if getattr(repo, attr) > 0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _daily(series: list[dict[str, Any]]) -> dict[str, int]:
|
|
115
|
+
return {str(item.get("timestamp", ""))[:10]: int(item.get("count", 0)) for item in series}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def collect_repo(client: GitHubClient, stats: RepoStats) -> RepoStats:
|
|
119
|
+
"""Fill in traffic and release data. Permission errors are recorded, not raised."""
|
|
120
|
+
owner, repo = stats.full_name.split("/", 1)
|
|
121
|
+
try:
|
|
122
|
+
views = client.traffic(owner, repo, "views")
|
|
123
|
+
clones = client.traffic(owner, repo, "clones")
|
|
124
|
+
except GitHubAPIError as exc:
|
|
125
|
+
stats.traffic_error = str(exc)
|
|
126
|
+
else:
|
|
127
|
+
stats.raw_traffic = {"views": views, "clones": clones}
|
|
128
|
+
stats.views = int(views.get("count", 0))
|
|
129
|
+
stats.view_uniques = int(views.get("uniques", 0))
|
|
130
|
+
stats.clones = int(clones.get("count", 0))
|
|
131
|
+
stats.clone_uniques = int(clones.get("uniques", 0))
|
|
132
|
+
stats.views_daily = _daily(views.get("views", []))
|
|
133
|
+
stats.clones_daily = _daily(clones.get("clones", []))
|
|
134
|
+
# Referrers are a nice-to-have; a failure here must not discard views and clones.
|
|
135
|
+
try:
|
|
136
|
+
stats.referrers = client.traffic(owner, repo, "referrers").get("referrers", [])
|
|
137
|
+
except GitHubAPIError:
|
|
138
|
+
stats.referrers = []
|
|
139
|
+
try:
|
|
140
|
+
release_count, assets = client.release_downloads(owner, repo)
|
|
141
|
+
except GitHubAPIError as exc:
|
|
142
|
+
stats.releases_error = str(exc)
|
|
143
|
+
else:
|
|
144
|
+
stats.releases = release_count
|
|
145
|
+
stats.downloads = sum(int(asset["download_count"]) for asset in assets)
|
|
146
|
+
return stats
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def collect_org(
|
|
150
|
+
client: GitHubClient,
|
|
151
|
+
owner: str,
|
|
152
|
+
include_all: bool = False,
|
|
153
|
+
workers: int = 8,
|
|
154
|
+
on_start: Callable[[int], None] | None = None,
|
|
155
|
+
on_repo: Callable[[RepoStats], None] | None = None,
|
|
156
|
+
) -> OrgReport:
|
|
157
|
+
"""Fetch every repository for OWNER and collect its stats concurrently."""
|
|
158
|
+
profile = client.owner_profile(owner)
|
|
159
|
+
repos = [RepoStats.from_api(repo) for repo in client.list_owner_repositories(owner)]
|
|
160
|
+
if not include_all:
|
|
161
|
+
repos = [repo for repo in repos if not repo.fork and not repo.archived]
|
|
162
|
+
if on_start:
|
|
163
|
+
on_start(len(repos))
|
|
164
|
+
collected: list[RepoStats] = []
|
|
165
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
166
|
+
futures = [pool.submit(collect_repo, client, repo) for repo in repos]
|
|
167
|
+
for future in as_completed(futures):
|
|
168
|
+
stats = future.result()
|
|
169
|
+
collected.append(stats)
|
|
170
|
+
if on_repo:
|
|
171
|
+
on_repo(stats)
|
|
172
|
+
collected.sort(key=lambda repo: (repo.views, repo.stars), reverse=True)
|
|
173
|
+
return OrgReport(owner=profile.get("login") or owner, profile=profile, repos=collected)
|