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:
parent
873ee6eae4
commit
aa2bf0d41f
4 changed files with 153 additions and 22 deletions
|
|
@ -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]))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue