pezlie 0.1.0__tar.gz
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.
- pezlie-0.1.0/.gitignore +12 -0
- pezlie-0.1.0/LICENSE +21 -0
- pezlie-0.1.0/PKG-INFO +79 -0
- pezlie-0.1.0/README.md +61 -0
- pezlie-0.1.0/pyproject.toml +27 -0
- pezlie-0.1.0/src/pezlie/__init__.py +1 -0
- pezlie-0.1.0/src/pezlie/bake.py +132 -0
- pezlie-0.1.0/src/pezlie/batch.py +43 -0
- pezlie-0.1.0/src/pezlie/lock.py +30 -0
- pezlie-0.1.0/src/pezlie/routes.py +90 -0
- pezlie-0.1.0/src/pezlie/sheet.py +52 -0
- pezlie-0.1.0/src/pezlie/sidecar.py +32 -0
- pezlie-0.1.0/tests/conftest.py +11 -0
- pezlie-0.1.0/tests/test_bake.py +119 -0
- pezlie-0.1.0/tests/test_batch.py +49 -0
- pezlie-0.1.0/tests/test_lock.py +23 -0
- pezlie-0.1.0/tests/test_parity.py +65 -0
- pezlie-0.1.0/tests/test_routes.py +90 -0
- pezlie-0.1.0/tests/test_sheet.py +58 -0
pezlie-0.1.0/.gitignore
ADDED
pezlie-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orochi235
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pezlie-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pezlie
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bake per-item renders into a mip chain of sprite sheets, and serve them
|
|
5
|
+
Project-URL: Repository, https://github.com/orochi235/pezlie
|
|
6
|
+
Author: orochi235
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: pillow>=10
|
|
11
|
+
Provides-Extra: routes
|
|
12
|
+
Requires-Dist: fastapi>=0.110; extra == 'routes'
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: fastapi>=0.110; extra == 'test'
|
|
15
|
+
Requires-Dist: httpx>=0.27; extra == 'test'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# bakery
|
|
20
|
+
|
|
21
|
+
Bakes one slot's renders into the wall's mip chain, and serves it. A slot is one
|
|
22
|
+
set of renders over the whole corpus; each item gets a 128px loose tile, and
|
|
23
|
+
every item's 8px and 32px tiles are composed onto one sheet per level, with a
|
|
24
|
+
JSON manifest beside each sheet.
|
|
25
|
+
|
|
26
|
+
It knows nothing about what the items are. brick-icons is the first host.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pezlie
|
|
30
|
+
pip install 'pezlie[routes]' # with the FastAPI routes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It needs `resvg` on `PATH` for SVG renders. The wall that draws what it bakes is
|
|
34
|
+
[`pezlie` on npm](https://www.npmjs.com/package/pezlie).
|
|
35
|
+
|
|
36
|
+
## What a host supplies
|
|
37
|
+
|
|
38
|
+
- **The renders for a slot** — `Render(id, path, sha)` per item that has one.
|
|
39
|
+
SVG goes through `resvg`; anything else is opened with Pillow.
|
|
40
|
+
- **The full order** — every item id, drawn or not. A cell's index on a sheet
|
|
41
|
+
is its position in this list, so the host must send the same order to
|
|
42
|
+
`compose` and to the wall's feed, or every sprite lands off by one.
|
|
43
|
+
`compose` refuses a list that repeats an id.
|
|
44
|
+
- **For the routes** — a slot-name-to-directory lookup, a lookup naming the
|
|
45
|
+
render file for an item in a slot, and the root those files must sit under.
|
|
46
|
+
|
|
47
|
+
## What it promises
|
|
48
|
+
|
|
49
|
+
- **Ink, never ground.** Tiles and sheets are transparent where there is no
|
|
50
|
+
ink. The wall paints the ground.
|
|
51
|
+
- **One writer per slot.** `bake_item`, `compose` and `bake_slot` hold a lock
|
|
52
|
+
on `<slot>/.bake.lock` and raise `BakeInProgress` instead of interleaving.
|
|
53
|
+
- **Freshness by sha.** An item whose sha matches `baked.json`, with tiles in
|
|
54
|
+
the current format, is not rebaked. The manifest carries the sha map, so the
|
|
55
|
+
wall can tell a stale cell from a fresh one.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pezlie.batch import Render, bake_slot
|
|
59
|
+
bake_slot(renders, out="thumbs/occt", order=all_ids)
|
|
60
|
+
|
|
61
|
+
from pezlie.routes import render_router, thumbs_router
|
|
62
|
+
app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
|
|
63
|
+
app.include_router(render_router(render_file, root, known_slot), prefix="/api/corpus/render")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Develop
|
|
67
|
+
|
|
68
|
+
Needs `resvg` on `PATH`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv venv bakery/.venv --python 3.14
|
|
72
|
+
uv pip install --python bakery/.venv/bin/python -e 'bakery[test]'
|
|
73
|
+
bakery/.venv/bin/python -m pytest bakery -q
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`tests/test_parity.py` bakes the same inputs through brick-icons'
|
|
77
|
+
`brick_icons/thumbs.py` and through this package and compares bytes. It looks
|
|
78
|
+
for a brick-icons checkout beside pezlie, or at `$BRICK_ICONS`, and skips
|
|
79
|
+
without one.
|
pezlie-0.1.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# bakery
|
|
2
|
+
|
|
3
|
+
Bakes one slot's renders into the wall's mip chain, and serves it. A slot is one
|
|
4
|
+
set of renders over the whole corpus; each item gets a 128px loose tile, and
|
|
5
|
+
every item's 8px and 32px tiles are composed onto one sheet per level, with a
|
|
6
|
+
JSON manifest beside each sheet.
|
|
7
|
+
|
|
8
|
+
It knows nothing about what the items are. brick-icons is the first host.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install pezlie
|
|
12
|
+
pip install 'pezlie[routes]' # with the FastAPI routes
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It needs `resvg` on `PATH` for SVG renders. The wall that draws what it bakes is
|
|
16
|
+
[`pezlie` on npm](https://www.npmjs.com/package/pezlie).
|
|
17
|
+
|
|
18
|
+
## What a host supplies
|
|
19
|
+
|
|
20
|
+
- **The renders for a slot** — `Render(id, path, sha)` per item that has one.
|
|
21
|
+
SVG goes through `resvg`; anything else is opened with Pillow.
|
|
22
|
+
- **The full order** — every item id, drawn or not. A cell's index on a sheet
|
|
23
|
+
is its position in this list, so the host must send the same order to
|
|
24
|
+
`compose` and to the wall's feed, or every sprite lands off by one.
|
|
25
|
+
`compose` refuses a list that repeats an id.
|
|
26
|
+
- **For the routes** — a slot-name-to-directory lookup, a lookup naming the
|
|
27
|
+
render file for an item in a slot, and the root those files must sit under.
|
|
28
|
+
|
|
29
|
+
## What it promises
|
|
30
|
+
|
|
31
|
+
- **Ink, never ground.** Tiles and sheets are transparent where there is no
|
|
32
|
+
ink. The wall paints the ground.
|
|
33
|
+
- **One writer per slot.** `bake_item`, `compose` and `bake_slot` hold a lock
|
|
34
|
+
on `<slot>/.bake.lock` and raise `BakeInProgress` instead of interleaving.
|
|
35
|
+
- **Freshness by sha.** An item whose sha matches `baked.json`, with tiles in
|
|
36
|
+
the current format, is not rebaked. The manifest carries the sha map, so the
|
|
37
|
+
wall can tell a stale cell from a fresh one.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from pezlie.batch import Render, bake_slot
|
|
41
|
+
bake_slot(renders, out="thumbs/occt", order=all_ids)
|
|
42
|
+
|
|
43
|
+
from pezlie.routes import render_router, thumbs_router
|
|
44
|
+
app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
|
|
45
|
+
app.include_router(render_router(render_file, root, known_slot), prefix="/api/corpus/render")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Develop
|
|
49
|
+
|
|
50
|
+
Needs `resvg` on `PATH`.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv venv bakery/.venv --python 3.14
|
|
54
|
+
uv pip install --python bakery/.venv/bin/python -e 'bakery[test]'
|
|
55
|
+
bakery/.venv/bin/python -m pytest bakery -q
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`tests/test_parity.py` bakes the same inputs through brick-icons'
|
|
59
|
+
`brick_icons/thumbs.py` and through this package and compares bytes. It looks
|
|
60
|
+
for a brick-icons checkout beside pezlie, or at `$BRICK_ICONS`, and skips
|
|
61
|
+
without one.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pezlie"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Bake per-item renders into a mip chain of sprite sheets, and serve them"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "orochi235" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = ["pillow>=10"]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/orochi235/pezlie"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
routes = ["fastapi>=0.110"]
|
|
17
|
+
test = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/pezlie"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bake per-item renders into a mip chain of sprite sheets, and serve them."""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Rasterize an item at every level, and compose the levels into sheets.
|
|
2
|
+
|
|
3
|
+
An item's cell is its position in the host's full order, so a render landing
|
|
4
|
+
later fills the cell it already had rather than renumbering the sheet.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from pezlie.lock import slot_lock
|
|
15
|
+
from pezlie.sheet import GROUND, LEVELS, LOOSE_LEVEL, SHEET_LEVELS, geometry
|
|
16
|
+
from pezlie.sidecar import BAKED, baked_shas, write_json
|
|
17
|
+
|
|
18
|
+
#: WebP q90 halves sheet-32 against PNG, and the wall fetches it on every open.
|
|
19
|
+
THUMB_EXT = "webp"
|
|
20
|
+
THUMB_SAVE = {"format": "WEBP", "quality": 90, "method": 4}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bake_item(item_id: str, render: Path | str, out: Path | str,
|
|
24
|
+
sha: str) -> list[int]:
|
|
25
|
+
"""Rasterize one item at every level. Returns the levels written; an
|
|
26
|
+
unchanged sha writes nothing."""
|
|
27
|
+
out = Path(out)
|
|
28
|
+
with slot_lock(out):
|
|
29
|
+
return _bake_item(item_id, Path(render), out, sha)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compose(out: Path | str, order: list[str]) -> list[Path]:
|
|
33
|
+
"""Paste every baked tile onto its sheet at its index in `order`.
|
|
34
|
+
|
|
35
|
+
`order` is every item, drawn or not: an index is a position in the corpus.
|
|
36
|
+
"""
|
|
37
|
+
out = Path(out)
|
|
38
|
+
with slot_lock(out):
|
|
39
|
+
return _compose(out, order)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bake_item(item_id: str, render: Path, out: Path, sha: str) -> list[int]:
|
|
43
|
+
shas = baked_shas(out)
|
|
44
|
+
# The sha covers the render, not the encoding: a tile in an old format is a miss.
|
|
45
|
+
if shas.get(item_id) == sha and all(
|
|
46
|
+
(out / str(level) / f"{item_id}.{THUMB_EXT}").is_file()
|
|
47
|
+
for level in LEVELS):
|
|
48
|
+
return []
|
|
49
|
+
drawn = _drawn(item_id, render, out)
|
|
50
|
+
for level in LEVELS:
|
|
51
|
+
path = out / str(level) / f"{item_id}.{THUMB_EXT}"
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
_square(drawn, level).save(path, **THUMB_SAVE)
|
|
54
|
+
write_json(out / BAKED, {**shas, item_id: sha})
|
|
55
|
+
return list(LEVELS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _compose(out: Path, order: list[str]) -> list[Path]:
|
|
59
|
+
repeated = [item_id for item_id, n in Counter(order).items() if n > 1]
|
|
60
|
+
if repeated:
|
|
61
|
+
raise ValueError(f"order repeats {len(repeated)} id(s), first {repeated[:3]}: "
|
|
62
|
+
"every cell after one lands a place off")
|
|
63
|
+
shas = baked_shas(out)
|
|
64
|
+
written = []
|
|
65
|
+
for level in SHEET_LEVELS:
|
|
66
|
+
g = geometry(len(order), level)
|
|
67
|
+
sheet = Image.new("RGBA", (g.size, g.size), (0, 0, 0, 0))
|
|
68
|
+
for index, item_id in enumerate(order):
|
|
69
|
+
tile = out / str(level) / f"{item_id}.{THUMB_EXT}"
|
|
70
|
+
if not tile.is_file():
|
|
71
|
+
continue
|
|
72
|
+
with Image.open(tile) as img:
|
|
73
|
+
cell = img.convert("RGBA")
|
|
74
|
+
x0, y0, _, _ = g.cell_box(index)
|
|
75
|
+
sheet.paste(cell, (x0, y0))
|
|
76
|
+
if g.gutter:
|
|
77
|
+
_replicate_edges(sheet, cell, x0, y0, g.gutter)
|
|
78
|
+
path = out / f"sheet-{level}.{THUMB_EXT}"
|
|
79
|
+
sheet.save(path, **THUMB_SAVE)
|
|
80
|
+
write_json(out / f"sheet-{level}.json", {
|
|
81
|
+
"level": level, "gutter": g.gutter, "pitch": g.pitch,
|
|
82
|
+
"cols": g.cols, "rows": g.rows, "count": len(order),
|
|
83
|
+
"size": g.size, "baked": shas,
|
|
84
|
+
})
|
|
85
|
+
written.append(path)
|
|
86
|
+
return written
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _drawn(item_id: str, render: Path, out: Path) -> Image.Image:
|
|
90
|
+
"""The render at `LOOSE_LEVEL` wide, as RGBA.
|
|
91
|
+
|
|
92
|
+
resvg has no letterbox flag, and passing both -w and -h stretches, so it is
|
|
93
|
+
asked for a width and `_square` pads. A raster render skips resvg, which
|
|
94
|
+
rejects one as "not an UTF-8 encoding" -- reading like a corrupt file.
|
|
95
|
+
"""
|
|
96
|
+
if render.suffix.lower() != ".svg":
|
|
97
|
+
with Image.open(render) as img:
|
|
98
|
+
return img.convert("RGBA")
|
|
99
|
+
wide = out / f".{item_id}.wide.png"
|
|
100
|
+
proc = subprocess.run(
|
|
101
|
+
["resvg", "--width", str(LOOSE_LEVEL), str(render), str(wide)],
|
|
102
|
+
capture_output=True, text=True)
|
|
103
|
+
if proc.returncode != 0 or not wide.is_file():
|
|
104
|
+
raise RuntimeError(f"resvg failed on {item_id}: "
|
|
105
|
+
f"{(proc.stderr or proc.stdout).strip()[:200]}")
|
|
106
|
+
try:
|
|
107
|
+
with Image.open(wide) as img:
|
|
108
|
+
return img.convert("RGBA")
|
|
109
|
+
finally:
|
|
110
|
+
wide.unlink(missing_ok=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _replicate_edges(sheet: Image.Image, cell: Image.Image,
|
|
114
|
+
x0: int, y0: int, gutter: int) -> None:
|
|
115
|
+
"""Pad a cell with its own edge pixels, or each mip reduction averages it
|
|
116
|
+
against its neighbor and the wall reads as halos."""
|
|
117
|
+
w, h = cell.size
|
|
118
|
+
for d in range(1, gutter + 1):
|
|
119
|
+
sheet.paste(cell.crop((0, 0, w, 1)), (x0, y0 - d))
|
|
120
|
+
sheet.paste(cell.crop((0, h - 1, w, h)), (x0, y0 + h + d - 1))
|
|
121
|
+
sheet.paste(cell.crop((0, 0, 1, h)), (x0 - d, y0))
|
|
122
|
+
sheet.paste(cell.crop((w - 1, 0, w, h)), (x0 + w + d - 1, y0))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _square(drawn: Image.Image, level: int) -> Image.Image:
|
|
126
|
+
"""Fit a render, centered, inside a `GROUND` square of `level` px."""
|
|
127
|
+
scale = level / max(drawn.size)
|
|
128
|
+
size = (max(1, round(drawn.width * scale)), max(1, round(drawn.height * scale)))
|
|
129
|
+
cell = Image.new("RGBA", (level, level), GROUND)
|
|
130
|
+
fitted = drawn.resize(size, Image.LANCZOS)
|
|
131
|
+
cell.paste(fitted, ((level - size[0]) // 2, (level - size[1]) // 2), fitted)
|
|
132
|
+
return cell
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Bake one slot: every render the host has for it, then the sheets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pezlie.bake import _bake_item, _compose
|
|
9
|
+
from pezlie.lock import slot_lock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Render:
|
|
14
|
+
id: str
|
|
15
|
+
path: Path
|
|
16
|
+
sha: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bake_slot(renders: Iterable[Render], out: Path | str, order: list[str],
|
|
20
|
+
log: Callable[[str], None] = print) -> tuple[int, int]:
|
|
21
|
+
"""Returns (baked, total). One line per render as it completes.
|
|
22
|
+
|
|
23
|
+
A render that is missing or will not rasterize is reported and skipped:
|
|
24
|
+
an interrupted fetch leaves zero-byte files, and one of them is not a
|
|
25
|
+
reason to abandon the thousands after it.
|
|
26
|
+
"""
|
|
27
|
+
out, renders = Path(out), list(renders)
|
|
28
|
+
total, baked = len(renders), 0
|
|
29
|
+
with slot_lock(out):
|
|
30
|
+
for i, r in enumerate(renders, 1):
|
|
31
|
+
if not r.path.is_file():
|
|
32
|
+
log(f" {i}/{total} {r.id} MISSING {r.path}")
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
made = _bake_item(r.id, r.path, out, r.sha)
|
|
36
|
+
except Exception as e: # noqa: BLE001
|
|
37
|
+
log(f" {i}/{total} {r.id} UNREADABLE {type(e).__name__}: {e}")
|
|
38
|
+
continue
|
|
39
|
+
baked += bool(made)
|
|
40
|
+
log(f" {i}/{total} {r.id} {'baked' if made else 'fresh'}")
|
|
41
|
+
for path in _compose(out, order):
|
|
42
|
+
log(f" wrote {path}")
|
|
43
|
+
return baked, total
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""One writer per slot. Two bakers over one directory interleave their
|
|
2
|
+
sidecar writes and leave a sheet that matches neither."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LOCK = ".bake.lock"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BakeInProgress(RuntimeError):
|
|
15
|
+
"""Another bake holds the slot. Refused rather than queued: a baker that
|
|
16
|
+
waits would compose a sheet from whatever the other left behind."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def slot_lock(out: Path) -> Iterator[None]:
|
|
21
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
fd = os.open(out / LOCK, os.O_CREAT | os.O_RDWR, 0o644)
|
|
23
|
+
try:
|
|
24
|
+
try:
|
|
25
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
26
|
+
except BlockingIOError:
|
|
27
|
+
raise BakeInProgress(f"another bake holds {out / LOCK}") from None
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
os.close(fd)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Mountable routes for a baked slot and the renders behind it.
|
|
2
|
+
|
|
3
|
+
app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
|
|
4
|
+
app.include_router(render_router(render_file, root, known_slot),
|
|
5
|
+
prefix="/api/corpus/render")
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
15
|
+
|
|
16
|
+
MEDIA_TYPES = {".svg": "image/svg+xml", ".png": "image/png", ".webp": "image/webp"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tile(base: Path, stem: str) -> Path | None:
|
|
20
|
+
"""The file for `stem` in whichever format it was baked: slots baked
|
|
21
|
+
before WebP are PNG, so a request's extension is a name, not a format."""
|
|
22
|
+
for ext in ("webp", "png"):
|
|
23
|
+
path = base / f"{stem}.{ext}"
|
|
24
|
+
if path.is_file():
|
|
25
|
+
return path
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def thumbs_router(slot_dir: Callable[[str], Path | None]) -> APIRouter:
|
|
30
|
+
"""`slot_dir` maps a slot name to its bake directory, or None for a slot
|
|
31
|
+
the host does not have."""
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
def _slot(slot: str) -> Path:
|
|
35
|
+
base = slot_dir(slot)
|
|
36
|
+
if base is None:
|
|
37
|
+
raise HTTPException(400, f"no such slot: {slot}")
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
@router.get("/{slot}/sheet-{level}.{ext}")
|
|
41
|
+
def get_sheet(slot: str, level: int, ext: str):
|
|
42
|
+
base = _slot(slot)
|
|
43
|
+
if ext == "json":
|
|
44
|
+
path = base / f"sheet-{level}.json"
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise HTTPException(404, "no such sheet manifest")
|
|
47
|
+
manifest = json.loads(path.read_text())
|
|
48
|
+
# The image is rewritten at a URL the client never varies, so a
|
|
49
|
+
# browser can hold last week's atlas against this manifest.
|
|
50
|
+
image = _tile(base, f"sheet-{level}")
|
|
51
|
+
if image is not None:
|
|
52
|
+
manifest["version"] = str(int(image.stat().st_mtime))
|
|
53
|
+
return JSONResponse(manifest)
|
|
54
|
+
path = _tile(base, f"sheet-{level}")
|
|
55
|
+
if path is None:
|
|
56
|
+
raise HTTPException(404, "no such sheet")
|
|
57
|
+
return FileResponse(path)
|
|
58
|
+
|
|
59
|
+
@router.get("/{slot}/{level}/{name}")
|
|
60
|
+
def get_tile(slot: str, level: int, name: str):
|
|
61
|
+
if "/" in name or ".." in name or not name.endswith((".png", ".webp")):
|
|
62
|
+
raise HTTPException(400, "bad thumbnail path")
|
|
63
|
+
path = _tile(_slot(slot) / str(level), Path(name).stem)
|
|
64
|
+
if path is None:
|
|
65
|
+
raise HTTPException(404, "no such thumbnail")
|
|
66
|
+
return FileResponse(path)
|
|
67
|
+
|
|
68
|
+
return router
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_router(render_file: Callable[[str, str], Path | None], root: Path,
|
|
72
|
+
known_slot: Callable[[str], bool]) -> APIRouter:
|
|
73
|
+
"""`render_file` names the render the host holds for an item in a slot.
|
|
74
|
+
Only its answer reaches the filesystem, and it must resolve inside `root`."""
|
|
75
|
+
router = APIRouter()
|
|
76
|
+
store = Path(root).resolve()
|
|
77
|
+
|
|
78
|
+
@router.get("/{slot}/{item_id}.svg")
|
|
79
|
+
def get_render(slot: str, item_id: str):
|
|
80
|
+
if not known_slot(slot):
|
|
81
|
+
raise HTTPException(400, f"no such slot: {slot}")
|
|
82
|
+
found = render_file(slot, item_id)
|
|
83
|
+
path = found.resolve() if found is not None else None
|
|
84
|
+
if path is None or store not in path.parents or not path.is_file():
|
|
85
|
+
raise HTTPException(404, "no such render")
|
|
86
|
+
# `.svg` is the wall's URL for a render, not a claim about the bytes.
|
|
87
|
+
return FileResponse(path, media_type=MEDIA_TYPES.get(
|
|
88
|
+
path.suffix, "application/octet-stream"))
|
|
89
|
+
|
|
90
|
+
return router
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Where a cell sits on a sheet: the level chain and the grid geometry."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
SHEET_LEVELS = (8, 32)
|
|
8
|
+
LOOSE_LEVEL = 128
|
|
9
|
+
#: The coarsest sheet ends the mip chain, so it cannot bleed and needs no
|
|
10
|
+
#: padding. Every finer sheet does.
|
|
11
|
+
GUTTER = 2
|
|
12
|
+
LEVELS = (*SHEET_LEVELS, LOOSE_LEVEL)
|
|
13
|
+
|
|
14
|
+
#: The bake owns the ink and the wall owns the ground. A baked ground makes
|
|
15
|
+
#: the zoom rungs disagree, and a cell changes shade on one wheel notch.
|
|
16
|
+
GROUND = (0, 0, 0, 0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Geometry:
|
|
21
|
+
count: int
|
|
22
|
+
level: int
|
|
23
|
+
cols: int
|
|
24
|
+
rows: int
|
|
25
|
+
gutter: int
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def pitch(self) -> int:
|
|
29
|
+
return self.level + 2 * self.gutter
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def size(self) -> int:
|
|
33
|
+
return self.cols * self.pitch
|
|
34
|
+
|
|
35
|
+
def cell_box(self, index: int) -> tuple[int, int, int, int]:
|
|
36
|
+
"""The cell's (left, top, right, bottom) on the sheet, gutters excluded."""
|
|
37
|
+
if not 0 <= index < self.cols * self.rows:
|
|
38
|
+
raise IndexError(f"cell {index} is outside a {self.cols}x{self.rows} grid")
|
|
39
|
+
col, row = index % self.cols, index // self.cols
|
|
40
|
+
x = col * self.pitch + self.gutter
|
|
41
|
+
y = row * self.pitch + self.gutter
|
|
42
|
+
return (x, y, x + self.level, y + self.level)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def geometry(count: int, level: int) -> Geometry:
|
|
46
|
+
if level not in SHEET_LEVELS:
|
|
47
|
+
raise ValueError(f"{level} is not a sheet level; sheets are {SHEET_LEVELS}")
|
|
48
|
+
cols = max(1, math.ceil(math.sqrt(count)))
|
|
49
|
+
# cols >= sqrt(count) keeps rows <= cols, so the square sheet never crops.
|
|
50
|
+
rows = max(1, math.ceil(count / cols))
|
|
51
|
+
gutter = 0 if level == min(SHEET_LEVELS) else GUTTER
|
|
52
|
+
return Geometry(count=count, level=level, cols=cols, rows=rows, gutter=gutter)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The slot's JSON sidecars: which sha each item was baked from."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
BAKED = "baked.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_json(path: Path) -> dict:
|
|
12
|
+
"""An unreadable sidecar is a cache miss, not a crash: baking again
|
|
13
|
+
recovers every value in it."""
|
|
14
|
+
if not path.is_file():
|
|
15
|
+
return {}
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(path.read_text())
|
|
18
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_json(path: Path, data: dict) -> None:
|
|
23
|
+
"""Whole or not at all: `write_text` truncates first, and a reader that
|
|
24
|
+
arrives mid-write takes the empty file for the truth."""
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
27
|
+
tmp.write_text(json.dumps(data, sort_keys=True))
|
|
28
|
+
os.replace(tmp, path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def baked_shas(out: Path | str) -> dict[str, str]:
|
|
32
|
+
return read_json(Path(out) / BAKED)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
SVG = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 170">'
|
|
4
|
+
'<rect x="0" y="0" width="256" height="170" fill="black"/></svg>')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def svg(tmp_path):
|
|
9
|
+
path = tmp_path / "render.svg"
|
|
10
|
+
path.write_text(SVG)
|
|
11
|
+
return path
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from pezlie import bake
|
|
7
|
+
from pezlie.sheet import LEVELS, geometry
|
|
8
|
+
from pezlie.sidecar import BAKED, baked_shas, write_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_it_rasterizes_every_level_for_one_item(tmp_path, svg):
|
|
12
|
+
out = tmp_path / "slot"
|
|
13
|
+
assert sorted(bake.bake_item("3001", svg, out, sha="abc123")) == [8, 32, 128]
|
|
14
|
+
for level in (8, 32, 128):
|
|
15
|
+
with Image.open(out / str(level) / f"3001.{bake.THUMB_EXT}") as img:
|
|
16
|
+
assert img.size == (level, level)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_a_baked_cell_is_letterboxed_square_with_ink_and_no_ground(tmp_path, svg):
|
|
20
|
+
out = tmp_path / "slot"
|
|
21
|
+
bake.bake_item("3001", svg, out, sha="abc123")
|
|
22
|
+
with Image.open(out / "128" / f"3001.{bake.THUMB_EXT}") as img:
|
|
23
|
+
rgba = img.convert("RGBA")
|
|
24
|
+
assert rgba.size == (128, 128)
|
|
25
|
+
assert rgba.getpixel((2, 2))[3] == 0
|
|
26
|
+
assert rgba.getpixel((64, 64))[3] == 255
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_it_bakes_a_raster_render_without_going_near_resvg(tmp_path):
|
|
30
|
+
src = tmp_path / "3001.webp"
|
|
31
|
+
Image.new("RGBA", (256, 170), (0, 0, 0, 255)).save(src, "WEBP")
|
|
32
|
+
out = tmp_path / "slot"
|
|
33
|
+
assert sorted(bake.bake_item("3001", src, out, sha="abc123")) == [8, 32, 128]
|
|
34
|
+
with Image.open(out / "128" / f"3001.{bake.THUMB_EXT}") as img:
|
|
35
|
+
assert img.convert("RGBA").getpixel((2, 2))[3] == 0
|
|
36
|
+
assert img.convert("RGBA").getpixel((64, 64))[3] == 255
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_it_skips_an_item_whose_sha_is_unchanged(tmp_path, svg):
|
|
40
|
+
out = tmp_path / "slot"
|
|
41
|
+
bake.bake_item("3001", svg, out, sha="abc123")
|
|
42
|
+
assert bake.bake_item("3001", svg, out, sha="abc123") == []
|
|
43
|
+
assert bake.bake_item("3001", svg, out, sha="different") != []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_the_baked_sha_is_readable_back(tmp_path, svg):
|
|
47
|
+
out = tmp_path / "slot"
|
|
48
|
+
bake.bake_item("3001", svg, out, sha="abc123")
|
|
49
|
+
assert baked_shas(out) == {"3001": "abc123"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _baked(tmp_path, svg, ids):
|
|
53
|
+
out = tmp_path / "slot"
|
|
54
|
+
for item_id in ids:
|
|
55
|
+
bake.bake_item(item_id, svg, out, sha=f"sha-{item_id}")
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_the_sheet_is_one_page_sized_from_the_item_count(tmp_path, svg):
|
|
60
|
+
out = _baked(tmp_path, svg, ["a", "b", "c"])
|
|
61
|
+
bake.compose(out, order=["a", "b", "c", "d"])
|
|
62
|
+
for level in (8, 32):
|
|
63
|
+
with Image.open(out / f"sheet-{level}.{bake.THUMB_EXT}") as img:
|
|
64
|
+
assert img.size == (geometry(4, level).size,) * 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_the_manifest_names_the_geometry_and_what_is_baked(tmp_path, svg):
|
|
68
|
+
out = _baked(tmp_path, svg, ["a", "c"])
|
|
69
|
+
bake.compose(out, order=["a", "b", "c", "d"])
|
|
70
|
+
m = json.loads((out / "sheet-32.json").read_text())
|
|
71
|
+
assert (m["level"], m["gutter"], m["pitch"], m["cols"], m["count"]) == (32, 2, 36, 2, 4)
|
|
72
|
+
assert m["baked"] == {"a": "sha-a", "c": "sha-c"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_an_item_with_no_tile_leaves_its_cell_empty(tmp_path, svg):
|
|
76
|
+
out = _baked(tmp_path, svg, ["a"])
|
|
77
|
+
bake.compose(out, order=["a", "b"])
|
|
78
|
+
g = geometry(2, 32)
|
|
79
|
+
with Image.open(out / f"sheet-32.{bake.THUMB_EXT}") as img:
|
|
80
|
+
assert img.crop(g.cell_box(0)).getextrema()[3][1] > 0
|
|
81
|
+
assert img.crop(g.cell_box(1)).getextrema()[3][1] == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_the_gutter_replicates_the_cell_edge(tmp_path, svg):
|
|
85
|
+
out = _baked(tmp_path, svg, ["a"])
|
|
86
|
+
bake.compose(out, order=["a", "b"])
|
|
87
|
+
x0, y0, _, _ = geometry(2, 32).cell_box(0)
|
|
88
|
+
with Image.open(out / f"sheet-32.{bake.THUMB_EXT}") as img:
|
|
89
|
+
assert img.getpixel((x0 - 1, y0)) == img.getpixel((x0, y0))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_truncated_sidecar_is_a_cache_miss_not_a_crash(tmp_path):
|
|
93
|
+
(tmp_path / BAKED).write_text("")
|
|
94
|
+
assert baked_shas(tmp_path) == {}
|
|
95
|
+
(tmp_path / BAKED).write_text("{oh no")
|
|
96
|
+
assert baked_shas(tmp_path) == {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_sidecar_is_written_whole_or_not_at_all(tmp_path):
|
|
100
|
+
write_json(tmp_path / "x.json", {"a": "1"})
|
|
101
|
+
assert json.loads((tmp_path / "x.json").read_text()) == {"a": "1"}
|
|
102
|
+
assert not list(tmp_path.glob("*.tmp"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a_format_change_rebakes_rather_than_composing_missing_tiles(
|
|
106
|
+
tmp_path, svg, monkeypatch):
|
|
107
|
+
out = tmp_path / "slot"
|
|
108
|
+
assert bake.bake_item("3001", svg, out, sha="abc") == list(LEVELS)
|
|
109
|
+
assert bake.bake_item("3001", svg, out, sha="abc") == []
|
|
110
|
+
monkeypatch.setattr(bake, "THUMB_EXT", "png")
|
|
111
|
+
monkeypatch.setattr(bake, "THUMB_SAVE", {"format": "PNG"})
|
|
112
|
+
assert bake.bake_item("3001", svg, out, sha="abc") == list(LEVELS)
|
|
113
|
+
for level in LEVELS:
|
|
114
|
+
assert (out / str(level) / "3001.png").is_file()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_compose_refuses_an_order_that_repeats_an_id(tmp_path):
|
|
118
|
+
with pytest.raises(ValueError, match="repeats 1 id"):
|
|
119
|
+
bake.compose(tmp_path, order=["a", "b", "a"])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pezlie.batch import Render, bake_slot
|
|
4
|
+
from pezlie.lock import BakeInProgress, slot_lock
|
|
5
|
+
from pezlie.sidecar import baked_shas
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_it_bakes_every_render_and_composes_the_sheets(tmp_path, svg):
|
|
9
|
+
out, lines = tmp_path / "slot", []
|
|
10
|
+
got = bake_slot([Render("a", svg, "s1"), Render("b", svg, "s2")], out,
|
|
11
|
+
order=["a", "b", "c"], log=lines.append)
|
|
12
|
+
assert got == (2, 2)
|
|
13
|
+
assert baked_shas(out) == {"a": "s1", "b": "s2"}
|
|
14
|
+
assert (out / "sheet-32.webp").is_file()
|
|
15
|
+
assert lines[0] == " 1/2 a baked"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_a_second_run_bakes_nothing(tmp_path, svg):
|
|
19
|
+
out, lines = tmp_path / "slot", []
|
|
20
|
+
renders = [Render("a", svg, "s1")]
|
|
21
|
+
bake_slot(renders, out, order=["a"], log=lambda _: None)
|
|
22
|
+
assert bake_slot(renders, out, order=["a"], log=lines.append) == (0, 1)
|
|
23
|
+
assert lines[0] == " 1/1 a fresh"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_a_missing_render_is_reported_and_the_slot_goes_on(tmp_path, svg):
|
|
27
|
+
out, lines = tmp_path / "slot", []
|
|
28
|
+
got = bake_slot([Render("gone", tmp_path / "nope.svg", "s"), Render("a", svg, "s")],
|
|
29
|
+
out, order=["gone", "a"], log=lines.append)
|
|
30
|
+
assert got == (1, 2)
|
|
31
|
+
assert "MISSING" in lines[0]
|
|
32
|
+
assert (out / "sheet-8.webp").is_file()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_an_unreadable_render_does_not_abandon_the_rest(tmp_path, svg):
|
|
36
|
+
empty = tmp_path / "empty.svg"
|
|
37
|
+
empty.write_text("")
|
|
38
|
+
out, lines = tmp_path / "slot", []
|
|
39
|
+
got = bake_slot([Render("bad", empty, "s"), Render("a", svg, "s")],
|
|
40
|
+
out, order=["bad", "a"], log=lines.append)
|
|
41
|
+
assert got == (1, 2)
|
|
42
|
+
assert "UNREADABLE" in lines[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_it_refuses_a_slot_another_bake_holds(tmp_path, svg):
|
|
46
|
+
out = tmp_path / "slot"
|
|
47
|
+
with slot_lock(out):
|
|
48
|
+
with pytest.raises(BakeInProgress):
|
|
49
|
+
bake_slot([Render("a", svg, "s")], out, order=["a"], log=lambda _: None)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pezlie import bake
|
|
4
|
+
from pezlie.lock import BakeInProgress, slot_lock
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_compose_refuses_while_another_bake_holds_the_slot(tmp_path):
|
|
8
|
+
with slot_lock(tmp_path):
|
|
9
|
+
with pytest.raises(BakeInProgress):
|
|
10
|
+
bake.compose(tmp_path, order=["a"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_bake_item_refuses_while_another_bake_holds_the_slot(tmp_path, svg):
|
|
14
|
+
out = tmp_path / "slot"
|
|
15
|
+
with slot_lock(out):
|
|
16
|
+
with pytest.raises(BakeInProgress):
|
|
17
|
+
bake.bake_item("a", svg, out, sha="x")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_a_failed_bake_releases_the_slot(tmp_path):
|
|
21
|
+
with pytest.raises(ValueError):
|
|
22
|
+
bake.compose(tmp_path, order=["a", "a"])
|
|
23
|
+
assert bake.compose(tmp_path, order=["a"])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The lift renamed things and moved nothing: bakery and the brick-icons file
|
|
2
|
+
it came from bake the same inputs to the same bytes. Skips without a
|
|
3
|
+
brick-icons checkout beside pezlie, or at $BRICK_ICONS."""
|
|
4
|
+
import importlib.util
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from pezlie import bake
|
|
13
|
+
|
|
14
|
+
LEGACY = (Path(os.environ.get("BRICK_ICONS")
|
|
15
|
+
or Path(__file__).resolve().parents[3] / "brick-icons")
|
|
16
|
+
/ "brick_icons" / "thumbs.py")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _legacy():
|
|
20
|
+
if not LEGACY.is_file():
|
|
21
|
+
pytest.skip(f"no brick-icons thumbs.py at {LEGACY}")
|
|
22
|
+
spec = importlib.util.spec_from_file_location("legacy_thumbs", LEGACY)
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
sys.modules[spec.name] = module # @dataclass looks its module up here
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _renders(root: Path) -> dict[str, Path]:
|
|
30
|
+
root.mkdir()
|
|
31
|
+
wide = root / "wide.svg"
|
|
32
|
+
wide.write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 170">'
|
|
33
|
+
'<rect x="20" y="10" width="200" height="150" fill="#c33"/>'
|
|
34
|
+
'<circle cx="128" cy="85" r="60" fill="#36c"/></svg>')
|
|
35
|
+
tall = root / "tall.svg"
|
|
36
|
+
tall.write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 240">'
|
|
37
|
+
'<path d="M0 240 L45 0 L90 240 Z" fill="#2a2"/></svg>')
|
|
38
|
+
raster = root / "raster.webp"
|
|
39
|
+
img = Image.new("RGBA", (256, 170))
|
|
40
|
+
for x in range(256):
|
|
41
|
+
for y in range(0, 170, 3):
|
|
42
|
+
img.putpixel((x, y), (x, 255 - x, y, 255))
|
|
43
|
+
img.save(raster, "WEBP")
|
|
44
|
+
return {"a": wide, "b": tall, "c": raster}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _files(root: Path) -> dict[str, bytes]:
|
|
48
|
+
return {str(p.relative_to(root)): p.read_bytes()
|
|
49
|
+
for p in sorted(root.rglob("*")) if p.is_file() and p.name != ".bake.lock"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_a_slot_bakes_to_the_same_bytes_as_the_code_it_was_lifted_from(tmp_path):
|
|
53
|
+
legacy = _legacy()
|
|
54
|
+
renders = _renders(tmp_path / "src")
|
|
55
|
+
order = ["a", "never-drawn", "b", "c"]
|
|
56
|
+
|
|
57
|
+
for item_id, path in renders.items():
|
|
58
|
+
legacy.bake_part(item_id, path, tmp_path / "legacy", sha=f"sha-{item_id}")
|
|
59
|
+
bake.bake_item(item_id, path, tmp_path / "lifted", sha=f"sha-{item_id}")
|
|
60
|
+
legacy.compose(tmp_path / "legacy", order)
|
|
61
|
+
bake.compose(tmp_path / "lifted", order)
|
|
62
|
+
|
|
63
|
+
before, after = _files(tmp_path / "legacy"), _files(tmp_path / "lifted")
|
|
64
|
+
assert sorted(before) == sorted(after)
|
|
65
|
+
assert [name for name in before if before[name] != after[name]] == []
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from pezlie.routes import render_router, thumbs_router
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def client(tmp_path):
|
|
11
|
+
slot = tmp_path / "thumbs" / "naive"
|
|
12
|
+
(slot / "128").mkdir(parents=True)
|
|
13
|
+
Image.new("RGBA", (128, 128)).save(slot / "128" / "3001.webp", "WEBP")
|
|
14
|
+
Image.new("RGBA", (8, 8)).save(slot / "sheet-8.webp", "WEBP")
|
|
15
|
+
(slot / "sheet-8.json").write_text('{"level": 8}')
|
|
16
|
+
(slot / "baked.json").write_text("{}")
|
|
17
|
+
|
|
18
|
+
store = tmp_path / "store"
|
|
19
|
+
(store / "naive").mkdir(parents=True)
|
|
20
|
+
(store / "naive" / "3001.svg").write_text("<svg viewBox='0 0 256 170'></svg>")
|
|
21
|
+
(store / "naive" / "3002.webp").write_bytes(b"RIFF\x00\x00\x00\x00WEBPVP8 ")
|
|
22
|
+
(tmp_path / "outside.svg").write_text("<svg>not in the store</svg>")
|
|
23
|
+
renders = {("naive", "3001"): store / "naive" / "3001.svg",
|
|
24
|
+
("naive", "3002"): store / "naive" / "3002.webp",
|
|
25
|
+
("naive", "escape"): store / ".." / "outside.svg"}
|
|
26
|
+
|
|
27
|
+
slots = {"naive"}
|
|
28
|
+
app = FastAPI()
|
|
29
|
+
app.include_router(thumbs_router(
|
|
30
|
+
lambda s: tmp_path / "thumbs" / s if s in slots else None), prefix="/api/thumbs")
|
|
31
|
+
app.include_router(render_router(
|
|
32
|
+
lambda s, i: renders.get((s, i)), store, lambda s: s in slots),
|
|
33
|
+
prefix="/api/corpus/render")
|
|
34
|
+
return TestClient(app)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_a_loose_tile_is_served_whatever_extension_was_asked(client):
|
|
38
|
+
r = client.get("/api/thumbs/naive/128/3001.png")
|
|
39
|
+
assert r.status_code == 200
|
|
40
|
+
assert r.headers["content-type"] == "image/webp"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_a_sheet_is_served(client):
|
|
44
|
+
assert client.get("/api/thumbs/naive/sheet-8.webp").status_code == 200
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_a_manifest_carries_its_image_version(client):
|
|
48
|
+
body = client.get("/api/thumbs/naive/sheet-8.json").json()
|
|
49
|
+
assert body["level"] == 8
|
|
50
|
+
assert body["version"].isdigit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_missing_manifest_is_404(client):
|
|
54
|
+
assert client.get("/api/thumbs/naive/sheet-32.json").status_code == 404
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_an_unknown_slot_is_400(client):
|
|
58
|
+
assert client.get("/api/thumbs/nonsense/128/3001.png").status_code == 400
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_a_tile_route_serves_images_only(client):
|
|
62
|
+
assert client.get("/api/thumbs/naive/128/baked.json").status_code == 400
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_a_tile_route_refuses_traversal(client):
|
|
66
|
+
assert client.get(
|
|
67
|
+
"/api/thumbs/naive/128/..%2F..%2Fbaked.json").status_code in (400, 404)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_render_is_served(client):
|
|
71
|
+
r = client.get("/api/corpus/render/naive/3001.svg")
|
|
72
|
+
assert r.status_code == 200
|
|
73
|
+
assert "<svg" in r.text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_a_render_is_typed_by_what_it_is_not_by_its_url(client):
|
|
77
|
+
r = client.get("/api/corpus/render/naive/3002.svg")
|
|
78
|
+
assert r.headers["content-type"] == "image/webp"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_an_unknown_render_is_404(client):
|
|
82
|
+
assert client.get("/api/corpus/render/naive/9999.svg").status_code == 404
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_a_render_in_an_unknown_slot_is_400(client):
|
|
86
|
+
assert client.get("/api/corpus/render/nonsense/3001.svg").status_code == 400
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_a_render_outside_the_store_is_404(client):
|
|
90
|
+
assert client.get("/api/corpus/render/naive/escape.svg").status_code == 404
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pezlie import sheet
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_levels_are_the_two_sheets_and_the_loose_one():
|
|
7
|
+
assert sheet.SHEET_LEVELS == (8, 32)
|
|
8
|
+
assert sheet.LOOSE_LEVEL == 128
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_the_grid_is_square_enough_to_hold_every_item():
|
|
12
|
+
g = sheet.geometry(24591, level=32)
|
|
13
|
+
assert (g.cols, g.rows) == (157, 157)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_the_coarsest_level_has_no_gutter():
|
|
17
|
+
assert sheet.geometry(100, level=8).gutter == 0
|
|
18
|
+
assert sheet.geometry(100, level=32).gutter == 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_pitch_is_the_cell_plus_both_gutters():
|
|
22
|
+
g = sheet.geometry(100, level=32)
|
|
23
|
+
assert g.pitch == 36
|
|
24
|
+
assert g.size == g.cols * 36
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_a_cell_lands_row_major_inside_its_gutter():
|
|
28
|
+
g = sheet.geometry(100, level=32) # cols == 10
|
|
29
|
+
assert g.cell_box(0) == (2, 2, 34, 34)
|
|
30
|
+
assert g.cell_box(1) == (38, 2, 70, 34)
|
|
31
|
+
assert g.cell_box(10) == (2, 38, 34, 70)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_an_index_past_the_grid_is_an_error():
|
|
35
|
+
g = sheet.geometry(4, level=8)
|
|
36
|
+
with pytest.raises(IndexError):
|
|
37
|
+
g.cell_box(g.cols * g.rows)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_the_loose_level_is_not_a_sheet():
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
sheet.geometry(100, level=sheet.LOOSE_LEVEL)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_a_square_sheet_never_crops_an_uneven_grid():
|
|
46
|
+
g = sheet.geometry(82, level=32)
|
|
47
|
+
assert (g.cols, g.rows) == (10, 9)
|
|
48
|
+
assert g.cell_box(81)[3] <= g.size
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_tiny_corpus_still_has_a_grid():
|
|
52
|
+
for count in (0, 1):
|
|
53
|
+
g = sheet.geometry(count, level=8)
|
|
54
|
+
assert (g.cols, g.rows) == (1, 1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_ground_is_transparent():
|
|
58
|
+
assert sheet.GROUND == (0, 0, 0, 0)
|