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 +41 -0
- ghtraffic/__main__.py +134 -0
- ghtraffic/collect.py +266 -0
- ghtraffic/config.py +74 -0
- ghtraffic/demo.py +185 -0
- ghtraffic/github.py +199 -0
- ghtraffic/repos.py +181 -0
- ghtraffic/scaffold.py +123 -0
- ghtraffic/tables.py +360 -0
- ghtraffic/traffic.py +169 -0
- ghtraffic-0.0.2.dist-info/METADATA +131 -0
- ghtraffic-0.0.2.dist-info/RECORD +15 -0
- ghtraffic-0.0.2.dist-info/WHEEL +4 -0
- ghtraffic-0.0.2.dist-info/entry_points.txt +2 -0
- ghtraffic-0.0.2.dist-info/licenses/LICENSE +21 -0
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})
|