Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
update_readme_metrics.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Refresh the SonarCloud figures quoted in README.md, in place.
3
4The README compares Ansel against darktable release by release, with every number
5linked to the SonarCloud measure it came from. Those numbers go stale silently, and a
6stale number in a document meant to be read by people deciding whether to trust the
7project is worse than no number at all.
8
9Rather than keeping a second copy of the table here, this reads the README itself.
10Every SonarCloud link already names the component it refers to, so the file IS the
11specification: the script finds each link, asks SonarCloud what that component measures
12today, and rewrites the figure. Descriptions, ordering, footnotes and prose are never
13touched, and a row added by hand is picked up on the next run with no change here.
14
15Two cell shapes are recognised:
16
17 [61,373](https://sonarcloud.io/component_measures?metric=complexity&id=PROJECT)
18 a single measure; the metric comes from the URL.
19
20 [536](https://sonarcloud.io/...&selected=PROJECT:src/x.c...) / 2206
21 the per-file shape used in the comparison tables: cyclomatic complexity,
22 then lines of code. The trailing number is updated too. The metric named in
23 the URL is IGNORED for these - a few of the hand-written links say
24 metric=ncloc while displaying complexity, and the position is what the
25 surrounding table promises the reader.
26
27Only public projects are read, over the anonymous API, so this needs no token.
28
29Usage:
30 python3 tools/update_readme_metrics.py [--readme README.md] [--check]
31
32 --check report what would change and exit non-zero if anything is stale, without
33 writing. Suitable for CI.
34"""
35
36import argparse
37import csv
38import importlib.util
39import json
40import os
41import re
42import shutil
43import sqlite3
44import subprocess
45import sys
46import tempfile
47import urllib.parse
48import urllib.request
49
50API = "https://sonarcloud.io/api/measures/component"
51TREE = "https://sonarcloud.io/api/components/tree"
52
53# [number](sonarcloud url) optionally followed by " / number"
54CELL = re.compile(
55 r"\[(?P<value>[\d.,]+)(?P<pct>%?)\]\‍((?P<url>https://sonarcloud\.io/[^)]+)\‍)"
56 r"(?P<tail>\s*/\s*(?P<second>[\d,]+))?"
57)
58
59
60def fetch(component, metrics):
61 """Ask SonarCloud for one component's measures. Returns {metric: raw string}."""
62 query = urllib.parse.urlencode({"component": component,
63 "metricKeys": ",".join(sorted(metrics))})
64 req = urllib.request.Request(API + "?" + query,
65 headers={"User-Agent": "ansel-readme-metrics"})
66 with urllib.request.urlopen(req, timeout=30) as fh:
67 payload = json.load(fh)
68 return {m["metric"]: m["value"]
69 for m in payload.get("component", {}).get("measures", [])}
70
71
72def relocate(project, path):
73 """Find a file that has moved, by basename, within the same project.
74
75 Ansel reorganises: bauhaus.c went from src/bauhaus/ to src/widgets/, mipmap_cache.c
76 to src/caches/, and so on. The README then points at components that 404, and the
77 figures beside them quietly stop being refreshed - which is exactly the failure this
78 script exists to prevent. Searching the project tree by basename recovers them, but
79 only when the answer is unambiguous: two files of the same name are left alone for a
80 human to resolve rather than guessed at.
81 """
82 basename = path.rsplit("/", 1)[-1]
83 query = urllib.parse.urlencode({"component": project, "q": basename,
84 "qualifiers": "FIL", "ps": "10"})
85 req = urllib.request.Request(TREE + "?" + query,
86 headers={"User-Agent": "ansel-readme-metrics"})
87 try:
88 with urllib.request.urlopen(req, timeout=30) as fh:
89 payload = json.load(fh)
90 except Exception: # noqa: BLE001
91 return None
92 hits = [c["key"] for c in payload.get("components", [])
93 if c["key"].rsplit("/", 1)[-1] == basename]
94 return hits[0] if len(hits) == 1 else None
95
96
97def component_of(url):
98 """The component a measure link points at, and the metric it names."""
99 parts = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
100 project = (parts.get("id") or [""])[0]
101 selected = (parts.get("selected") or [""])[0]
102 metric = (parts.get("metric") or ["complexity"])[0]
103 return (selected or project), metric
104
105
106def format_like(old, value, metric):
107 """Render a fresh value the way the README already renders that column."""
108 if metric == "comment_lines_density":
109 return "%.1f" % float(value)
110 n = int(float(value))
111 return "{:,}".format(n) if "," in old else str(n)
112
113
114# A block the script owns entirely, regenerated on every run. The marker carries its
115# own specification - which projects, under which column headings, and which directory
116# to subtract - so the README stays the single source of truth for what it displays.
117BLOCK = re.compile(
118 r"(?P<open><!-- BEGIN GENERATED (?P<name>[\w-]+):(?P<spec>[^>]*)-->\n)"
119 r"(?P<body>.*?)"
120 r"(?P<close><!-- END GENERATED (?P=name) -->)",
121 re.DOTALL)
122
123
124# Engine figures for the Darktable releases, measured once with the tooling below on the
125# tagged source trees, and frozen because a release does not change. Ansel's column is
126# re-measured on every run from the working tree, which is the only one that moves.
127#
128# Measured with: lizard (cyclomatic complexity, summed over every function outside
129# src/iop) and cloc (lines of code and comment lines, C/C++/Objective-C only), with
130# vendored code and git submodules excluded. Reproduce any column with
131# tools/code_health.py on the corresponding tag.
132FROZEN_ENGINE = {
133 "Darktable 3.8": {"tag": "release-3.8.1", "complexity": 35244,
134 "code": 199820, "comment": 28736},
135 "Darktable 4.0": {"tag": "release-4.0.0", "complexity": 37156,
136 "code": 207304, "comment": 31877},
137 "Darktable 5.0": {"tag": "release-5.0.0", "complexity": 38016,
138 "code": 229248, "comment": 34431},
139 "Darktable 5.6": {"tag": "release-5.6.0", "complexity": 44059,
140 "code": 260318, "comment": 40879},
141}
142
143# src/external holds the git submodules - rawspeed, LibRaw, sentry-native and the rest -
144# which are upstream projects pinned at a commit, not this repository's code. They are
145# 64% of the functions under src/ when the submodules are checked out, so leaving them in
146# would not skew the figures, it would replace them. A git worktree does not populate
147# submodules, which is exactly why this filter must be tested against a full checkout
148# rather than assumed to work.
149ENGINE_EXCLUDE = ("/external/", "/apps/ansel-chart/", "/iop/",
150 "/tests/", "/image_test/samples/",
151 "/doxygen-awesome-css/")
152ENGINE_SUFFIXES = (".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx")
153ENGINE_LANGUAGES = frozenset(("C", "C/C++ Header", "C++"))
154
155
157 p = "/" + path.replace("\\", "/").lstrip("/")
158 if not p.lower().endswith(ENGINE_SUFFIXES):
159 return True
160 return any(part in p for part in ENGINE_EXCLUDE)
161
162
163def measure_engine(source_dir="src"):
164 """Measure this working tree's engine with lizard and cloc.
165
166 Returns None if either tool is missing, so the table is left untouched rather than
167 written with half of it guessed.
168 """
169 if not (shutil.which("lizard") and shutil.which("cloc")):
170 sys.stderr.write("readme-metrics: lizard and cloc are needed for the engine table\n")
171 return None
172
173 cmd = ["lizard", "--csv", "-l", "c", "-l", "cpp"]
174 for part in ENGINE_EXCLUDE:
175 cmd += ["-x", "*%s*" % part]
176 cmd.append(source_dir)
177 out = subprocess.run(cmd, capture_output=True, text=True,
178 errors="replace", check=False).stdout
179 complexity = 0
180 for parts in csv.reader(out.splitlines()):
181 # lizard quotes its path fields; csv, never a bare split
182 if len(parts) < 8:
183 continue
184 try:
185 ccn = int(parts[1])
186 except ValueError:
187 continue
188 if _engine_excluded(parts[6]):
189 continue
190 complexity += ccn
191
192 out = subprocess.run(["cloc", "--quiet", "--json", "--by-file", source_dir],
193 capture_output=True, text=True, errors="replace",
194 check=False).stdout
195 try:
196 data = json.loads(out)
197 except ValueError:
198 return None
199 data.pop("header", None)
200 data.pop("SUM", None)
201 code = comment = 0
202 for path, v in data.items():
203 if v.get("language") not in ENGINE_LANGUAGES or _engine_excluded(path):
204 continue
205 code += v.get("code", 0)
206 comment += v.get("comment", 0)
207 if not complexity or not code:
208 return None
209 return {"complexity": complexity, "code": code, "comment": comment}
210
211
212def engine_table(spec, previous=""):
213 """The engine comparison: local tooling for size and complexity, Sonar for cognitive.
214
215 Comparing the projects as a whole compares their feature sets: the set of pixel
216 operations under src/iop has diverged between the forks, and those modules are
217 independent of one another, so their bulk says little about maintainability.
218 Subtracting them compares the engine, which is what both projects need whatever
219 their module set.
220
221 Cyclomatic complexity, lines of code and comment ratio come from ONE tool applied
222 identically to every version, because SonarCloud and lizard do not define
223 cyclomatic complexity the same way and mixing them silently compares nothing.
224 Cognitive complexity has no local equivalent, so it is reported from SonarCloud for
225 the three versions that have a project there, and left blank for the rest rather
226 than approximated.
227 """
228 # Each entry is "<sonar project or -> = <column label>". Whether a column is
229 # measured locally or read from the frozen table is decided by its label, not by
230 # its Sonar key: Ansel is measured locally AND has a Sonar project, and an earlier
231 # version of this code used the key to decide both and silently blanked Ansel's
232 # cognitive complexity.
233 columns, sonar = [], {}
234 for item in spec.split(","):
235 item = item.strip()
236 if not item or item.startswith("exclude="):
237 continue
238 key, _, label = item.partition("=")
239 label = label.strip() or key.strip()
240 columns.append(label)
241 key = key.strip()
242 sonar[label] = key if key and key != "-" else None
243
244 local = measure_engine()
245 if local is None:
246 raise RuntimeError("local engine measurement unavailable")
247
248 data = {}
249 for label in columns:
250 if label in FROZEN_ENGINE:
251 data[label] = dict(FROZEN_ENGINE[label])
252 else:
253 data[label] = dict(local) # the tree this script is running in
254 key = sonar.get(label)
255 cog = None
256 if key:
257 # Subtract src/external as well as src/iop. The three projects do NOT
258 # configure the same exclusions - aurelienpierre_darktable analyses its
259 # vendored submodules while the other two exclude them - so trusting each
260 # project's own scope compares different bodies of code. Left uncorrected
261 # this inflated Darktable 4.0's engine by 4,242 cyclomatic and 3,810
262 # cognitive, enough to reverse its ranking against Ansel and to make the
263 # SonarCloud figures appear to contradict the local ones. A component that
264 # is already excluded simply 404s and contributes zero.
265 try:
266 m = ["cognitive_complexity"]
267 total = fetch(key, m)
268 cog = int(float(total.get("cognitive_complexity", 0)))
269 for sub in ("src/iop", "src/external"):
270 try:
271 part = fetch("%s:%s" % (key, sub), m)
272 cog -= int(float(part.get("cognitive_complexity", 0)))
273 except Exception: # noqa: BLE001 - absent means excluded
274 pass
275 except Exception: # noqa: BLE001 - blank beats a wrong number
276 cog = None
277 data[label]["cognitive"] = cog
278
279 def ratio(d):
280 return "%.1f %%" % (100.0 * d["comment"] / max(1, d["comment"] + d["code"]))
281
282 # Documentation coverage needs Doxygen's symbol table. When it has not been built,
283 # keep whatever the README already shows rather than blanking a real figure.
284 kept_docs, kept_other = {}, {}
285 for line in (previous or "").splitlines():
286 for prefix, store in (("| Functions carrying documentation", kept_docs),
287 ("| Types, constants and macros carrying documentation",
288 kept_other)):
289 if line.startswith(prefix):
290 cells = [c.strip() for c in line.strip().strip("|").split("|")]
291 for label, value in zip(columns, cells[1:]):
292 if value and value != "—":
293 store[label] = value
294 live_docs = measure_docs(DOXYGEN_DB[0])
295 if live_docs is None:
296 # Nothing prepared for us: build the symbol table rather than give up on the row.
297 built = build_doxygen_db()
298 if built:
299 sys.stderr.write("readme-metrics: built a Doxygen symbol table for "
300 "documentation coverage\n")
301 live_docs = measure_docs(built)
302 for label in columns:
303 d = data[label]
304 if label in FROZEN_DOCS:
305 f = FROZEN_DOCS[label]
306 d["docs"] = "%.1f %%" % (100.0 * f["documented"] / f["functions"])
307 elif live_docs:
308 d["docs"] = "%.1f %%" % (100.0 * live_docs["documented"] / live_docs["functions"])
309 else:
310 d["docs"] = kept_docs.get(label, "—")
311 if label in FROZEN_DOCS_OTHER:
312 f = FROZEN_DOCS_OTHER[label]
313 d["docs_other"] = "%.1f %%" % (100.0 * f["documented"] / f["symbols"])
314 elif live_docs and live_docs.get("other_symbols"):
315 d["docs_other"] = "%.1f %%" % (100.0 * live_docs["other_documented"]
316 / live_docs["other_symbols"])
317 else:
318 d["docs_other"] = kept_other.get(label, "—")
319
320 rows = [("Cyclomatic complexity", lambda d: "{:,}".format(d["complexity"])),
321 ("Lines of code", lambda d: "{:,}".format(d["code"])),
322 ("Comment lines", lambda d: "{:,}".format(d["comment"])),
323 ("Ratio of comments", ratio),
324 ("Cognitive complexity",
325 lambda d: "{:,}".format(d["cognitive"]) if d["cognitive"] else "—"),
326 ("Functions carrying documentation", lambda d: d["docs"]),
327 ("Types, constants and macros carrying documentation",
328 lambda d: d["docs_other"])]
329 out = ["| Metric | " + " | ".join(columns) + " |",
330 "| ------ | " + " | ".join("-----------:" for _ in columns) + " |"]
331 for label, render in rows:
332 out.append("| " + label + " | " + " | ".join(render(data[c]) for c in columns) + " |")
333 return "\n".join(out) + "\n"
334
335
336
337# ---------------------------------------------------------------- frozen release data
338#
339# Everything below describes released Darktable versions, measured once with the tooling
340# in this file and in tools/code_health.py on the corresponding tag. A release does not
341# change, so re-measuring it on every run would mean keeping four Darktable checkouts
342# around to compute constants. Ansel's column is measured live, because it is the only
343# one that moves.
344#
345# Reproduce any of these with:
346# git clone https://github.com/darktable-org/darktable && cd darktable
347# git checkout <tag>
348# python3 <ansel>/tools/code_health.py --source-dir src --repo-root .
349
350# Share of engine functions carrying a documentation comment, from Doxygen's own record.
351# Doxygen counts functions differently from lizard - it sees static inline definitions in
352# headers, and function-like macros - so these totals do not match the per-function table
353# above. Only the ratio is published, for that reason.
354FROZEN_DOCS = {
355 "Darktable 3.8": {"functions": 9308, "documented": 1997},
356 "Darktable 4.0": {"functions": 9600, "documented": 2008},
357 "Darktable 5.0": {"functions": 10212, "documented": 2048},
358 "Darktable 5.6": {"functions": 11316, "documented": 2228},
359}
360
361# Everything that is not a function: types, constants, enumerations and macros. Reported
362# separately because the two behave nothing alike - a codebase can explain what its
363# functions do while leaving every type and macro bare, and that is what all five of these
364# versions do.
365FROZEN_DOCS_OTHER = {
366 "Darktable 3.8": {"symbols": 6363, "documented": 332},
367 "Darktable 4.0": {"symbols": 6695, "documented": 329},
368 "Darktable 5.0": {"symbols": 7373, "documented": 349},
369 "Darktable 5.6": {"symbols": 8190, "documented": 404},
370}
371
372FROZEN_FUNCTIONS = {
373 "Darktable 3.8": {"functions": 7242, "mean": 4.87, "max": 194, "over15": 428, "over50": 45},
374 "Darktable 4.0": {"functions": 7484, "mean": 4.96, "max": 210, "over15": 456, "over50": 48},
375 "Darktable 5.0": {"functions": 7759, "mean": 4.90, "max": 252, "over15": 453, "over50": 48},
376 "Darktable 5.6": {"functions": 8691, "mean": 5.07, "max": 249, "over15": 522, "over50": 63},
377}
378
379FROZEN_INCLUDES = {
380 "Darktable 3.8": {"med_dep": 14.5, "avg_aff": 84, "over25": 32,
381 "cycles": 4, "trapped": 17, "god": 30},
382 "Darktable 4.0": {"med_dep": 13.4, "avg_aff": 83, "over25": 31,
383 "cycles": 4, "trapped": 17, "god": 30},
384 "Darktable 5.0": {"med_dep": 15.0, "avg_aff": 95, "over25": 34,
385 "cycles": 4, "trapped": 17, "god": 36},
386 "Darktable 5.6": {"med_dep": 14.1, "avg_aff": 96, "over25": 32,
387 "cycles": 4, "trapped": 17, "god": 38},
388}
389
390# Jaccard similarity, normalised tokens, between released versions only. The cells
391# involving Ansel move with Ansel and are recomputed when --darktable-trees is given.
392FROZEN_SIMILARITY = {
393 ("Darktable 3.8", "Darktable 4.0"): 87.3,
394 ("Darktable 3.8", "Darktable 5.6"): 39.6,
395 ("Darktable 4.0", "Darktable 5.6"): 43.2,
396}
397
398DOXYGEN_DB = [None] # set from the command line before any table is built
399
400TREE_DIRS = {"Darktable 3.8": "dt38", "Darktable 4.0": "dt40",
401 "Darktable 5.0": "dt50", "Darktable 5.6": "dt56"}
402
403
405 """Import the sibling analysis module, which owns the include-graph measurement."""
406 path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "code_health.py")
407 spec = importlib.util.spec_from_file_location("code_health", path)
408 mod = importlib.util.module_from_spec(spec)
409 spec.loader.exec_module(mod)
410 return mod
411
412
413def measure_functions(source_dir="src"):
414 """Per-function complexity of this tree's engine, via lizard."""
415 if not shutil.which("lizard"):
416 return None
417 cmd = ["lizard", "--csv", "-l", "c", "-l", "cpp"]
418 for part in ENGINE_EXCLUDE:
419 cmd += ["-x", "*%s*" % part]
420 cmd.append(source_dir)
421 out = subprocess.run(cmd, capture_output=True, text=True,
422 errors="replace", check=False).stdout
423 ccns = []
424 for parts in csv.reader(out.splitlines()):
425 if len(parts) < 8:
426 continue
427 try:
428 ccn = int(parts[1])
429 except ValueError:
430 continue
431 if _engine_excluded(parts[6]):
432 continue
433 ccns.append(ccn)
434 if not ccns:
435 return None
436 ccns.sort()
437 n = len(ccns)
438 return {"functions": n, "mean": round(sum(ccns) / n, 2), "max": ccns[-1],
439 "over15": sum(1 for c in ccns if c > 15),
440 "over50": sum(1 for c in ccns if c > 50)}
441
442
443def measure_includes(repo_root=".", source_dir="src"):
444 """Include-graph exposure of this tree's engine."""
445 ch = _code_health()
446 ch.EXCLUDED_DIR_PARTS[:] = [p for p in ENGINE_EXCLUDE]
447 ch.load_submodule_exclusions(repo_root)
448 cwd = os.getcwd()
449 os.chdir(repo_root)
450 try:
451 edges = ch.source_include_edges(".", source_dir) or []
452 finally:
453 os.chdir(cwd)
454 if not edges:
455 return None
456
457 succ, pred, nodes = {}, {}, set()
458 for a, b in edges:
459 succ.setdefault(a, set()).add(b)
460 pred.setdefault(b, set()).add(a)
461 nodes.update((a, b))
462
463 def closure(adj, start):
464 seen, stack = set(), [start]
465 while stack:
466 u = stack.pop()
467 for v in adj.get(u, ()):
468 if v not in seen:
469 seen.add(v)
470 stack.append(v)
471 seen.discard(start)
472 return seen
473
474 n = len(nodes)
475 headers = [f for f in nodes if f.lower().endswith((".h", ".hpp", ".hxx"))]
476 sources = [f for f in nodes if f not in headers]
477 dep = sorted(len(closure(succ, f)) / n * 100 for f in sources)
478 aff = [len(closure(pred, h)) for h in headers]
479 cycles = [c for c in ch.strongly_connected(sorted(nodes), succ) if len(c) > 1]
480 god = len({a for a, b in edges
481 if b.endswith("darktable.h") and a.lower().endswith((".h", ".hpp"))})
482 return {"med_dep": round(dep[len(dep) // 2], 1),
483 "avg_aff": int(round(sum(aff) / max(1, len(aff)))),
484 "over25": int(round(100.0 * sum(1 for a in aff if a / n > 0.25) / len(headers))),
485 "cycles": len(cycles),
486 "trapped": sum(len(c) for c in cycles),
487 "god": god}
488
489
490def build_doxygen_db(doxyfile="doc/Doxyfile"):
491 """Produce Doxygen's symbol table, when one has not been built already.
492
493 The documentation build makes this in its first pass, but running this script by
494 hand should not require having run that first. Only the SQLite output is asked for -
495 no HTML, no graphs - which takes seconds rather than minutes.
496 """
497 if not (shutil.which("doxygen") and os.path.exists(doxyfile)):
498 return None
499 out = tempfile.mkdtemp(prefix="readme-metrics-doxygen-")
500 with open(doxyfile, encoding="utf-8", errors="replace") as fh:
501 config = fh.read()
502 config += "\n".join([
503 "", "OUTPUT_DIRECTORY = %s" % out,
504 "GENERATE_HTML = NO", "GENERATE_AUTOGEN_DEF = NO", "HAVE_DOT = NO",
505 "GENERATE_SQLITE3 = YES", "QUIET = YES", "WARNINGS = NO",
506 "WARN_IF_UNDOCUMENTED = NO", "WARN_IF_DOC_ERROR = NO",
507 "WARN_IF_INCOMPLETE_DOC = NO", ""])
508 try:
509 subprocess.run(["doxygen", "-"], input=config, text=True,
510 capture_output=True, check=False)
511 except (OSError, subprocess.SubprocessError):
512 return None
513 db = os.path.join(out, "sqlite3", "doxygen_sqlite3.db")
514 return db if os.path.exists(db) else None
515
516
517def measure_docs(db_path):
518 """Share of engine functions carrying a documentation comment.
519
520 Reads the SQLite symbol table Doxygen produces (GENERATE_SQLITE3), which the
521 documentation build already generates in its first pass, and filters to the engine
522 the same way everything else here does. A function counts as documented when Doxygen
523 recorded a brief or detailed description for it - that is, when it carries a real
524 doc-comment rather than an ordinary one.
525 """
526 if not db_path or not os.path.exists(db_path):
527 return None
528 try:
529 con = sqlite3.connect("file:%s?mode=ro" % db_path, uri=True)
530 rows = con.execute(
531 "SELECT p.name, m.kind, "
532 " TRIM(COALESCE(m.briefdescription,'')) || TRIM(COALESCE(m.detaileddescription,'')) "
533 "FROM memberdef m JOIN path p ON p.rowid = m.file_id").fetchall()
534 con.close()
535 except sqlite3.Error:
536 return None
537 fn_total = fn_doc = other_total = other_doc = 0
538 for path, kind, text in rows:
539 if _engine_excluded(path):
540 continue
541 has = bool((text or "").strip())
542 if kind == "function":
543 fn_total += 1
544 fn_doc += 1 if has else 0
545 else:
546 other_total += 1
547 other_doc += 1 if has else 0
548 if not fn_total:
549 return None
550 return {"functions": fn_total, "documented": fn_doc,
551 "other_symbols": other_total, "other_documented": other_doc}
552
553
554def _columns(spec):
555 out = []
556 for item in spec.split(","):
557 item = item.strip()
558 if not item or "=" not in item:
559 continue
560 key, _, label = item.partition("=")
561 out.append(label.strip())
562 return out
563
564
566 """Per-function engine complexity. Ansel measured live, releases frozen."""
567 cols = _columns(spec)
568 live = measure_functions()
569 if live is None:
570 raise RuntimeError("lizard unavailable")
571 data = {c: dict(FROZEN_FUNCTIONS[c]) if c in FROZEN_FUNCTIONS else dict(live)
572 for c in cols}
573 rows = [("Functions", lambda d: "{:,}".format(d["functions"])),
574 ("Average complexity", lambda d: "%.2f" % d["mean"]),
575 ("Worst single function", lambda d: "{:,}".format(d["max"])),
576 ("Functions above 15 — awkward to test", lambda d: "{:,}".format(d["over15"])),
577 ("Functions above 50 — effectively untestable",
578 lambda d: "{:,}".format(d["over50"]))]
579 out = ["| Engine only | " + " | ".join(cols) + " |",
580 "| ----------- | " + " | ".join("-----------:" for _ in cols) + " |"]
581 for label, render in rows:
582 out.append("| " + label + " | " + " | ".join(render(data[c]) for c in cols) + " |")
583 return "\n".join(out) + "\n"
584
585
587 """Include-graph exposure. Ansel measured live, releases frozen."""
588 cols = _columns(spec)
589 live = measure_includes()
590 if live is None:
591 raise RuntimeError("include measurement unavailable")
592 data = {c: dict(FROZEN_INCLUDES[c]) if c in FROZEN_INCLUDES else dict(live)
593 for c in cols}
594 rows = [("A source file depends on this share of the engine, median",
595 lambda d: "%.1f %%" % d["med_dep"]),
596 ("Changing one header forces re-reading this many files, average",
597 lambda d: "{:,}".format(d["avg_aff"])),
598 ("Headers whose change exposes over a quarter of the engine",
599 lambda d: "%d %%" % d["over25"]),
600 ("Circular include groups", lambda d: "{:,}".format(d["cycles"])),
601 ("Files trapped in those groups", lambda d: "{:,}".format(d["trapped"])),
602 ("Headers including the application-wide `darktable.h`",
603 lambda d: "{:,}".format(d["god"]))]
604 out = ["| Engine only | " + " | ".join(cols) + " |",
605 "| ----------- | " + " | ".join("-----------:" for _ in cols) + " |"]
606 for label, render in rows:
607 out.append("| " + label + " | " + " | ".join(render(data[c]) for c in cols) + " |")
608 return "\n".join(out) + "\n"
609
610
611def similarity_table(spec, trees=None, previous="", source_dir="src"):
612 """Upper-triangle similarity matrix.
613
614 Release-to-release cells are frozen. The cells involving Ansel move with Ansel and
615 need the Darktable sources to recompute, so they are refreshed only when
616 --darktable-trees points at a directory holding dt38/ dt40/ dt50/ dt56/ checkouts.
617 Without it the values already in the README are KEPT, not blanked: a table that
618 loses real numbers because an optional input was missing is worse than one that is
619 slightly out of date, and the omission is reported on stderr either way.
620 """
621 kept = {}
622 for line in (previous or "").splitlines():
623 if not line.startswith("| **Ansel**"):
624 continue
625 cells = [c.strip() for c in line.strip().strip("|").split("|")]
626 labels = _columns(spec)
627 for label, value in zip(labels, cells[1:]):
628 if value and value not in ("—", "?"):
629 kept[label] = value
630 cols = _columns(spec)
631 live = {}
632 if trees:
633 path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clone_detect.py")
634 spec_cd = importlib.util.spec_from_file_location("clone_detect", path)
635 cdm = importlib.util.module_from_spec(spec_cd)
636 spec_cd.loader.exec_module(cdm)
637 _f, ansel_corpus, _t = cdm.scan(source_dir, 20, 12, True)
638 for label in cols:
639 sub = TREE_DIRS.get(label)
640 if not sub:
641 continue
642 root = os.path.join(trees, sub, "src")
643 if not os.path.isdir(root):
644 sys.stderr.write("readme-metrics: no tree for %s at %s\n" % (label, root))
645 continue
646 _f2, other, _t2 = cdm.scan(root, 20, 12, True)
647 j = 100.0 * len(ansel_corpus & other) / max(1, len(ansel_corpus | other))
648 live[label] = round(j, 1)
649
650 def cell(a, b):
651 if a == b:
652 return "—"
653 if a == "Ansel" or b == "Ansel":
654 other = b if a == "Ansel" else a
655 if other in live:
656 return "%.1f %%" % live[other]
657 return kept.get(other) # keep what the README already had
658 return "%.1f %%" % FROZEN_SIMILARITY.get((a, b), FROZEN_SIMILARITY.get((b, a), 0.0))
659
660 out = ["| | " + " | ".join(cols) + " |",
661 "| --- | " + " | ".join("---:" for _ in cols) + " |"]
662 for i, a in enumerate(cols):
663 cells = []
664 for j, b in enumerate(cols):
665 value = "" if j < i else cell(a, b)
666 if value is None:
667 raise RuntimeError(
668 "no value for %s x %s and none in the README; pass --darktable-trees"
669 % (a, b))
670 cells.append(value)
671 out.append("| **" + a + "** | " + " | ".join(cells) + " |")
672 return "\n".join(out) + "\n"
673
674
675def main():
676 ap = argparse.ArgumentParser(description=__doc__,
677 formatter_class=argparse.RawDescriptionHelpFormatter)
678 ap.add_argument("--readme", default="README.md")
679 ap.add_argument("--doxygen-db",
680 default="doc/api/sqlite3/doxygen_sqlite3.db",
681 help="Doxygen SQLite symbol table, for documentation coverage; "
682 "produced by the docs build's first pass")
683 ap.add_argument("--darktable-trees", default=None,
684 help="directory holding dt38/ dt40/ dt50/ dt56/ Darktable checkouts, "
685 "needed only to refresh the Ansel row of the similarity matrix")
686 ap.add_argument("--check", action="store_true",
687 help="report staleness without writing; non-zero exit if stale")
688 args = ap.parse_args()
689
690 with open(args.readme, encoding="utf-8") as fh:
691 text = fh.read()
692
693 # Collect every component the README refers to, and what it needs from each, so
694 # the API is called once per component rather than once per cell.
695 wanted = {}
696 for m in CELL.finditer(text):
697 comp, metric = component_of(m.group("url"))
698 if not comp:
699 continue
700 needs = wanted.setdefault(comp, set())
701 if m.group("second"):
702 needs.update(("complexity", "ncloc"))
703 else:
704 needs.add(metric)
705
706 sys.stderr.write("readme-metrics: %d components to refresh\n" % len(wanted))
707 measures, failed, moved = {}, [], {}
708 for i, (comp, metrics) in enumerate(sorted(wanted.items()), 1):
709 try:
710 measures[comp] = fetch(comp, metrics)
711 except Exception as exc: # noqa: BLE001 - report, continue
712 found = None
713 if ":" in comp:
714 project, path = comp.split(":", 1)
715 found = relocate(project, path)
716 if found:
717 try:
718 measures[comp] = fetch(found, metrics)
719 moved[comp] = found
720 sys.stderr.write("readme-metrics: %s moved to %s\n"
721 % (comp.split(":")[-1], found.split(":")[-1]))
722 except Exception as exc2: # noqa: BLE001
723 failed.append((comp, str(exc2)))
724 measures[comp] = {}
725 else:
726 failed.append((comp, str(exc)))
727 measures[comp] = {}
728 if i % 20 == 0:
729 sys.stderr.write("readme-metrics: %d/%d\n" % (i, len(wanted)))
730
731 changes = []
732
733 def replace(m):
734 comp, metric = component_of(m.group("url"))
735 have = measures.get(comp, {})
736 old_value, old_second = m.group("value"), m.group("second")
737 # Per-file cells are "complexity / ncloc" by position, whatever the URL says.
738 key = "complexity" if old_second else metric
739 fresh = have.get(key)
740 if fresh is None:
741 return m.group(0)
742 new_value = format_like(old_value, fresh, key)
743 new_second = old_second
744 if old_second:
745 ncloc = have.get("ncloc")
746 if ncloc is not None:
747 new_second = format_like(old_second, ncloc, "ncloc")
748 if new_value != old_value or new_second != old_second:
749 changes.append((comp, key,
750 "%s%s" % (old_value, " / " + old_second if old_second else ""),
751 "%s%s" % (new_value, " / " + new_second if new_second else "")))
752 url = m.group("url")
753 if comp in moved:
754 url = url.replace(urllib.parse.quote(comp, safe=""),
755 urllib.parse.quote(moved[comp], safe=""))
756 url = url.replace(comp, moved[comp])
757 out = "[%s%s](%s)" % (new_value, m.group("pct"), url)
758 if old_second:
759 out += " / " + new_second
760 return out
761
762 updated = CELL.sub(replace, text)
763
764 DOXYGEN_DB[0] = args.doxygen_db
765 builders = {"engine-metrics": engine_table,
766 "engine-complexity": functions_table,
767 "engine-includes": includes_table,
768 "similarity-matrix": lambda sp, prev: similarity_table(
769 sp, args.darktable_trees, prev)}
770
771 def regenerate(m):
772 build = builders.get(m.group("name"))
773 if build is None:
774 return m.group(0)
775 try:
776 needs_prev = build in (builders["similarity-matrix"],
777 builders["engine-metrics"])
778 body = (build(m.group("spec"), m.group("body")) if needs_prev
779 else build(m.group("spec")))
780 except Exception as exc: # noqa: BLE001
781 sys.stderr.write("readme-metrics: %s failed (%s), left as is\n"
782 % (m.group("name"), exc))
783 return m.group(0)
784 if body.strip() != m.group("body").strip():
785 changes.append((m.group("name"), "generated block", "stale", "refreshed"))
786 return m.group("open") + body + m.group("close")
787
788 updated = BLOCK.sub(regenerate, updated)
789
790 for comp, err in failed:
791 sys.stderr.write("readme-metrics: WARNING could not read %s (%s)\n" % (comp, err))
792 for comp, metric, old, new in changes:
793 sys.stderr.write(" %-58s %-22s %s -> %s\n"
794 % (comp.split(":")[-1], metric, old, new))
795 sys.stderr.write("readme-metrics: %d figures changed, %d unreadable\n"
796 % (len(changes), len(failed)))
797
798 if args.check:
799 return 1 if changes else 0
800 if changes:
801 with open(args.readme, "w", encoding="utf-8") as fh:
802 fh.write(updated)
803 sys.stderr.write("readme-metrics: wrote %s\n" % args.readme)
804 return 0
805
806
807if __name__ == "__main__":
808 sys.exit(main())
const float max
engine_table(spec, previous="")
measure_engine(source_dir="src")
measure_functions(source_dir="src")
measure_includes(repo_root=".", source_dir="src")
build_doxygen_db(doxyfile="doc/Doxyfile")
format_like(old, value, metric)
fetch(component, metrics)
similarity_table(spec, trees=None, previous="", source_dir="src")