Guards fix: cultcache-py version is read from the imported module; texture cap boundary, rounding, orientation, group and failure cases pinned

Findings of verdict cut-ships-addon-guards.s1: guard-not-import (the version now comes from the checkout the imported
cultcache_py lives in, one owner, no pyproject-beside-a-path), prefix-compare-survives (0.3.0.1, 0.3.0rc1, 0.3.00
refused), cap-boundary-unpinned and group-restore-unpinned (smoke_package texture_boundary_cases: at the cap, one over,
height-long, non-square rounding, file/packed/node-group images, failed export restores). A copy whose scale raises is
removed. All commits build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 06:59:24 +02:00
commit aa2bf0d41f
4 changed files with 153 additions and 22 deletions

View file

@ -725,9 +725,13 @@ def _swap_in_scaled_images(objects, limit, swaps):
continue
if original not in copies:
copy = original.copy()
try:
copy.scale(max(1, round(original.size[0] * limit / longest)),
max(1, round(original.size[1] * limit / longest)))
except BaseException:
bpy.data.images.remove(copy) # not yet in swaps, so _restore_images cannot find it
raise
copies[original] = copy
copy.scale(max(1, round(original.size[0] * limit / longest)),
max(1, round(original.size[1] * limit / longest)))
node.image = copies[original]
swaps.append((node, original, copies[original]))

View file

@ -16,7 +16,7 @@ from pathlib import Path
from typing import Any, NamedTuple
# The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded
# from source, so its version is [project].version in the pyproject.toml beside that source.
# from source, so the guard reads [project].version of the checkout the imported module itself came from.
TESTED_CULTCACHE_PY = "0.3.0"
SCHEMA = "aetheria.ship_authoring"
@ -67,26 +67,27 @@ def encode_hardpoint(**fields: Any) -> list[Any]:
return [fields[name] for name in HARDPOINT_MEMBERS]
def _cultcache_py_version(cultlib_packages: str) -> str:
pyproject = Path(cultlib_packages) / "cultcache-py" / "pyproject.toml"
def _cultcache_py_version(module: Any) -> str:
"""[project].version of the cultcache-py checkout the imported module lives in (src/cultcache_py/__init__.py sits
two levels under it). A module with no file or no readable pyproject has no version: ""."""
try:
with pyproject.open("rb") as handle:
with (Path(module.__file__).parents[2] / "pyproject.toml").open("rb") as handle:
return str(tomllib.load(handle)["project"]["version"])
except (OSError, tomllib.TOMLDecodeError, KeyError):
raise ValueError(f"cultcache-py's pyproject.toml is missing or unreadable under {cultlib_packages}; this add-on "
f"is tested against cultcache-py {TESTED_CULTCACHE_PY}") from None
except (AttributeError, TypeError, IndexError, OSError, tomllib.TOMLDecodeError, KeyError):
return ""
def _libraries(cultlib_packages: str):
found = _cultcache_py_version(cultlib_packages)
if found != TESTED_CULTCACHE_PY:
raise ValueError(f"cultcache-py {found} under {cultlib_packages} is not the tested {TESTED_CULTCACHE_PY}; the "
f"add-on writes raw envelopes and refuses a CultLib whose write path may have moved")
source = Path(cultlib_packages) / "cultcache-py" / "src"
if source.exists() and str(source) not in sys.path:
sys.path.insert(0, str(source))
import cultcache_py # type: ignore
import msgpack # type: ignore
found = _cultcache_py_version(cultcache_py)
if found != TESTED_CULTCACHE_PY:
raise ValueError(f"the imported cultcache-py (version {found or 'unreadable'}; CultLib packages {cultlib_packages}) "
f"is not the tested {TESTED_CULTCACHE_PY}; the add-on writes raw envelopes and refuses a "
f"CultLib whose write path may have moved")
return cultcache_py, msgpack

View file

@ -20,6 +20,7 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t
the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves.
"""
import array
import contextlib
import io
import json
@ -115,6 +116,93 @@ def texture_cap_pass(addon, hull_object, albedo, detail, limit, expected, glb):
raise SystemExit(f"Texture cap {limit}: a scaled copy is left in bpy.data.images")
def expected_cap_size(width, height, limit):
"""The longest side at the cap, the other side scaled by the same ratio and rounded half up; untouched at or under the
cap. Integer arithmetic, so it shares nothing with the add-on's float rounding."""
longest = max(width, height)
if longest <= limit:
return (width, height)
return (max(1, (2 * width * limit + longest) // (2 * longest)), max(1, (2 * height * limit + longest) // (2 * longest)))
def texture_boundary_cases(addon, hull_object, glb):
"""Each case gives the hull one material whose image is width x height (generated, file-backed, packed, or inside a
node group) and packages at cap 256: the GLB holds exactly the expected size, the image is as it was (size, pixels,
node identity), and no copy is left. Cases sit exactly at the cap, one over, on the long side being the height, on
sizes where rounding and flooring differ, and on a tall image whose width rounds up."""
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = limit = 256
original_materials = list(hull_object.data.materials)
folder = Path(tempfile.mkdtemp(prefix="aetheria-smoke-cap-"))
cases = [("generated", 256, 128), ("generated", 257, 125), ("generated", 257, 128), ("generated", 256, 256),
("generated", 255, 255), ("generated", 375, 376), ("generated", 125, 513), ("generated", 1000, 4097),
("file", 513, 400), ("file", 256, 257), ("packed", 513, 400), ("packed", 258, 70), ("group", 513, 400),
("group", 125, 513)]
try:
for kind, width, height in cases:
label = f"{kind} {width}x{height}"
while hull_object.data.materials:
hull_object.data.materials.pop(index=0)
image = bpy.data.images.new("CapCase", width, height)
image.pixels.foreach_set([((k * 7) % 251) / 251.0 for k in range(width * height * 4)])
if kind in ("file", "packed"):
image.filepath_raw = str(folder / f"{kind}_{width}_{height}.png")
image.file_format = "PNG"
image.save()
bpy.data.images.remove(image)
image = bpy.data.images.load(str(folder / f"{kind}_{width}_{height}.png"))
image.name = "CapCase"
if kind == "packed":
image.pack()
material = bpy.data.materials.new("CapCase")
material.use_nodes = True
tree = material.node_tree
if kind == "group":
group = bpy.data.node_groups.new("CapGroup", "ShaderNodeTree")
group.interface.new_socket("Color", in_out="OUTPUT", socket_type="NodeSocketColor")
texture = group.nodes.new("ShaderNodeTexImage")
texture.image = image
group.links.new(texture.outputs["Color"], group.nodes.new("NodeGroupOutput").inputs[0])
holder = tree.nodes.new("ShaderNodeGroup")
holder.node_tree = group
tree.links.new(holder.outputs[0], tree.nodes["Principled BSDF"].inputs["Base Color"])
else:
texture = tree.nodes.new("ShaderNodeTexImage")
texture.image = image
tree.links.new(texture.outputs["Color"], tree.nodes["Principled BSDF"].inputs["Base Color"])
hull_object.data.materials.append(material)
pixels = bytes(array.array("f", image.pixels[:]).tobytes())
names = sorted(i.name for i in bpy.data.images)
result, report = package(addon)
if result != {"FINISHED"}:
raise SystemExit(f"Texture case {label}: Package was refused")
if glb_image_sizes(glb) != [expected_cap_size(width, height, limit)]:
raise SystemExit(f"Texture case {label}: the GLB image is {glb_image_sizes(glb)}, "
f"not {expected_cap_size(width, height, limit)}")
if (tuple(image.size) != (width, height) or bytes(array.array("f", image.pixels[:]).tobytes()) != pixels
or texture.image != image or sorted(i.name for i in bpy.data.images) != names):
raise SystemExit(f"Texture case {label}: the .blend's image changed or a copy is left behind")
if kind == "file" and Path(image.filepath_raw).exists() is False:
raise SystemExit(f"Texture case {label}: the source file is gone")
# An exporter that fails (the GLB's path is a directory) still restores the originals and deletes the copies.
names = sorted(i.name for i in bpy.data.images)
glb.unlink()
glb.mkdir()
try:
result, report = package(addon)
finally:
glb.rmdir()
if result != {"CANCELLED"}:
raise SystemExit("Texture failure case: the export into a directory did not fail")
if tuple(image.size) != (width, height) or texture.image != image or sorted(i.name for i in bpy.data.images) != names:
raise SystemExit("Texture failure case: a failed export left a scaled copy or a swapped node")
finally:
while hull_object.data.materials:
hull_object.data.materials.pop(index=0)
for material in original_materials:
hull_object.data.materials.append(material)
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048
BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc")
@ -287,6 +375,7 @@ def main():
texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 500), (256, 256)], glb)
texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 125), (256, 256)], glb)
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048
texture_boundary_cases(aetheria_ships, hull, glb)
print("TEXTURE_CAP_OK")
# A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing.
if sorted(obj.name for obj in bpy.data.objects) != objects_before or any(

View file

@ -569,31 +569,55 @@ class RefusalsNameNoInputTests(ShipFileCase):
class UntestedCultCacheTests(ShipFileCase):
"""_libraries refuses a CultLib whose cultcache-py is not the version the raw envelope writes were tested against."""
"""_libraries refuses a cultcache-py that is not the version the raw envelope writes were tested against. The version
is read from the checkout of the module that actually imports, so each case imports a stub cultcache_py."""
def packages(self, version=None):
def setUp(self):
super().setUp()
saved_modules = {name: module for name, module in sys.modules.items() if name.split(".")[0] == "cultcache_py"}
saved_path = list(sys.path)
for name in saved_modules:
del sys.modules[name]
self.addCleanup(self.restore, saved_modules, saved_path)
@staticmethod
def restore(saved_modules, saved_path):
for name in [name for name in sys.modules if name.split(".")[0] == "cultcache_py"]:
del sys.modules[name]
sys.modules.update(saved_modules)
sys.path[:] = saved_path
def packages(self, version=None, with_source=True):
root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, root, True)
(root / "cultcache-py").mkdir()
checkout = root / "cultcache-py"
checkout.mkdir()
if version is not None:
(root / "cultcache-py" / "pyproject.toml").write_text(
(checkout / "pyproject.toml").write_text(
f'[project]\nname = "cultcache-py"\nversion = "{version}"\n', encoding="utf-8")
if with_source:
(checkout / "src" / "cultcache_py").mkdir(parents=True)
(checkout / "src" / "cultcache_py" / "__init__.py").write_text("", encoding="utf-8")
return str(root)
def test_a_different_version_is_refused_naming_the_version_and_the_packages_path(self):
before = self.bytes()
for version in ("9.9.9", "0.4.0", "0.3.1", "0.3"): # any drift, not only a different major
stub = self.packages(version)
for call in (lambda: ship_cc.read(self.path, stub), lambda: ship_cc.read_layout(self.path, stub)):
# any drift, not only a different major; 0.3.0.1 and 0.3.0rc1 start with the tested version and are not it
for version in ("9.9.9", "0.4.0", "0.3.1", "0.3", "0.3.0.1", "0.3.0rc1", "0.3.00"):
for call in (lambda stub: ship_cc.read(self.path, stub), lambda stub: ship_cc.read_layout(self.path, stub)):
stub = self.packages(version)
with self.subTest(version=version), self.assertRaises(ValueError) as caught:
call()
call(stub)
self.fail("accepted")
self.assertIn(version, str(caught.exception))
self.assertIn(stub, str(caught.exception))
self.restore({}, list(sys.path)) # the next import is fresh
self.assertEqual(before, self.bytes())
def test_a_save_through_an_untested_cultcache_writes_nothing(self):
_, _, revision = ship_cc.read_layout(self.path, PACKAGES)
before = self.bytes()
self.restore({}, list(sys.path))
with self.assertRaises(ValueError):
ship_cc.replace_layout(self.path, self.packages("9.9.9"), "mod.skiff", revision, [2, 2, [True] * 4], [])
self.assertEqual(before, self.bytes())
@ -604,9 +628,22 @@ class UntestedCultCacheTests(ShipFileCase):
ship_cc.read(self.path, stub)
self.assertIn(stub, str(caught.exception))
def test_the_guard_certifies_the_library_that_is_imported_not_the_path_it_was_given(self):
# A pyproject that says 0.3.0 beside a path whose source is not what imports does not vouch for the import.
real = self.packages("0.4.0") # imports first, and is the library in use
with self.assertRaises(ValueError):
ship_cc.read(self.path, real)
liar = self.packages("0.3.0", with_source=False) # claims the tested version, supplies no source
sys.path.insert(0, str(Path(real) / "cultcache-py" / "src"))
with self.assertRaises(ValueError) as caught:
ship_cc.read(self.path, liar)
self.assertIn("0.4.0", str(caught.exception))
def test_the_tested_version_is_the_one_the_real_packages_directory_carries(self):
self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(PACKAGES))
self.restore({}, list(sys.path))
self.assertEqual("mod.skiff", ship_cc.read(self.path, PACKAGES).ship.body[0])
import cultcache_py
self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(cultcache_py))
class ShipIdTests(unittest.TestCase):