From aa2bf0d41fb7378ab25cd23f6395ab2b07618195 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 06:59:24 +0200 Subject: [PATCH] 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 --- tools/blender/aetheria_ships/__init__.py | 8 ++- tools/blender/aetheria_ships/ship_cc.py | 23 +++--- tools/blender/tests/smoke_package.py | 89 ++++++++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 55 ++++++++++++--- 4 files changed, 153 insertions(+), 22 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 3ee83df5d..7df050681 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -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])) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 5ef61445d..cb76e744c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -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 diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index c4568199b..e70d21710 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -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( diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 176182e6e..f4973f9b7 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -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):