diff --git a/docs/products.md b/docs/products.md index 002ce50..d9c5158 100644 --- a/docs/products.md +++ b/docs/products.md @@ -30,7 +30,7 @@ object that a reached object references and no census holds is listed as `missin |---|---| | inputs | `objects`: the object table of what it reaches (`nnnotes.objects/1`); the stage's own inputs (master tables, data its parameters name) | | depends | `link.addresses` (when it has keys or resource paths), the `unity.export` and `sprite.crop` tasks whose results it read | -| artifacts | `#`: the product's own files; `#objects`: the object table of the objects its files use (the whole table unless the stage narrows it) | +| artifacts | `#`: the product's own files; `#objects`: the object table of the objects its files use (the whole table unless the stage narrows it to some objects, or to some files of an object: a shader's programs for one graphics API) | | item | one for the subject: `exported`, or `unsupported` with a reason code | The key holds the object table, so a product is assembled again exactly when a file of an object it reaches diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index 4fc3020..a1ab4e7 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -204,11 +204,35 @@ def json(self, oid: str, role: str = "json"): @dataclass class Assembly: - """What `assemble` makes: the product's files {path: (bytes, extension, kind)} and the object ids its files use - (None: every object of the table).""" + """What `assemble` makes: the product's files {path: (bytes, extension, kind)} and what of the object table they + use: `objects` the object ids (None: every object of the table), `roles` {object id: the roles of its files the + product keeps} for an object of which it uses some files only (a shader's programs for one graphics API). The + product's object table, and so its manifest, holds only those.""" files: dict objects: set | None = None + roles: dict = field(default_factory=dict) + + +def _prune(tid: str, objects: dict, made: Assembly) -> dict: + """The entries of an object table an Assembly keeps.""" + keep = set(objects) if made.objects is None else set(made.objects) + unknown = sorted((keep | set(made.roles)) - set(objects)) + if unknown: + raise ValueError(f"{tid}: objects not in the table: {', '.join(unknown[:5])}") + out = {} + for o, e in objects.items(): + if o not in keep: + continue + roles = made.roles.get(o) + if roles is not None: + files = e.get("files") or {} + lacking = sorted(set(roles) - set(files)) + if lacking: + raise ValueError(f"{tid}: object {o} has no {', '.join(lacking)} file") + e = dict(e, files={r: f for r, f in files.items() if r in roles}) + out[o] = e + return out class ProductStage(Stage): @@ -303,11 +327,8 @@ def run(self, task, depot) -> Output: raise ValueError(f"{task.id}: a product file cannot be named {PRODUCT_TABLE!r}") aid = contract.artifact_id(task.id, path) arts.append(contract.artifact(aid, depot.add(body, ext), prov, {"kind": kind})) - if made.objects is not None: - unknown = sorted(set(made.objects) - set(doc["objects"])) - if unknown: - raise ValueError(f"{task.id}: objects not in the table: {', '.join(unknown[:5])}") - doc = dict(doc, objects={o: e for o, e in doc["objects"].items() if o in made.objects}) + if made.objects is not None or made.roles: + doc = dict(doc, objects=_prune(task.id, doc["objects"], made)) data = contract.encode(doc) aid = contract.artifact_id(task.id, PRODUCT_TABLE) arts.append(contract.artifact(aid, depot.add(data, "json"), prov, {"kind": TABLE_KIND})) diff --git a/tests/pipeline/test_assemble.py b/tests/pipeline/test_assemble.py index b14dc93..4cd733d 100644 --- a/tests/pipeline/test_assemble.py +++ b/tests/pipeline/test_assemble.py @@ -313,6 +313,41 @@ def assemble(self, task, objects): execute(t, depot, registry(Wrong({"m/a": "key/a"})), force=True) +class Roles(Toy): + """A Toy that keeps the `json` file alone of the texture its prefab references.""" + + ROLES: dict = {oid(10): {"json"}} + + def assemble(self, task, objects): + made = super().assemble(task, objects) + return assemble.Assembly(made.files, roles=self.ROLES) + + +def test_a_product_keeps_some_files_of_an_object(tmp_path): + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(60)]}) + exported(depot, env) + t, doc = product(depot, env, "m/a", {"m/a": "key/a"}, stage=Roles({"m/a": "key/a"})) + table = table_of(depot, doc) + assert set(table["objects"][oid(10)]["files"]) == {"json"} # no image + assert oid(60) in table["objects"] and oid(61) in table["objects"] # the others whole + entries, manifest = manifest_of(depot, t, doc) + assert set(manifest["objects"][oid(10)]["files"]) == {"json"} + assert f"{oid(10)}#image" not in {e["id"] for e in entries} + + +def test_a_product_cannot_keep_a_file_an_object_lacks(tmp_path): + class Wrong(Roles): + ROLES = {oid(10): {"json", "mesh"}} + + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(60)]}) + exported(depot, env) + t = describe(Wrong({"m/a": "key/a"}), "m/a", None, env) + with pytest.raises(ValueError, match="has no mesh file"): + execute(t, depot, registry(Wrong({"m/a": "key/a"})), force=True) + + # ---------------------------------------------------------------- the layout def manifest_of(depot, t, doc): """(entries, the manifest) of a product layout for one result."""