weft-usd 0.1.0__py3-none-any.whl

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.
absent/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """absent -- find what a USD stage refers to that is no longer there.
2
+
3
+ USD can tell you what is in a scene. This tells you what isn't, and why.
4
+ """
5
+
6
+ from .detect import ( # noqa: F401
7
+ ANCHOR_KEY,
8
+ AUTHORED_AGAINST_KEY,
9
+ DEAD,
10
+ MOVED,
11
+ UNKNOWN,
12
+ Finding,
13
+ Report,
14
+ anchor_index,
15
+ scan,
16
+ scan_file,
17
+ )
18
+
19
+ __all__ = [
20
+ "ANCHOR_KEY", "AUTHORED_AGAINST_KEY", "DEAD", "MOVED", "UNKNOWN",
21
+ "Finding", "Report", "anchor_index", "scan", "scan_file",
22
+ ]
absent/__main__.py ADDED
@@ -0,0 +1,98 @@
1
+ """absent -- report what a USD stage refers to that is no longer there.
2
+
3
+ python -m absent shot.usda
4
+ python -m absent shot.usda --json
5
+ python -m absent shot.usda --anchor-key "weft:id"
6
+
7
+ Exits 1 if anything is dead, so it can gate a publish.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from .detect import ANCHOR_KEY, DEAD, MOVED, UNKNOWN, scan_file
17
+
18
+
19
+ def _render(report, show_unknown: bool) -> str:
20
+ out = []
21
+ groups = [
22
+ (DEAD, report.dead, "gone -- nothing defines this any more"),
23
+ (MOVED, report.moved, "the anchor still exists, at a new path"),
24
+ ]
25
+ if show_unknown:
26
+ groups.append(
27
+ (UNKNOWN, report.unknown,
28
+ "refers to nothing, and nothing was recorded to say why"))
29
+
30
+ for label, items, gloss in groups:
31
+ if not items:
32
+ continue
33
+ out.append("")
34
+ out.append("%s (%d) -- %s" % (label.upper(), len(items), gloss))
35
+ out.append("-" * 72)
36
+ for f in items:
37
+ out.append(str(f))
38
+
39
+ out.append("")
40
+ out.append("-" * 72)
41
+ out.append("scanned %d prims, %d prim specs, %d anchors"
42
+ % (report.prims_scanned, report.specs_scanned, report.anchors_found))
43
+ out.append("dead %d moved %d unknown %d"
44
+ % (len(report.dead), len(report.moved), len(report.unknown)))
45
+
46
+ if report.anchors_found == 0 and (report.unknown or report.dead):
47
+ out.append("")
48
+ out.append("No anchor names on this stage, so every absence reads as "
49
+ "'unknown'.")
50
+ out.append("Anchors let this tool separate 'gone' from 'moved' -- see "
51
+ "--anchor-key.")
52
+ return "\n".join(out)
53
+
54
+
55
+ def main(argv=None) -> int:
56
+ ap = argparse.ArgumentParser(
57
+ prog="absent",
58
+ description="Find what a USD stage refers to that is no longer there.")
59
+ ap.add_argument("stage", help="a .usd / .usda / .usdc file")
60
+ ap.add_argument("--anchor-key", default=ANCHOR_KEY,
61
+ help="prim custom-data key holding the stable anchor name "
62
+ "(default: %(default)s)")
63
+ ap.add_argument("--json", action="store_true", dest="as_json",
64
+ help="emit findings as JSON")
65
+ ap.add_argument("--quiet-unknown", action="store_true",
66
+ help="hide findings where nothing was recorded to explain them")
67
+ args = ap.parse_args(argv)
68
+
69
+ try:
70
+ report = scan_file(args.stage, args.anchor_key)
71
+ except IOError as e:
72
+ print("absent: %s" % e, file=sys.stderr)
73
+ return 2
74
+
75
+ if args.as_json:
76
+ print(json.dumps({
77
+ "stage": args.stage,
78
+ "scanned": {
79
+ "prims": report.prims_scanned,
80
+ "prim_specs": report.specs_scanned,
81
+ "anchors": report.anchors_found,
82
+ },
83
+ "findings": [
84
+ {
85
+ "kind": f.kind, "path": f.path, "detail": f.detail,
86
+ "verdict": f.verdict, "layer": f.layer, "moved_to": f.moved_to,
87
+ }
88
+ for f in report.findings
89
+ ],
90
+ }, indent=2))
91
+ else:
92
+ print(_render(report, not args.quiet_unknown))
93
+
94
+ return 1 if report.dead else 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
absent/detect.py ADDED
@@ -0,0 +1,357 @@
1
+ """Find what a USD stage refers to that is no longer there.
2
+
3
+ USD can tell you what is in a scene. It cannot tell you what is missing.
4
+
5
+ An `over` at a path that no longer composes is *structurally identical* to a
6
+ legitimate sparse override authored ahead of its target -- which is why none of
7
+ USD's shipped validators catch one, and why they never will. The same silence
8
+ covers relationship targets that resolve to nothing, material bindings to
9
+ deactivated materials, and collection patterns that match zero prims.
10
+
11
+ This module reports those. On a stock stage it can say "this refers to nothing".
12
+ Given anchor names (see ANCHOR_KEY), it can say *why*: the target is gone, or the
13
+ target moved and here is where it went. That distinction is the whole argument
14
+ for carrying a name separate from the hierarchy.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Dict, Iterable, Iterator, List, Optional
21
+
22
+ from pxr import Sdf, Usd, UsdShade
23
+
24
+
25
+ # A stable name for a thing a scene refers to, carried in-band as prim custom
26
+ # data. Never an address -- nothing resolves it to fetch a prim on the hot path.
27
+ ANCHOR_KEY = "absent:id"
28
+
29
+ # Recorded by whoever authors an override: the anchor it was authored against.
30
+ # This is what turns "refers to nothing" into "dead" versus "moved".
31
+ AUTHORED_AGAINST_KEY = "absent:authoredAgainst"
32
+
33
+
34
+ DEAD = "dead" # target absent, and its anchor is absent too
35
+ MOVED = "moved" # target absent, but its anchor is elsewhere on the stage
36
+ UNKNOWN = "unknown" # target absent, nothing recorded -- cannot tell why
37
+
38
+
39
+ @dataclass
40
+ class Finding:
41
+ kind: str # what sort of absence
42
+ path: str # where the dangling reference lives
43
+ detail: str # what it points at
44
+ verdict: str = UNKNOWN
45
+ layer: str = "" # which layer authored it, when known
46
+ moved_to: Optional[str] = None # populated when verdict is MOVED
47
+
48
+ def __str__(self) -> str:
49
+ head = "%-22s %-7s %s" % (self.kind, self.verdict, self.path)
50
+ if self.moved_to:
51
+ return "%s\n %s\n anchor now at %s" % (head, self.detail, self.moved_to)
52
+ return "%s\n %s" % (head, self.detail)
53
+
54
+
55
+ @dataclass
56
+ class Report:
57
+ findings: List[Finding] = field(default_factory=list)
58
+ prims_scanned: int = 0
59
+ specs_scanned: int = 0
60
+ anchors_found: int = 0
61
+
62
+ @property
63
+ def dead(self) -> List[Finding]:
64
+ return [f for f in self.findings if f.verdict == DEAD]
65
+
66
+ @property
67
+ def moved(self) -> List[Finding]:
68
+ return [f for f in self.findings if f.verdict == MOVED]
69
+
70
+ @property
71
+ def unknown(self) -> List[Finding]:
72
+ return [f for f in self.findings if f.verdict == UNKNOWN]
73
+
74
+
75
+ # -- anchors ---------------------------------------------------------------
76
+
77
+ def anchor_index(stage: Usd.Stage, key: str = ANCHOR_KEY) -> Dict[str, str]:
78
+ """Map every anchor name on the stage to the path it currently sits at.
79
+
80
+ This is the only thing anchor names are used for here: telling a target that
81
+ vanished from a target that moved. They are never used to address anything.
82
+ """
83
+ index: Dict[str, str] = {}
84
+ for prim in stage.TraverseAll():
85
+ name = prim.GetCustomDataByKey(key)
86
+ if name:
87
+ index[str(name)] = str(prim.GetPath())
88
+ return index
89
+
90
+
91
+ def _nested_get(data, key: str):
92
+ """Read a colon-delimited key out of a raw customData dict.
93
+
94
+ Usd.Prim.GetCustomDataByKey treats ':' as a nesting delimiter, so a key like
95
+ "absent:id" lives at customData["absent"]["id"]. Sdf spec customData is a
96
+ plain dict, so we have to walk it ourselves to match that behaviour.
97
+ """
98
+ cur = data
99
+ for part in key.split(":"):
100
+ if not hasattr(cur, "get"):
101
+ return None
102
+ cur = cur.get(part)
103
+ if cur is None:
104
+ return None
105
+ return cur
106
+
107
+
108
+ def _adjudicate(recorded: Optional[str], anchors: Dict[str, str]) -> tuple:
109
+ """Given the anchor an override recorded, decide dead / moved / unknown."""
110
+ if not recorded:
111
+ return UNKNOWN, None
112
+ where = anchors.get(str(recorded))
113
+ if where:
114
+ return MOVED, where
115
+ return DEAD, None
116
+
117
+
118
+ # -- spec walking ----------------------------------------------------------
119
+
120
+ def _walk_prim_specs(spec) -> Iterator:
121
+ yield spec
122
+ for child in spec.nameChildren:
123
+ yield from _walk_prim_specs(child)
124
+
125
+
126
+ def _local_layers(stage: Usd.Stage) -> List[Sdf.Layer]:
127
+ """The layers this pipeline authors into -- the root layer stack.
128
+
129
+ Deliberately excludes referenced and payloaded layers: an `over` inside a
130
+ published asset is that asset's business, not this stage's.
131
+ """
132
+ session = stage.GetSessionLayer()
133
+ return [ly for ly in stage.GetLayerStack(includeSessionLayers=False) if ly != session]
134
+
135
+
136
+ # -- the checks ------------------------------------------------------------
137
+
138
+ def stale_overrides(stage: Usd.Stage, anchors: Dict[str, str], report: Report) -> None:
139
+ """`over` specs whose composed prim is not defined by anything.
140
+
141
+ The classic silent failure: the asset renamed Hat to Cap, the lighter's
142
+ visibility override stayed behind, and it composes to nothing forever.
143
+ """
144
+ for layer in _local_layers(stage):
145
+ for root in layer.rootPrims:
146
+ for spec in _walk_prim_specs(root):
147
+ report.specs_scanned += 1
148
+ if spec.specifier != Sdf.SpecifierOver:
149
+ continue
150
+ prim = stage.GetPrimAtPath(spec.path)
151
+ if prim and prim.IsValid() and prim.IsDefined():
152
+ continue # a legitimate sparse override -- says nothing
153
+ recorded = _nested_get(spec.customData, AUTHORED_AGAINST_KEY)
154
+ verdict, moved_to = _adjudicate(recorded, anchors)
155
+ report.findings.append(Finding(
156
+ kind="stale-override",
157
+ path=str(spec.path),
158
+ detail="over composes to nothing; no prim is defined here",
159
+ verdict=verdict,
160
+ layer=layer.identifier,
161
+ moved_to=moved_to,
162
+ ))
163
+
164
+
165
+ def overrides_under_instances(stage: Usd.Stage, report: Report) -> None:
166
+ """`over` specs beneath an instanceable prim, which USD silently discards."""
167
+ for layer in _local_layers(stage):
168
+ for root in layer.rootPrims:
169
+ for spec in _walk_prim_specs(root):
170
+ if spec.specifier != Sdf.SpecifierOver:
171
+ continue
172
+ prim = stage.GetPrimAtPath(spec.path)
173
+ if not (prim and prim.IsValid()):
174
+ continue
175
+ if not prim.IsInstanceProxy():
176
+ continue
177
+ report.findings.append(Finding(
178
+ kind="override-in-instance",
179
+ path=str(spec.path),
180
+ detail="beneath an instanceable prim; this opinion is discarded",
181
+ verdict=DEAD,
182
+ layer=layer.identifier,
183
+ ))
184
+
185
+
186
+ def dangling_targets(stage: Usd.Stage, anchors: Dict[str, str], report: Report) -> None:
187
+ """Relationship targets that resolve to nothing, or to a deactivated prim."""
188
+ for prim in stage.TraverseAll():
189
+ report.prims_scanned += 1
190
+ for rel in prim.GetRelationships():
191
+ # material:binding gets a more specific check of its own.
192
+ if rel.GetName().startswith("material:binding"):
193
+ continue
194
+ for target in rel.GetTargets():
195
+ prim_path = target.GetPrimPath()
196
+ tgt = stage.GetPrimAtPath(prim_path)
197
+
198
+ if not (tgt and tgt.IsValid()):
199
+ recorded = prim.GetCustomDataByKey(AUTHORED_AGAINST_KEY)
200
+ verdict, moved_to = _adjudicate(recorded, anchors)
201
+ report.findings.append(Finding(
202
+ kind="dangling-target",
203
+ path="%s.%s" % (prim.GetPath(), rel.GetName()),
204
+ detail="targets %s, which does not exist" % target,
205
+ verdict=verdict,
206
+ moved_to=moved_to,
207
+ ))
208
+ continue
209
+
210
+ if not tgt.IsActive():
211
+ report.findings.append(Finding(
212
+ kind="target-deactivated",
213
+ path="%s.%s" % (prim.GetPath(), rel.GetName()),
214
+ detail="targets %s, which is deactivated -- still resolves, "
215
+ "silently" % target,
216
+ verdict=DEAD,
217
+ ))
218
+ elif not tgt.IsDefined():
219
+ report.findings.append(Finding(
220
+ kind="dangling-target",
221
+ path="%s.%s" % (prim.GetPath(), rel.GetName()),
222
+ detail="targets %s, which is override-only and never "
223
+ "defined" % target,
224
+ verdict=UNKNOWN,
225
+ ))
226
+
227
+
228
+ def stripped_targets(stage: Usd.Stage, report: Report) -> None:
229
+ """Relationships whose targets were authored, then removed by composition.
230
+
231
+ When a relationship target points outside the scope of the reference that
232
+ brought it in, USD discards the target — the relationship survives with an
233
+ empty target list. A mesh whose material binding is emptied this way renders
234
+ unshaded.
235
+
236
+ USD does warn at composition time, but that warning goes to a log during a
237
+ scene open that prints hundreds of lines, and the stage is otherwise valid.
238
+ This finds it after the fact, which is when anyone actually looks.
239
+ """
240
+ for prim in stage.TraverseAll():
241
+ for rel in prim.GetRelationships():
242
+ if rel.GetTargets():
243
+ continue
244
+ authored = []
245
+ try:
246
+ for spec in rel.GetPropertyStack(Usd.TimeCode.Default()):
247
+ items = spec.targetPathList
248
+ for slot in ("explicitItems", "prependedItems", "appendedItems",
249
+ "addedItems"):
250
+ authored.extend(getattr(items, slot, ()) or ())
251
+ except Exception:
252
+ continue
253
+ if not authored:
254
+ continue # a relationship that never had targets is not a finding
255
+ report.findings.append(Finding(
256
+ kind="targets-stripped",
257
+ path="%s.%s" % (prim.GetPath(), rel.GetName()),
258
+ detail="targets %s were authored, then dropped in composition — "
259
+ "out of scope of the reference that brought them in"
260
+ % ", ".join(str(a) for a in authored[:3]),
261
+ verdict=DEAD,
262
+ ))
263
+
264
+
265
+ def material_bindings(stage: Usd.Stage, report: Report) -> None:
266
+ """Bindings to a material that is missing or deactivated.
267
+
268
+ ComputeBoundMaterial() hands back a valid UsdShade.Material wrapping an
269
+ inactive prim, so nothing downstream notices until a render looks wrong.
270
+ """
271
+ for prim in stage.Traverse():
272
+ rel = UsdShade.MaterialBindingAPI(prim).GetDirectBindingRel()
273
+ if not rel:
274
+ continue
275
+ for target in rel.GetTargets():
276
+ mat = stage.GetPrimAtPath(target.GetPrimPath())
277
+ if not (mat and mat.IsValid()):
278
+ report.findings.append(Finding(
279
+ kind="binding-missing",
280
+ path=str(prim.GetPath()),
281
+ detail="bound to %s, which does not exist" % target,
282
+ verdict=DEAD,
283
+ ))
284
+ elif not mat.IsActive():
285
+ report.findings.append(Finding(
286
+ kind="binding-deactivated",
287
+ path=str(prim.GetPath()),
288
+ detail="bound to %s, which is deactivated; ComputeBoundMaterial "
289
+ "still returns it" % target,
290
+ verdict=DEAD,
291
+ ))
292
+
293
+
294
+ def empty_collections(stage: Usd.Stage, report: Report) -> None:
295
+ """Collections that were authored with membership but now match nothing.
296
+
297
+ Katana's CEL failure mode, in USD: after a restructure the pattern matches
298
+ zero and nothing complains. Zero-match should be an error, not a no-op.
299
+ """
300
+ for prim in stage.TraverseAll():
301
+ try:
302
+ collections = Usd.CollectionAPI.GetAllCollections(prim)
303
+ except Exception:
304
+ return # API not available in this USD build; skip the whole check
305
+ for coll in collections:
306
+ includes = coll.GetIncludesRel()
307
+ authored = bool(includes and includes.GetTargets())
308
+ expr_attr = coll.GetMembershipExpressionAttr()
309
+ if not authored and not (expr_attr and expr_attr.HasAuthoredValue()):
310
+ continue # nothing was ever asked for; not a finding
311
+ try:
312
+ query = coll.ComputeMembershipQuery()
313
+ included = Usd.CollectionAPI.ComputeIncludedPaths(query, stage)
314
+ except Exception:
315
+ continue
316
+ if not included:
317
+ report.findings.append(Finding(
318
+ kind="empty-collection",
319
+ path=str(coll.GetCollectionPath()),
320
+ detail="membership was authored but matches zero prims",
321
+ verdict=DEAD,
322
+ ))
323
+
324
+
325
+ CHECKS = {
326
+ "stale-override": stale_overrides,
327
+ "override-in-instance": overrides_under_instances,
328
+ "dangling-target": dangling_targets,
329
+ "targets-stripped": stripped_targets,
330
+ "material-binding": material_bindings,
331
+ "empty-collection": empty_collections,
332
+ }
333
+
334
+
335
+ def scan(stage: Usd.Stage, anchor_key: str = ANCHOR_KEY) -> Report:
336
+ """Run every check against a composed stage."""
337
+ report = Report()
338
+ anchors = anchor_index(stage, anchor_key)
339
+ report.anchors_found = len(anchors)
340
+
341
+ stale_overrides(stage, anchors, report)
342
+ overrides_under_instances(stage, report)
343
+ dangling_targets(stage, anchors, report)
344
+ stripped_targets(stage, report)
345
+ material_bindings(stage, report)
346
+ empty_collections(stage, report)
347
+
348
+ order = {DEAD: 0, MOVED: 1, UNKNOWN: 2}
349
+ report.findings.sort(key=lambda f: (order.get(f.verdict, 3), f.kind, f.path))
350
+ return report
351
+
352
+
353
+ def scan_file(path: str, anchor_key: str = ANCHOR_KEY) -> Report:
354
+ stage = Usd.Stage.Open(path)
355
+ if not stage:
356
+ raise IOError("could not open stage: %s" % path)
357
+ return scan(stage, anchor_key)
deps/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """deps -- what depends on what, in USD, without opening a scene.
2
+
3
+ No stock USD API answers this. The composition-arc APIs report which *files* a
4
+ file needs and never see a material binding, a skel binding, an attribute
5
+ connection, or a class arc -- and none of them can tell you what points *at*
6
+ something, which is the question people actually ask before republishing.
7
+ """
8
+
9
+ from .graph import Cache, Graph, Unresolved, build, discover # noqa: F401
10
+ from .walk import ( # noqa: F401
11
+ ARC, ASSET_ATTR, COLLECTION, CONN, INHERIT, REL,
12
+ Edge, LayerRecord, walk_file, walk_layer,
13
+ )
14
+
15
+ __all__ = [
16
+ "Cache", "Graph", "Unresolved", "build", "discover",
17
+ "Edge", "LayerRecord", "walk_file", "walk_layer",
18
+ "ARC", "ASSET_ATTR", "COLLECTION", "CONN", "INHERIT", "REL",
19
+ ]