ghtraffic 0.0.2__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.
ghtraffic/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Accumulate GitHub traffic and insights across all your repos and orgs.
2
+
3
+ GitHub keeps repository traffic (views, clones, referrers, popular paths) for
4
+ 14 days only. ``ghtraffic`` collects it on a schedule and merges it into a
5
+ growing, git-friendly history of CSV files, together with star, fork and
6
+ watcher counts, and ships a static dashboard to explore it.
7
+
8
+ >>> from ghtraffic import collect, load_settings, Files # doctest: +SKIP
9
+ >>> collect(load_settings(owners=["@me"]), store=Files("~/traffic")) # doctest: +SKIP
10
+
11
+ Main pieces:
12
+
13
+ - :func:`collect`: one idempotent run (cold start and update are the same call).
14
+ - :func:`load_settings` / :class:`Settings`: which owners and repos.
15
+ - :data:`TABLES`: the on-disk schema; :class:`Files`: the default store.
16
+ - :func:`init_data_repo`: write the workflow + config into a data repository.
17
+ """
18
+
19
+ from ghtraffic.collect import RunReport, collect, write_manifest
20
+ from ghtraffic.config import Settings, load_settings
21
+ from ghtraffic.github import GitHubError, make_graphql, make_rest, resolve_token
22
+ from ghtraffic.scaffold import init_data_repo
23
+ from ghtraffic.tables import TABLES, Files, TableSpec, merge_rows, read_table
24
+
25
+ __all__ = [
26
+ "Files",
27
+ "GitHubError",
28
+ "RunReport",
29
+ "Settings",
30
+ "TABLES",
31
+ "TableSpec",
32
+ "collect",
33
+ "init_data_repo",
34
+ "load_settings",
35
+ "make_graphql",
36
+ "make_rest",
37
+ "merge_rows",
38
+ "read_table",
39
+ "resolve_token",
40
+ "write_manifest",
41
+ ]
ghtraffic/__main__.py ADDED
@@ -0,0 +1,134 @@
1
+ """Command line: ``ghtraffic collect | init | guard | demo | manifest``.
2
+
3
+ ``python -m ghtraffic collect --data-dir .`` is what the GitHub Action runs.
4
+ Without ``--data-dir``, data goes to ``~/.local/share/ghtraffic/data`` (never
5
+ the current directory, so a local run cannot end up committed by accident).
6
+ Every :class:`~ghtraffic.config.Settings` field is also a ``collect`` flag.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ import typing
14
+ from dataclasses import fields
15
+ from pathlib import Path
16
+
17
+ from ghtraffic.collect import collect, has_private_data, write_manifest
18
+ from ghtraffic.config import (
19
+ DFLT_CONFIG_FILENAME,
20
+ DFLT_DATA_DIR,
21
+ Settings,
22
+ load_settings,
23
+ )
24
+ from ghtraffic.scaffold import DFLT_CRON, default_action_ref, init_data_repo
25
+ from ghtraffic.tables import Files
26
+
27
+
28
+ def _add_settings_flags(parser: argparse.ArgumentParser) -> None:
29
+ """One flag per Settings field, so a new setting needs no CLI edit."""
30
+ hints = typing.get_type_hints(Settings)
31
+ for f in fields(Settings):
32
+ flag = "--" + f.name.replace("_", "-")
33
+ kind = hints[f.name]
34
+ if kind is bool:
35
+ parser.add_argument(flag, action=argparse.BooleanOptionalAction)
36
+ elif typing.get_origin(kind) is tuple:
37
+ parser.add_argument(flag, action="append", help="repeatable")
38
+ else:
39
+ parser.add_argument(flag, type=kind)
40
+
41
+
42
+ def _parser() -> argparse.ArgumentParser:
43
+ p = argparse.ArgumentParser(prog="ghtraffic", description=__doc__.splitlines()[0])
44
+ sub = p.add_subparsers(dest="cmd", required=True)
45
+
46
+ c = sub.add_parser("collect", help="fetch traffic and merge it into the data dir")
47
+ c.add_argument("--data-dir", default=str(DFLT_DATA_DIR))
48
+ c.add_argument("--config", help=f"default: <data-dir>/{DFLT_CONFIG_FILENAME}")
49
+ c.add_argument(
50
+ "--fail-on-errors",
51
+ action="store_true",
52
+ help="exit 1 when some repo or owner failed (never for missing access)",
53
+ )
54
+ _add_settings_flags(c)
55
+
56
+ i = sub.add_parser("init", help="write the workflow and config into a data repo")
57
+ i.add_argument("dir", nargs="?", default=".")
58
+ i.add_argument("--owner", action="append", dest="owners")
59
+ i.add_argument("--cron", default=DFLT_CRON)
60
+ i.add_argument("--action-ref", help="default: this ghtraffic's release tag")
61
+ i.add_argument("--overwrite", action="store_true")
62
+
63
+ g = sub.add_parser(
64
+ "guard", help="exit 1 if private data would be committed to a public repo"
65
+ )
66
+ g.add_argument("--data-dir", default=".")
67
+ g.add_argument("--repo", required=True, help="owner/name of the data repo")
68
+
69
+ d = sub.add_parser(
70
+ "demo", help="write a synthetic data dir (for the demo dashboard)"
71
+ )
72
+ d.add_argument("--data-dir", required=True)
73
+ d.add_argument("--days", type=int, default=420)
74
+
75
+ m = sub.add_parser("manifest", help="rewrite manifest.json from the files present")
76
+ m.add_argument("--data-dir", default=str(DFLT_DATA_DIR))
77
+ return p
78
+
79
+
80
+ def _guard(data_dir: str, repo: str) -> int:
81
+ from ghtraffic.github import make_rest
82
+
83
+ if not make_rest()(f"/repos/{repo}").get("private", False) and has_private_data(
84
+ Files(data_dir)
85
+ ):
86
+ print(
87
+ f"ghtraffic: REFUSING to commit: {repo} is public and the history "
88
+ "holds private repos. Make the data repo private, or exclude private "
89
+ "repos, or set the action input allow-public: true.",
90
+ file=sys.stderr,
91
+ )
92
+ return 1
93
+ return 0
94
+
95
+
96
+ def main(argv=None) -> int:
97
+ """Entry point of the ``ghtraffic`` command."""
98
+ args = _parser().parse_args(argv)
99
+ if args.cmd == "collect":
100
+ data_dir = Path(args.data_dir)
101
+ overrides = {f.name: getattr(args, f.name) for f in fields(Settings)}
102
+ settings = load_settings(
103
+ args.config or data_dir / DFLT_CONFIG_FILENAME, **overrides
104
+ )
105
+ report = collect(settings, store=Files(data_dir), log=print)
106
+ failed = report.errors or report.owner_errors
107
+ return 1 if (args.fail_on_errors and failed) else 0
108
+ if args.cmd == "init":
109
+ written = init_data_repo(
110
+ args.dir,
111
+ owners=args.owners or ("@me",),
112
+ cron=args.cron,
113
+ action_ref=args.action_ref or default_action_ref(),
114
+ overwrite=args.overwrite,
115
+ )
116
+ print("\n".join(written) or "nothing written (files exist; --overwrite)")
117
+ return 0
118
+ if args.cmd == "guard":
119
+ return _guard(args.data_dir, args.repo)
120
+ if args.cmd == "demo":
121
+ from ghtraffic.demo import make_demo_data
122
+
123
+ make_demo_data(Files(args.data_dir), n_days=args.days)
124
+ return 0
125
+ if args.cmd == "manifest":
126
+ from datetime import date
127
+
128
+ write_manifest(Files(args.data_dir), updated=date.today().isoformat())
129
+ return 0
130
+ return 2 # pragma: no cover
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
ghtraffic/collect.py ADDED
@@ -0,0 +1,266 @@
1
+ """The collection run: list repos, fetch what GitHub still holds, merge it in.
2
+
3
+ :func:`collect` is the one orchestrator. It is safe to run any number of times
4
+ a day and in any order: every table merges idempotently (see
5
+ :mod:`ghtraffic.tables`), so the cold start (the first 14-day backfill) and the
6
+ daily update are the same call.
7
+
8
+ Repos the token cannot push to have no readable traffic: they are counted and
9
+ listed in ``last_run.json``, never treated as a failure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import time
17
+ from collections.abc import Callable, Iterable, MutableMapping
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from dataclasses import asdict, dataclass, field
20
+ from functools import partial
21
+ from datetime import date, datetime, timezone
22
+
23
+ from ghtraffic.config import DFLT_DATA_DIR, Settings
24
+ from ghtraffic.github import GitHubError, GraphQL, Rest, make_graphql, make_rest
25
+ from ghtraffic.repos import list_repos
26
+ from ghtraffic.tables import (
27
+ MANIFEST_KEY,
28
+ SCHEMA_VERSION,
29
+ TABLES,
30
+ Files,
31
+ read_table,
32
+ table_files,
33
+ write_rows,
34
+ )
35
+ from ghtraffic.traffic import fetch_daily, fetch_popular, star_history
36
+
37
+ LAST_RUN_KEY = "last_run.json"
38
+ _log = logging.getLogger("ghtraffic")
39
+ #: HTTP statuses meaning "this token cannot read this repo's traffic".
40
+ NO_ACCESS_STATUSES = frozenset({401, 403, 404})
41
+
42
+
43
+ @dataclass
44
+ class RunReport:
45
+ """What a run did, for logs and ``last_run.json``."""
46
+
47
+ started_at: str
48
+ duration_s: float = 0.0
49
+ repos_listed: int = 0
50
+ repos_collected: int = 0
51
+ snapshots: int = 0
52
+ no_access: list[str] = field(default_factory=list)
53
+ errors: dict[str, str] = field(default_factory=dict)
54
+ owner_errors: dict[str, str] = field(default_factory=dict)
55
+ files_changed: int = 0
56
+
57
+ def summary(self) -> str:
58
+ """One human line, with no repo names (safe for public logs)."""
59
+ return (
60
+ f"ghtraffic: {self.repos_listed} repos listed, "
61
+ f"{self.repos_collected} collected ({self.snapshots} snapshots), "
62
+ f"{len(self.no_access)} without push access, "
63
+ f"{len(self.errors)} errors, {len(self.owner_errors)} owner errors, "
64
+ f"{self.files_changed} files changed in {self.duration_s:.1f}s"
65
+ )
66
+
67
+
68
+ def _last_snapshots(store: MutableMapping, today: date) -> dict[str, str]:
69
+ years = {str(today.year), str(today.year - 1)}
70
+ last: dict[str, str] = {}
71
+ for r in read_table(store, TABLES["windows"], years=years):
72
+ last[r["repo"]] = max(last.get(r["repo"], ""), str(r["fetched"]))
73
+ return last
74
+
75
+
76
+ def _snapshot_due(last: str | None, today: date, every_days: int) -> bool:
77
+ if not last:
78
+ return True
79
+ return (today - date.fromisoformat(last)).days >= every_days
80
+
81
+
82
+ def write_manifest(store: MutableMapping, *, updated: str) -> dict:
83
+ """Write ``manifest.json``: the schema and the files of every table."""
84
+ manifest = {
85
+ "schema_version": SCHEMA_VERSION,
86
+ "updated": updated,
87
+ "tables": {
88
+ name: {
89
+ "files": table_files(store, spec),
90
+ "columns": list(spec.columns),
91
+ "key": list(spec.key),
92
+ "description": spec.description,
93
+ }
94
+ for name, spec in TABLES.items()
95
+ },
96
+ }
97
+ text = json.dumps(manifest, indent=2) + "\n"
98
+ if store.get(MANIFEST_KEY) != text:
99
+ store[MANIFEST_KEY] = text
100
+ return manifest
101
+
102
+
103
+ def _safely(fn: Callable, repo: str):
104
+ """``(repo, fn(repo), None)``, or ``(repo, None, error)``: one repo never sinks a run."""
105
+ try:
106
+ return repo, fn(repo), None
107
+ except Exception as e: # noqa: BLE001 - any failure is recorded per repo
108
+ return repo, None, e
109
+
110
+
111
+ def _repo_row(r: dict, last_seen: str) -> dict:
112
+ return {
113
+ **{c: r.get(c, "") for c in TABLES["repos"].columns},
114
+ "last_seen": last_seen,
115
+ }
116
+
117
+
118
+ def collect(
119
+ settings: Settings,
120
+ *,
121
+ store: MutableMapping | None = None,
122
+ rest: Rest | None = None,
123
+ graphql: GraphQL | None = None,
124
+ list_repos: Callable[..., Iterable[dict]] = list_repos,
125
+ star_history: Callable[..., list[dict]] = star_history,
126
+ now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
127
+ log: Callable[[str], None] = _log.info,
128
+ ) -> RunReport:
129
+ """Run one collection into ``store`` and return its report.
130
+
131
+ Args:
132
+ settings: what to collect (:func:`~ghtraffic.config.load_settings`).
133
+ store: where the history lives; default ``~/.local/share/ghtraffic/data``.
134
+ rest, graphql: GitHub transports; default urllib with an env token.
135
+ list_repos: ``(owners, *, graphql, rest, exclude, include_forks,
136
+ include_archived, on_error) -> records`` with the keys of
137
+ :data:`~ghtraffic.repos.REPO_FIELDS`.
138
+ star_history: ``(repo, *, graphql) -> stars rows``, for the backfill.
139
+ now: the clock; log: where the one-line summary goes.
140
+ """
141
+ store = Files(DFLT_DATA_DIR) if store is None else store
142
+ rest = rest or make_rest()
143
+ graphql = graphql or make_graphql()
144
+ t0 = time.monotonic()
145
+ started = now()
146
+ today = started.date()
147
+ fetched = today.isoformat()
148
+ report = RunReport(started_at=started.isoformat(timespec="seconds"))
149
+ workers = max(1, settings.max_workers)
150
+
151
+ def on_owner_error(owner: str, e: Exception) -> None:
152
+ report.owner_errors[owner] = str(e)
153
+
154
+ repos = list(
155
+ list_repos(
156
+ settings.owners,
157
+ graphql=graphql,
158
+ rest=rest,
159
+ exclude=settings.exclude,
160
+ include_forks=settings.include_forks,
161
+ include_archived=settings.include_archived,
162
+ on_error=on_owner_error,
163
+ )
164
+ )
165
+ report.repos_listed = len(repos)
166
+ rows: dict[str, list[dict]] = {name: [] for name in TABLES}
167
+ rows["repos"] = [_repo_row(r, fetched) for r in repos]
168
+
169
+ # star history first, so today's live values (below) win a same-day merge
170
+ if settings.backfill_stars:
171
+ done = {
172
+ r["repo"]
173
+ for r in read_table(store, TABLES["backfills"])
174
+ if r["kind"] == "stars"
175
+ }
176
+ todo = [
177
+ r["repo"]
178
+ for r in repos
179
+ if r["stars"] and r["can_push"] and r["repo"] not in done
180
+ ]
181
+ backfill = partial(star_history, graphql=graphql)
182
+ with ThreadPoolExecutor(max_workers=workers) as pool:
183
+ for repo, got, err in pool.map(lambda r: _safely(backfill, r), todo):
184
+ if err is not None:
185
+ report.errors[repo] = f"star backfill: {err}" # retried next run
186
+ else:
187
+ rows["stars"] += got
188
+ rows["backfills"].append(
189
+ {"repo": repo, "kind": "stars", "date": fetched}
190
+ )
191
+ rows["stars"] += [
192
+ {"date": fetched, **{k: r[k] for k in ("repo", "stars", "forks", "watchers")}}
193
+ for r in repos
194
+ ]
195
+
196
+ # traffic: only where the token can push
197
+ readable = [r["repo"] for r in repos if r["can_push"]]
198
+ report.no_access = sorted(r["repo"] for r in repos if not r["can_push"])
199
+ last_snap = _last_snapshots(store, today)
200
+ due = {
201
+ repo: _snapshot_due(last_snap.get(repo), today, settings.snapshot_every_days)
202
+ for repo in readable
203
+ }
204
+
205
+ def fetch(repo: str) -> dict[str, list[dict]]:
206
+ got = fetch_daily(repo, rest=rest, fetched=fetched)
207
+ if not due[repo]:
208
+ got.pop("windows")
209
+ return got
210
+ return {**got, **fetch_popular(repo, rest=rest, fetched=fetched)}
211
+
212
+ with ThreadPoolExecutor(max_workers=workers) as pool:
213
+ for repo, got, err in pool.map(lambda r: _safely(fetch, r), readable):
214
+ if err is not None:
215
+ if (
216
+ isinstance(err, GitHubError)
217
+ and err.status in NO_ACCESS_STATUSES
218
+ and not (err.is_rate_limit or err.is_policy_block)
219
+ ):
220
+ report.no_access.append(repo)
221
+ else:
222
+ report.errors[repo] = f"{type(err).__name__}: {err}"
223
+ continue
224
+ report.repos_collected += 1
225
+ report.snapshots += bool(due[repo])
226
+ for name, table_rows in got.items():
227
+ rows[name] += table_rows
228
+ report.no_access.sort()
229
+
230
+ for name, spec in TABLES.items():
231
+ if name != "runs" and rows[name]:
232
+ report.files_changed += write_rows(store, spec, rows[name])
233
+
234
+ report.duration_s = round(time.monotonic() - t0, 1)
235
+ report.files_changed += write_rows(
236
+ store,
237
+ TABLES["runs"],
238
+ [
239
+ {
240
+ "started_at": report.started_at,
241
+ "duration_s": report.duration_s,
242
+ "repos_listed": report.repos_listed,
243
+ "repos_collected": report.repos_collected,
244
+ "repos_no_access": len(report.no_access),
245
+ "snapshots": report.snapshots,
246
+ "errors": len(report.errors) + len(report.owner_errors),
247
+ }
248
+ ],
249
+ )
250
+ store[LAST_RUN_KEY] = json.dumps(asdict(report), indent=2) + "\n"
251
+ write_manifest(store, updated=fetched)
252
+ log(report.summary())
253
+ return report
254
+
255
+
256
+ def has_private_data(store: MutableMapping) -> bool:
257
+ """Whether the history holds anything about a private repo."""
258
+ return any(r["private"] == "true" for r in read_table(store, TABLES["repos"]))
259
+
260
+
261
+ def window_days(store: MutableMapping) -> dict[str, int]:
262
+ """How many distinct days each daily table holds (a quick health check)."""
263
+ return {
264
+ name: len({r["date"] for r in read_table(store, TABLES[name])})
265
+ for name in ("views", "clones")
266
+ }
ghtraffic/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Collection settings: which owners, which repos, how often to snapshot.
2
+
3
+ Settings come from a TOML file (``ghtraffic.toml`` at the data directory's root
4
+ by default), under a ``[ghtraffic]`` table or at top level, overridden by
5
+ keyword arguments. Every field has a default, so an empty file (or none)
6
+ collects every repo of the authenticated user and their organizations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, fields
13
+ from pathlib import Path
14
+
15
+ import tomllib
16
+
17
+ DFLT_CONFIG_FILENAME = "ghtraffic.toml"
18
+ DFLT_DATA_DIR = (
19
+ Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
20
+ / "ghtraffic"
21
+ / "data"
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Settings:
27
+ """What to collect, and how.
28
+
29
+ Attributes:
30
+ owners: user/org logins; ``"@me"`` means you and every org you belong to.
31
+ exclude: ``owner/repo`` fnmatch patterns to skip (``"acme/*"`` skips an owner).
32
+ include_forks: collect forks too.
33
+ include_archived: collect archived repos too.
34
+ snapshot_every_days: days between snapshots of the 14-day aggregates
35
+ (referrers, paths, window totals). 14 (the default) tiles the window:
36
+ snapshots never overlap, so their counts can be added up.
37
+ backfill_stars: on first sight of a repo, rebuild its star history from
38
+ stargazer dates (one GraphQL call per 100 stars).
39
+ max_workers: concurrent requests while fetching traffic.
40
+ """
41
+
42
+ owners: tuple[str, ...] = ("@me",)
43
+ exclude: tuple[str, ...] = ()
44
+ include_forks: bool = True
45
+ include_archived: bool = True
46
+ snapshot_every_days: int = 14
47
+ backfill_stars: bool = True
48
+ max_workers: int = 8
49
+
50
+
51
+ def _from_mapping(d: dict) -> Settings:
52
+ d = dict(d.get("ghtraffic", d))
53
+ known = [f.name for f in fields(Settings)]
54
+ unknown = sorted(set(d) - set(known))
55
+ if unknown:
56
+ raise ValueError(
57
+ f"Unknown ghtraffic setting(s) {unknown}; valid keys are {known}."
58
+ )
59
+ for k in ("owners", "exclude"):
60
+ if isinstance(d.get(k), str):
61
+ d[k] = (d[k],)
62
+ elif k in d:
63
+ d[k] = tuple(d[k])
64
+ return Settings(**d)
65
+
66
+
67
+ def load_settings(path: str | os.PathLike | None = None, **overrides) -> Settings:
68
+ """Settings from a TOML file (missing file: defaults), then ``overrides``."""
69
+ s = Settings()
70
+ if path is not None and Path(path).exists():
71
+ s = _from_mapping(tomllib.loads(Path(path).read_text(encoding="utf-8")))
72
+ overrides = {k: v for k, v in overrides.items() if v is not None}
73
+ base = {f.name: getattr(s, f.name) for f in fields(Settings)}
74
+ return _from_mapping({**base, **overrides})