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/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)