spritegen-cli 0.1.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.
spritegen/fal.py ADDED
@@ -0,0 +1,228 @@
1
+ """The one place a command turns into money.
2
+
3
+ Every paid call goes through `call`, which does two things before anything is spent:
4
+ it refuses without a key, and it honours a dry run by printing the payload instead of
5
+ sending it. Both live here rather than in each stage so no stage can forget one.
6
+
7
+ `_subscribe` is the seam. It is the only function that touches `fal_client`, which is
8
+ imported inside it: the rest of the CLI — `--help`, `status`, `cost` — must not pay the
9
+ import cost of an HTTP client, and a test must not need one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from urllib.parse import urlparse
17
+
18
+ KEY_VAR = "FAL_KEY"
19
+
20
+ #: Where a result may be fetched from. The endpoint decides what URL comes back, and
21
+ #: `urls_in` walks the whole response for anything under a `url` key — so without this,
22
+ #: a malformed or tampered response naming `file:///…` would have a local file copied
23
+ #: into the asset and passed off as generated art.
24
+ ALLOWED_HOSTS = ("fal.media", "fal.ai", "fal.run", "fal-cdn.com")
25
+
26
+ #: A hard ceiling on a downloaded body. A clip is megabytes; nothing here is hundreds.
27
+ MAX_BYTES = 256 * 1024 * 1024
28
+
29
+ TIMEOUT = 600
30
+
31
+
32
+ class Untrusted(ValueError):
33
+ """A URL the endpoint returned that this tool will not fetch."""
34
+
35
+
36
+ def _allowed_names() -> tuple[str, ...]:
37
+ from . import settings
38
+
39
+ return ALLOWED_HOSTS + settings.load().extra_hosts()
40
+
41
+
42
+ def _bare_host(url) -> str:
43
+ """The hostname httpx will connect to, lowered, with no port and no brackets."""
44
+ return (url.host or "").lower()
45
+
46
+
47
+ def check_url(url: str, allowed: tuple[str, ...] | None = None) -> str:
48
+ r"""The URL, if it is one this tool will fetch — R2.8.
49
+
50
+ `allowed` is the host set to hold it to; without one it is fal's, extended by
51
+ `SPRITEGEN_ALLOWED_HOSTS`. It is a parameter because this is not the only place that
52
+ writes bytes from the network to disk — the weight cache does too, from a different
53
+ set of hosts, and it needs every one of the checks here rather than its own worse
54
+ copy of two of them.
55
+
56
+ The host is read from `httpx.URL`, which is the parser that opens the connection,
57
+ and it is required to agree with `urlparse` besides. **Any authority carrying
58
+ userinfo is refused outright**, and that is what catches the crafted case: fal's
59
+ links never need userinfo, and a URL like `https://evil.com\@fal.media/x` puts the
60
+ part a reader's eye stops at into the userinfo instead of the host.
61
+ """
62
+ import httpx
63
+
64
+ parsed = urlparse(url)
65
+ if parsed.scheme != "https":
66
+ raise Untrusted(f"refusing to fetch {url!r}: only https")
67
+
68
+ connects = httpx.URL(url)
69
+ if connects.userinfo or "@" in (parsed.netloc or ""):
70
+ raise Untrusted(f"refusing to fetch {url!r}: the authority carries userinfo")
71
+
72
+ host = _bare_host(connects)
73
+ if host != (parsed.hostname or "").lower():
74
+ raise Untrusted(
75
+ f"refusing to fetch {url!r}: it parses as {parsed.hostname!r} but connects "
76
+ f"to {host!r}"
77
+ )
78
+
79
+ extensible = allowed is None
80
+ allowed = _allowed_names() if extensible else allowed
81
+ if not any(host == name or host.endswith(f".{name}") for name in allowed):
82
+ hint = " Set SPRITEGEN_ALLOWED_HOSTS to add one." if extensible else ""
83
+ raise Untrusted(f"refusing to fetch {url!r}: only {', '.join(allowed)}.{hint}")
84
+ return url
85
+
86
+
87
+ #: How many redirects a fetch will follow before giving up.
88
+ MAX_HOPS = 5
89
+
90
+
91
+ def upload(path: Path) -> str:
92
+ """Put a local file in fal's storage and return the URL that reaches it.
93
+
94
+ That URL is standing access to the file for anyone holding it, which is why
95
+ `ledger.redact` keeps it out of the recorded payload.
96
+ """
97
+ import fal_client
98
+
99
+ return fal_client.upload_file(str(path))
100
+
101
+
102
+ def download(url: str, dest: Path, allowed: tuple[str, ...] | None = None) -> Path:
103
+ """Fetch into `dest`, refusing anything this tool should not fetch.
104
+
105
+ **The redirect chain is walked here rather than inside the client.** Following it
106
+ automatically means a live request has already gone to every intermediate host by the
107
+ time the call returns, so checking where it landed checks nothing about the middle.
108
+ `follow_redirects=False` plus `response.next_request` makes each hop a decision this
109
+ function gets to refuse before it happens.
110
+
111
+ The body streams in chunks against a ceiling rather than being read whole, so an
112
+ oversized response fails instead of being held in memory — and the partial file is
113
+ removed, because a truncated image that `exists()` accepts is worse than none.
114
+
115
+ `allowed` narrows the host set for a caller that is not fetching a fal result — the
116
+ weight cache passes its own. Nothing else about the fetch differs, which is why it is
117
+ this function with a parameter and not a second one.
118
+ """
119
+ import httpx
120
+
121
+ check_url(url, allowed)
122
+ dest.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ with httpx.Client(follow_redirects=False, timeout=TIMEOUT) as client:
125
+ request = client.build_request("GET", url)
126
+ for _ in range(MAX_HOPS + 1):
127
+ with client.send(request, stream=True) as response:
128
+ hop = response.next_request
129
+ if hop is not None:
130
+ check_url(str(hop.url), allowed)
131
+ request = hop
132
+ continue
133
+
134
+ response.raise_for_status()
135
+ size = 0
136
+ try:
137
+ with dest.open("wb") as handle:
138
+ for chunk in response.iter_bytes(1 << 20):
139
+ size += len(chunk)
140
+ if size > MAX_BYTES:
141
+ raise Untrusted(
142
+ f"{url} is over the {MAX_BYTES:,} byte ceiling"
143
+ )
144
+ handle.write(chunk)
145
+ except BaseException:
146
+ dest.unlink(missing_ok=True)
147
+ raise
148
+ return dest
149
+
150
+ raise Untrusted(f"{url} redirected more than {MAX_HOPS} times")
151
+
152
+
153
+ class MissingKey(ValueError):
154
+ """No usable `FAL_KEY`.
155
+
156
+ A ValueError so the CLI turns it into an exit code and a line on stderr: an absent
157
+ key is a thing to go fix, not a bug to report.
158
+ """
159
+
160
+
161
+ def require_key() -> str:
162
+ """The key, or an error naming what to set — R2.2.
163
+
164
+ Blank counts as absent. An empty environment variable is how a key goes missing in
165
+ practice, and letting it through only moves the failure to the endpoint, after the
166
+ request was built.
167
+ """
168
+ from . import settings
169
+
170
+ key = settings.load().fal_key or ""
171
+ if not key.strip():
172
+ raise MissingKey(f"{KEY_VAR} is not set; a paid call needs it")
173
+ return key
174
+
175
+
176
+ def show(endpoint: str, payload: dict) -> None:
177
+ """Print the call that would be made — R2.3.
178
+
179
+ The payload is printed as it would be sent, not summarised: the point of a dry run
180
+ is to check the thing itself, and a summary is where the difference hides.
181
+ """
182
+ print(f"dry run: {endpoint}")
183
+ print(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
184
+
185
+
186
+ def _subscribe(endpoint: str, payload: dict) -> dict:
187
+ """The call itself. Patched out in tests; nothing else imports `fal_client`."""
188
+ import fal_client
189
+
190
+ return fal_client.subscribe(endpoint, arguments=payload, with_logs=False)
191
+
192
+
193
+ def call(endpoint: str, payload: dict, *, dry_run: bool = False) -> dict:
194
+ """Run one paid call, or show what it would be — R2.2, R2.3.
195
+
196
+ A dry run returns before the key is checked, on purpose: it issues no paid call, so
197
+ a payload can be inspected on a machine that has no credentials at all.
198
+ """
199
+ if dry_run:
200
+ show(endpoint, payload)
201
+ return {"dry_run": True, "endpoint": endpoint, "payload": payload}
202
+
203
+ require_key()
204
+ return _subscribe(endpoint, payload)
205
+
206
+
207
+ def urls_in(result: dict) -> list[str]:
208
+ """Every URL an endpoint returned, in the order found.
209
+
210
+ The endpoints disagree about where they put it — `video.url`, `images[].url`,
211
+ `url` — so this walks the result instead of encoding one shape per endpoint and
212
+ getting it wrong for the next one.
213
+ """
214
+ found: list[str] = []
215
+
216
+ def walk(node) -> None:
217
+ if isinstance(node, dict):
218
+ for key, value in node.items():
219
+ if key == "url" and isinstance(value, str):
220
+ found.append(value)
221
+ else:
222
+ walk(value)
223
+ elif isinstance(node, list):
224
+ for item in node:
225
+ walk(item)
226
+
227
+ walk(result)
228
+ return found
spritegen/imaging.py ADDED
@@ -0,0 +1,219 @@
1
+ """The local, unpaid transforms — chroma cutting and pixel-grid recovery.
2
+
3
+ Ported from the `gen_image.py` this grew out of, and pinned by `tests/parity`. Nothing here
4
+ calls an endpoint: given the same bytes in, these produce the same bytes out, which is
5
+ why they can be re-run for free after a paid call and why the fixtures can hold them to
6
+ the behaviour the scripts had.
7
+
8
+ Two stages use them, which is the reason they are not inside either: `anchor` cuts the
9
+ chroma off what it generated, and `board` recovers the grid across a whole board.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+
17
+ def parse_chroma(text: str) -> tuple[int, int, int]:
18
+ """`00b140` to `(0, 177, 64)`."""
19
+ raw = text.strip().lstrip("#")
20
+ if len(raw) != 6:
21
+ raise ValueError("a chroma key is 6 hex digits, e.g. 00b140")
22
+ try:
23
+ value = int(raw, 16)
24
+ except ValueError:
25
+ raise ValueError(f"chroma key {text!r} is not hex") from None
26
+ return (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF
27
+
28
+
29
+ def _near_transparent(alpha, radius: int):
30
+ """Opaque pixels within `radius` of a transparent one."""
31
+ import numpy as np
32
+
33
+ near = alpha < 1.0
34
+ grown = near.copy()
35
+ for _ in range(radius):
36
+ padded = np.pad(grown, 1, constant_values=False)
37
+ grown = padded[:-2, 1:-1] | padded[2:, 1:-1] | padded[1:-1, :-2] | padded[1:-1, 2:] | grown
38
+ return grown & (alpha > 0.0)
39
+
40
+
41
+ def cut_chroma(
42
+ path: Path,
43
+ key: tuple[int, int, int],
44
+ tol: float,
45
+ feather: float,
46
+ despill: bool,
47
+ despill_radius: int,
48
+ mode: str = "flood",
49
+ ) -> dict:
50
+ """Replace a chroma background with alpha, in place, and report what it measured.
51
+
52
+ `flood` counts as background only what the border can reach, so a green the artist
53
+ put inside the figure survives. `global` takes every pixel near the key wherever it
54
+ is, which is the only thing that clears islanded chroma — the gap between an arm
55
+ and the body, the space through hair — at the cost of eating art that is
56
+ legitimately that colour.
57
+ """
58
+ import numpy as np
59
+ from PIL import Image, ImageDraw
60
+
61
+ rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.float32)
62
+ height, width = rgb.shape[:2]
63
+
64
+ distance = np.sqrt(((rgb - np.array(key, dtype=np.float32)) ** 2).sum(axis=2))
65
+
66
+ # Background candidate: near the key, with the feather band still inside.
67
+ candidate = distance <= (tol + feather)
68
+
69
+ if mode == "global":
70
+ reached = candidate
71
+ else:
72
+ # A one-pixel frame that is guaranteed to be a candidate gives the flood a
73
+ # single starting point, whatever the art does at the edge.
74
+ flood = Image.new("L", (width + 2, height + 2), 255)
75
+ flood.paste(Image.fromarray(np.where(candidate, 255, 0).astype(np.uint8)), (1, 1))
76
+ ImageDraw.floodfill(flood, (0, 0), 128, thresh=0)
77
+ reached = np.asarray(flood, dtype=np.uint8)[1 : height + 1, 1 : width + 1] == 128
78
+
79
+ # Inside the reached background, alpha ramps 0 to 1 across the feather band.
80
+ if feather > 0:
81
+ ramp = np.clip((distance - tol) / feather, 0.0, 1.0)
82
+ else:
83
+ ramp = (distance > tol).astype(np.float32)
84
+ alpha = np.where(reached, ramp, 1.0)
85
+
86
+ if despill and despill_radius > 0:
87
+ # Leaked key lives in two places: along the contour, where the chroma painted
88
+ # over the edge, and islanded inside the silhouette, where the border flood
89
+ # never arrives and the pixel stays opaque and green. Both go, because without
90
+ # the second a downscale spreads each island over a whole block.
91
+ dominant = int(np.argmax(key))
92
+ others = [channel for channel in range(3) if channel != dominant]
93
+ ceiling = np.maximum(rgb[:, :, others[0]], rgb[:, :, others[1]])
94
+ target = _near_transparent(alpha, despill_radius) | (~reached & candidate)
95
+ spilled = target & (alpha > 0.0) & (rgb[:, :, dominant] > ceiling)
96
+ rgb[:, :, dominant] = np.where(spilled, ceiling, rgb[:, :, dominant])
97
+ spill_px = int(spilled.sum())
98
+ else:
99
+ spill_px = 0
100
+
101
+ out = np.dstack([rgb, alpha * 255.0]).round().clip(0, 255).astype(np.uint8)
102
+ Image.fromarray(out, mode="RGBA").save(path)
103
+
104
+ return {
105
+ "transparent_px": int((alpha <= 0.0).sum()),
106
+ "partial_px": int(((alpha > 0.0) & (alpha < 1.0)).sum()),
107
+ "opaque_px": int((alpha >= 1.0).sum()),
108
+ "despilled_px": spill_px,
109
+ }
110
+
111
+
112
+ #: Seeds OpenCV's RNG before pixelfixer reconstructs. See `to_pixelart`.
113
+ PIXELART_SEED = 0
114
+
115
+ INSTALL_PIXELFIXER = (
116
+ "recovering a native pixel grid needs pixelfixer, which is on no package registry: "
117
+ 'pip install "pixelfixer @ '
118
+ 'git+https://github.com/Retro-Diffusion/pixel-art-fixer.git#subdirectory=python"'
119
+ )
120
+
121
+
122
+ def require_pixelfixer():
123
+ """`pixelfixer`, or an error naming the one command that fixes it.
124
+
125
+ Not a declared dependency, and it cannot be one: it is on no registry, and a direct
126
+ URL in `dependencies` or an extra makes a distribution PyPI refuses — so declaring it
127
+ would trade an uninstallable package for an unpublishable one.
128
+
129
+ It reaches exactly two commands, `anchor --pixelart` and `board --art pixel-art`, so
130
+ the tool without it is the whole tool minus one art direction. That is the same shape
131
+ `torch` and `rembg` have, and the third time this project has met it.
132
+ """
133
+ try:
134
+ import pixelfixer
135
+ except ModuleNotFoundError as exc:
136
+ raise NotImplementedError(INSTALL_PIXELFIXER) from exc
137
+ return pixelfixer
138
+
139
+
140
+ def pixelfixer_available() -> bool:
141
+ """Whether the grid recovery could run. Used to report, never to decide silently."""
142
+ try:
143
+ require_pixelfixer()
144
+ except NotImplementedError:
145
+ return False
146
+ return True
147
+
148
+
149
+ def to_pixelart(path: Path, colors: int, scale: int, seed: int = PIXELART_SEED) -> dict:
150
+ """Recover the native pixel grid the art already has and rewrite the image on it.
151
+
152
+ The detector votes on a cell from the art itself. It does not invent one: on a
153
+ smooth painting with no blocks in it the vote returns nonsense, which is why the
154
+ prompt has to ask for flat blocks and why `cols` and `rows` come back in the report
155
+ — they are how you check a grid was there at all.
156
+
157
+ **Seeded, unlike the script this came from.** pixelfixer reconstructs each block by
158
+ k-means on OpenCV's global RNG, so the original ran three times on one input and
159
+ produced three different images — same grid, same colour count, different pixels.
160
+ Seeding costs nothing and is what makes a re-run comparable to the run before it,
161
+ and a paid call's output worth re-deriving instead of re-buying.
162
+ """
163
+ require_pixelfixer()
164
+
165
+ import cv2
166
+ import numpy as np
167
+ from PIL import Image
168
+ from pixelfixer import detect
169
+ from pixelfixer.reconstruct import reconstruct
170
+
171
+ cv2.setRNGSeed(seed)
172
+ rgba = np.array(Image.open(path).convert("RGBA"))
173
+ found = detect(rgba)
174
+ out = reconstruct(rgba, found["step_x"], found["step_y"], found["cols"], found["rows"])
175
+ image = Image.fromarray(out)
176
+
177
+ if colors:
178
+ # No dither: it scatters half-tone noise across blocks the grid just flattened.
179
+ quantized = image.convert("RGB").quantize(colors=colors, dither=Image.Dither.NONE)
180
+ merged = quantized.convert("RGBA")
181
+ merged.putalpha(image.getchannel("A"))
182
+ image = merged
183
+
184
+ native = image.size
185
+ if scale > 1:
186
+ image = image.resize((native[0] * scale, native[1] * scale), Image.NEAREST)
187
+
188
+ image.save(path)
189
+ used = len({tuple(pixel) for pixel in np.array(image.convert("RGBA")).reshape(-1, 4)})
190
+ return {
191
+ "cols": int(found["cols"]),
192
+ "rows": int(found["rows"]),
193
+ "step_x": round(float(found["step_x"]), 3),
194
+ "step_y": round(float(found["step_y"]), 3),
195
+ "native": f"{native[0]}x{native[1]}",
196
+ "scale": scale,
197
+ "seed": seed,
198
+ "colours": used,
199
+ }
200
+
201
+
202
+ def measure_alpha(path: Path) -> dict:
203
+ """What the alpha channel of a cut image looks like.
204
+
205
+ Ported from `bg_remove.py`'s `measure`. It is how you tell a matte that worked from
206
+ one that returned the picture untouched: an image with no transparent pixels came
207
+ back opaque, whatever the endpoint reported.
208
+ """
209
+ import numpy as np
210
+ from PIL import Image
211
+
212
+ image = Image.open(path).convert("RGBA")
213
+ alpha = np.asarray(image)[:, :, 3]
214
+ return {
215
+ "size": "x".join(str(value) for value in image.size),
216
+ "transparent_px": int((alpha == 0).sum()),
217
+ "partial_px": int(((alpha > 0) & (alpha < 255)).sum()),
218
+ "opaque_px": int((alpha == 255).sum()),
219
+ }
spritegen/ledger.py ADDED
@@ -0,0 +1,143 @@
1
+ """One line per paid call, appended, never rewritten.
2
+
3
+ This is the only record that a call happened and what it cost in artifacts. It is
4
+ append-only JSONL rather than a document for one reason: a run that dies halfway has
5
+ still spent the money, and a format that has to be re-serialised whole can lose the
6
+ line that says so.
7
+
8
+ What goes in a line is fixed by R2.1 — the endpoint, the exact payload sent, the URLs
9
+ that came back, and the sha256 of every file written. That is enough to answer "was
10
+ this file paid for, and by which call" months later, and enough for `cost` to report
11
+ an asset whose outputs were long since deleted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ CHUNK = 1 << 20
22
+
23
+ #: What an uploaded file's URL is replaced by. `fal_client.upload_file` returns standing
24
+ #: access to that file for anyone holding the link, so recording it verbatim would turn
25
+ #: this ledger into a key to every reference image, anchor and clip ever sent — art that
26
+ #: has not shipped included. What the record needs is that a file went up and which one,
27
+ #: not a way back to it.
28
+ REDACTED = "<uploaded>"
29
+
30
+ #: Payload keys that carry one, or a list of them.
31
+ UPLOAD_KEYS = frozenset(
32
+ {
33
+ "image_url",
34
+ "image_urls",
35
+ "video_url",
36
+ "reference_image_urls",
37
+ "end_image_url",
38
+ "tail_image_url",
39
+ }
40
+ )
41
+
42
+
43
+ def redact(payload: dict) -> dict:
44
+ """The payload as it is recorded — everything, minus the upload URLs.
45
+
46
+ R2.1 asks for the exact payload sent, and this is the one deliberate exception:
47
+ every other field is what was asked for, and these are handles to files that are
48
+ already on this disk anyway.
49
+ """
50
+ out = {}
51
+ for key, value in payload.items():
52
+ if key not in UPLOAD_KEYS:
53
+ out[key] = value
54
+ elif isinstance(value, list):
55
+ out[key] = [REDACTED] * len(value)
56
+ else:
57
+ out[key] = REDACTED
58
+ return out
59
+
60
+
61
+ def sha256_of(path: Path) -> str:
62
+ """The file's digest, read in chunks — a board is tens of megabytes."""
63
+ digest = hashlib.sha256()
64
+ with path.open("rb") as handle:
65
+ while chunk := handle.read(CHUNK):
66
+ digest.update(chunk)
67
+ return digest.hexdigest()
68
+
69
+
70
+ def describe(path: Path) -> dict:
71
+ """One written file, as it appears in a ledger line."""
72
+ return {"path": path.name, "bytes": path.stat().st_size, "sha256": sha256_of(path)}
73
+
74
+
75
+ def entry(
76
+ *,
77
+ stage: str,
78
+ endpoint: str,
79
+ payload: dict,
80
+ urls: list[str] | None = None,
81
+ files: list[Path] | None = None,
82
+ at: str | None = None,
83
+ ) -> dict:
84
+ """A ledger line, built but not written.
85
+
86
+ Separate from `append` so a dry run can show exactly what would be recorded, and so
87
+ a test does not need a filesystem to check the shape.
88
+ """
89
+ return {
90
+ "at": at or datetime.now(UTC).isoformat(timespec="seconds"),
91
+ "stage": stage,
92
+ "endpoint": endpoint,
93
+ "payload": redact(payload),
94
+ "urls": list(urls or []),
95
+ "files": [describe(path) for path in files or []],
96
+ }
97
+
98
+
99
+ def append(path: Path, line: dict) -> dict:
100
+ """Append one line, creating the ledger if this is the asset's first paid call."""
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ with path.open("a", encoding="utf-8") as handle:
103
+ handle.write(json.dumps(line, ensure_ascii=False) + "\n")
104
+ return line
105
+
106
+
107
+ def read(path: Path) -> list[dict]:
108
+ """Every line, in order.
109
+
110
+ A blank line is skipped and a malformed one is fatal: the ledger is the record of
111
+ spending, and quietly dropping a line it could not parse would understate it.
112
+ """
113
+ if not path.exists():
114
+ return []
115
+ lines = []
116
+ for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
117
+ if not raw.strip():
118
+ continue
119
+ try:
120
+ lines.append(json.loads(raw))
121
+ except json.JSONDecodeError as exc:
122
+ raise ValueError(f"{path}:{number} is not a ledger line: {exc}") from exc
123
+ return lines
124
+
125
+
126
+ def summarise(lines: list[dict]) -> list[dict]:
127
+ """The ledger folded to one row per stage and endpoint, in first-call order — R2.4.
128
+
129
+ Grouped by both because a stage can be re-run against a different endpoint — that
130
+ is the whole point of having three motion endpoints — and a row that merged them
131
+ would hide which one the money went to.
132
+ """
133
+ rows: dict[tuple[str, str], dict] = {}
134
+ for line in lines:
135
+ key = (line.get("stage", "?"), line.get("endpoint", "?"))
136
+ row = rows.setdefault(
137
+ key, {"stage": key[0], "endpoint": key[1], "calls": 0, "files": 0, "bytes": 0}
138
+ )
139
+ row["calls"] += 1
140
+ for written in line.get("files", []):
141
+ row["files"] += 1
142
+ row["bytes"] += int(written.get("bytes", 0))
143
+ return list(rows.values())