Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
download_stats.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# This file is part of the Ansel project.
3# Copyright (C) 2026 Aurélien PIERRE.
4#
5# Ansel is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansel is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansel. If not, see <http://www.gnu.org/licenses/>.
17
18"""Download statistics for the nightly packages, as a daily series.
19
20 download_stats.py snapshot > today.json
21 download_stats.py append series.json today.json > series.json
22
23GitHub keeps one lifetime `download_count` per release asset and nothing else -- delete
24the asset (the monthly prune does, and so did moving August 2026 between releases) and
25its count is gone. Docker Hub keeps one lifetime `pull_count` per repository. Neither is
26a time series. This turns them into one: a snapshot of every counter, once a day, appended
27to a list that only ever grows. Downloads *between* two dates are the difference of two
28snapshots; the loss on deletion stops mattering once the day before is on record.
29
30Every route a user takes ends up in these counters: the website's buttons, the in-app
31"Update to the latest nightly build", the Homebrew cask and the Scoop manifest all
32download the release asset, and AppImageUpdate fetches the .zsync and ranges of the
33AppImage. Neither Homebrew nor Scoop has analytics for third-party taps; there is
34nothing to add from their side.
35
36The build month of an asset is read from its release tag (nightly-YYYY-MM) when the tag
37carries one, and from the asset's creation date otherwise -- an asset moved between
38releases is created anew, so its date is the day it was moved, not the night it was built.
39"""
40
41import collections
42import datetime
43import json
44import re
45import os
46import sys
47import urllib.request
48
49REPO = "aurelienpierreeng/ansel"
50API = "https://api.github.com"
51DOCKER_IMAGE = "aurelienpierre/ansel"
52DOCKER_HUB = "https://hub.docker.com/v2/repositories"
53MONTH_TAG_RE = re.compile(r"^nightly-(\d{4}-\d{2})$")
54
55# Filename suffix -> format key. Same vocabulary as nightly_manifest.py, plus the
56# updater's .zsync, which is its own line: those are AppImageUpdate fetching the newest
57# build incrementally, not a person clicking a button.
58FORMATS = [
59 (".AppImage.zsync", "zsync"),
60 ("-x86_64.AppImage", "appimage"),
61 ("-x86_64.flatpak", "flatpak"),
62 ("-arm64.dmg", "dmg-arm64"),
63 ("-i386.dmg", "dmg-i386"),
64 ("-win64.exe", "exe"),
65 ("-docker.tar.zst", "docker-archive"),
66]
67
68
69def format_of(name):
70 for suffix, key in FORMATS:
71 if name.endswith(suffix):
72 return key
73 return "other"
74
75
76def get_json(url, token=None):
77 req = urllib.request.Request(url, headers={
78 "Accept": "application/vnd.github+json", "User-Agent": "ansel-download-stats",
79 **({"Authorization": f"Bearer {token}"} if token else {}),
80 })
81 with urllib.request.urlopen(req, timeout=60) as r:
82 return json.loads(r.read().decode("utf-8"))
83
84
85def github_releases(token):
86 page, out = 1, []
87 while True:
88 batch = get_json(f"{API}/repos/{REPO}/releases?per_page=100&page={page}", token)
89 if not batch:
90 return out
91 out.extend(batch)
92 page += 1
93
94
95def snapshot(token):
96 by_format = collections.Counter()
97 by_month = collections.Counter()
98 by_release = collections.Counter()
99 assets = 0
100 for rel in github_releases(token):
101 tag = rel["tag_name"]
102 m = MONTH_TAG_RE.match(tag)
103 for a in rel["assets"]:
104 n = int(a.get("download_count") or 0)
105 month = m.group(1) if m else (a.get("created_at") or "")[:7]
106 by_format[format_of(a["name"])] += n
107 by_month[month] += n
108 by_release[tag] += n
109 assets += 1
110
111 # Repository clones, per day, from the traffic API: the closest thing to "downloads"
112 # for people who build from source. Only the last 14 days are exposed and the call
113 # needs write access to the repository, so it is best effort: a token without it
114 # (possibly the workflow's own GITHUB_TOKEN) yields a warning and no traffic block,
115 # and the snapshot is otherwise complete. The raw count is dominated by CI -- every
116 # workflow job clones -- so `uniques` (distinct cloners) is the figure to read.
117 traffic = None
118 try:
119 clones = get_json(f"{API}/repos/{REPO}/traffic/clones?per=day", token)
120 traffic = {c["timestamp"][:10]: {"clones": c["count"], "uniques": c["uniques"]} for c in clones.get("clones", [])}
121 except Exception as e: # noqa: BLE001
122 print(f"traffic API unavailable ({e}); snapshot has no clone counts", file=sys.stderr)
123
124 docker = None
125 try:
126 hub = get_json(f"{DOCKER_HUB}/{DOCKER_IMAGE}/")
127 docker = {"pull_count": int(hub.get("pull_count") or 0), "star_count": int(hub.get("star_count") or 0)}
128 except Exception as e: # noqa: BLE001 -- Docker Hub down must not lose the GitHub half
129 print(f"docker hub unreachable: {e}", file=sys.stderr)
130
131 return {
132 "date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"),
133 "github": {
134 "total": sum(by_format.values()),
135 "assets": assets,
136 "by_format": dict(sorted(by_format.items())),
137 "by_month": dict(sorted(by_month.items())),
138 "by_release": dict(sorted(by_release.items())),
139 },
140 "docker_hub": docker,
141 "traffic": traffic,
142 }
143
144
145def append(series, snap):
146 """One entry per date, the newest snapshot for a date winning; sorted by date.
147
148 Clone traffic is a rolling 14-day window, so each snapshot's `traffic` is folded
149 into the series' own by-date map (`series[-1]["traffic_by_day"]` carries it forward):
150 a day seen in several snapshots keeps the last value, and days older than the
151 window survive because they were recorded when they were inside it."""
152 entries = {s["date"]: s for s in series if isinstance(s, dict) and "date" in s}
153 by_day = {}
154 for s in series:
155 by_day.update(s.get("traffic_by_day") or {})
156 by_day.update(s.get("traffic") or {})
157 by_day.update(snap.get("traffic") or {})
158 snap = dict(snap)
159 snap["traffic_by_day"] = dict(sorted(by_day.items()))
160 entries[snap["date"]] = snap
161 return [entries[d] for d in sorted(entries)]
162
163
164def main():
165 cmd = sys.argv[1] if len(sys.argv) > 1 else ""
166 if cmd == "snapshot":
167 json.dump(snapshot(os.environ.get("GITHUB_TOKEN")), sys.stdout, indent=1)
168 sys.stdout.write("\n")
169 elif cmd == "append" and len(sys.argv) == 4:
170 try:
171 series = json.load(open(sys.argv[2], encoding="utf-8"))
172 except (FileNotFoundError, json.JSONDecodeError):
173 series = []
174 if not isinstance(series, list):
175 series = []
176 snap = json.load(open(sys.argv[3], encoding="utf-8"))
177 json.dump(append(series, snap), sys.stdout, separators=(",", ":"))
178 sys.stdout.write("\n")
179 else:
180 sys.exit(__doc__)
181
182
183if __name__ == "__main__":
184 main()
append(series, snap)
get_json(url, token=None)