File size: 17,393 Bytes
34f3bc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
"""Per-dataset Python schema registry β€” replaces meta/labvla_manifest.json.

A schema file is any Python module that either:
  (a) defines a module-level `SCHEMA: DatasetSchema`, or
  (b) calls `register(SCHEMA, name=..., aliases=...)` at import time.

Resolution order for `--dataset_schema <spec>`:
  1. Registry lookup by exact name (populated by `register()` or by the
     lazy auto-import of the in-repo `schemas/` package).
  2. Dotted Python path  `pkg.module:ATTR`  (optional `:ATTR`; defaults to `SCHEMA`).
  3. Absolute file path  `/abs/path/schema.py[:ATTR]`.
  4. Fail with fuzzy suggestions from the registry.

A register-only module (one that sets `SCHEMA = None` / omits `SCHEMA` and
instead `register(...)`s by name at import time β€” e.g. schemas/labutopia_level3.py)
is supported by BOTH the dotted-path and file-path forms: importing the module
runs its `register()` side effects, and resolution then falls back to a
registry lookup for `<spec>` rather than raising on the missing/None `SCHEMA`.
Pass `:ATTR` only when the module truly exposes that attribute.

The returned `DatasetSchema` is passed to `discover_schema(..., override=...)`
which is then threaded through the adapter. Downstream (hydrate_all, deploy)
is unchanged.
"""
from __future__ import annotations

import difflib
import importlib
import importlib.util
import logging
import os
from pathlib import Path
from typing import Iterable, Optional

from .dataset_schema import DatasetSchema
from .errors import SchemaSpecError  # noqa: F401  # re-export for backward compat

logger = logging.getLogger(__name__)

# name -> DatasetSchema instance. Populated at import time by `register()`.
_REGISTRY: dict[str, DatasetSchema] = {}

# Track aliases separately so error messages can hint at the canonical name.
_ALIASES: dict[str, str] = {}

# Idempotence guard: the lazy `schemas/` auto-import runs at most once per
# process; nested calls during `resolve()` just short-circuit.
_AUTOLOADED = False

# Sibling `schemas/*.py` modules that failed to import during the in-repo
# autoload, captured as ``[(module_name, exception), ...]``. Surfaced loudly on
# the first `resolve()` / `registered_names()` so a broken schema file does not
# silently masquerade as an "unknown schema".
_AUTOLOAD_FAILURES: list[tuple[str, Exception]] = []


def _raise_if_autoload_failed() -> None:
    """Fail loud if any in-repo `schemas/*.py` failed to import during autoload.

    Splices the real underlying exception(s) into a ``SchemaSpecError`` so the
    operator sees the actual ImportError root cause instead of a misleading
    "unknown schema" miss. Only fires when there is at least one failure β€” a
    clean ``schemas/`` directory leaves behavior unchanged.
    """
    if not _AUTOLOAD_FAILURES:
        return
    details = "; ".join(
        f"{name}: {type(exc).__name__}: {exc}" for name, exc in _AUTOLOAD_FAILURES
    )
    first_exc = _AUTOLOAD_FAILURES[0][1]
    raise SchemaSpecError(
        f"schema autoload failed for {len(_AUTOLOAD_FAILURES)} module(s) in the "
        f"in-repo schemas/ package: {details}. Fix the import error(s) above β€” a "
        f"broken schema module would otherwise look like an 'unknown schema'."
    ) from first_exc


def register(
    schema: DatasetSchema,
    *,
    name: Optional[str] = None,
    aliases: Iterable[str] = (),
) -> DatasetSchema:
    """Register a DatasetSchema under `name` (default: schema.schema_id).

    Also accepts optional aliases (useful when a dataset has both a short and
    long name, e.g. `labutopia_transport_beaker` + `Level3_TransportBeaker`).
    Returns the schema unchanged so callers can write `SCHEMA = register(...)`.
    """
    if not isinstance(schema, DatasetSchema):
        raise TypeError(
            f"register() expects DatasetSchema, got {type(schema).__name__}. "
            f"Construct it via schema.presets.franka8(...) or DatasetSchema(...)."
        )
    key = name or schema.schema_id
    if key in _REGISTRY and _REGISTRY[key] is not schema:
        existing = _REGISTRY[key]
        _same_source = False
        try:
            _same_source = (existing.source_path and schema.source_path
                            and Path(str(existing.source_path)).resolve()
                            == Path(str(schema.source_path)).resolve())
        except OSError:
            pass
        _equal = False
        try:
            _equal = existing.to_dict() == schema.to_dict()
        except Exception:  # noqa: BLE001
            pass
        if _same_source or _equal:
            # Benign re-import of the same module/schema (e.g. via a second
            # path spelling) β€” keep quiet idempotency.
            logger.debug("register(): re-registering identical schema %r", key)
        elif os.environ.get("LABVLA_ALLOW_SCHEMA_OVERRIDE") == "1":
            logger.warning(
                "register(): OVERRIDING schema %r (was schema_id=%s from %s, "
                "now schema_id=%s from %s) β€” explicitly allowed via "
                "LABVLA_ALLOW_SCHEMA_OVERRIDE=1",
                key, existing.schema_id, existing.source_path,
                schema.schema_id, schema.source_path,
            )
        else:
            # Schema identity drives layout/delta-mask/camera semantics; a
            # silent last-import-wins override would make a stable
            # --dataset_schema spec depend on import order.
            raise ValueError(
                f"register(): schema name {key!r} is already registered with "
                f"DIFFERENT content (existing from {existing.source_path}, "
                f"new from {schema.source_path}). Set "
                f"LABVLA_ALLOW_SCHEMA_OVERRIDE=1 to override deliberately."
            )
    _REGISTRY[key] = schema
    for a in aliases:
        if a == key:
            continue  # self-alias is a no-op, not a collision
        if a in _REGISTRY:
            # Alias would shadow an existing canonical entry β†’ refuse.
            # `resolve()` consults _ALIASES before _REGISTRY, so silently
            # accepting this makes the canonical schema under name `a`
            # unreachable by name.
            raise ValueError(
                f"register(): alias {a!r} collides with existing canonical "
                f"schema {a!r} (schema_id={_REGISTRY[a].schema_id}). "
                f"Pick a different alias."
            )
        if a in _ALIASES and _ALIASES[a] != key:
            logger.warning("register(): alias %r already points to %r, overwriting with %r",
                           a, _ALIASES[a], key)
        _ALIASES[a] = key
    return schema


def registered_names() -> list[str]:
    """Return canonical names known to the registry (not aliases).

    If any in-repo schema module failed to import during autoload the returned
    set would be silently incomplete, so fail loud with the real underlying
    exception instead of returning a partial list.
    """
    _ensure_autoload()
    _raise_if_autoload_failed()
    return sorted(_REGISTRY.keys())


def _ensure_autoload() -> None:
    """Lazy auto-import of the in-repo `schemas/` package + any dirs listed
    in the LABVLA_SCHEMA_PATH env var (colon-separated, PYTHONPATH-style).

    Done lazily so CLI cold-start isn't slowed down when `--dataset_schema`
    is unused, and to avoid surprising import side-effects in library code.
    """
    global _AUTOLOADED
    if _AUTOLOADED:
        return

    # Only set _AUTOLOADED=True AFTER successful completion. The
    # prior "set first, import later" meant a transient ImportError would
    # permanently disable schema discovery in the process (the next call
    # short-circuits). Now a failed import leaves the gate open for retry.

    # In-repo default package: import by name. The top-level `schemas/`
    # package's __init__.py performs the glob import of sibling files.
    _AUTOLOAD_FAILURES.clear()
    try:
        schemas_pkg = importlib.import_module("schemas")
    except ModuleNotFoundError:
        pass  # no in-repo schemas/ dir β€” user may rely on dotted/file specs only
    except Exception as e:  # pragma: no cover
        logger.warning("schemas/ auto-import raised %r; will retry on next call", e)
        return
    else:
        # Capture per-sibling import failures recorded by the package so
        # resolve()/registered_names() can surface them loudly.
        failures = getattr(schemas_pkg, "IMPORT_FAILURES", None)
        if failures:
            _AUTOLOAD_FAILURES.extend(failures)

    # External dirs from env.
    extra_paths = os.environ.get("LABVLA_SCHEMA_PATH", "")
    if extra_paths:
        for p in extra_paths.split(os.pathsep):
            p = p.strip()
            if not p:
                continue
            _import_all_py_in_dir(Path(p))

    _AUTOLOADED = True


def _import_all_py_in_dir(d: Path) -> None:
    if not d.is_dir():
        logger.warning("LABVLA_SCHEMA_PATH entry %s is not a directory β€” skipping", d)
        return
    for py in sorted(d.glob("*.py")):
        if py.name.startswith("_"):
            continue
        # LABVLA_SCHEMA_PATH files are imported for their register() side
        # effects; a module that only register()s (no top-level SCHEMA) is
        # valid here, so allow it instead of raising.
        _load_file(py, allow_register_only=True)


def _load_file(
    path: Path,
    attr: Optional[str] = None,
    *,
    allow_register_only: bool = False,
) -> Optional[DatasetSchema]:
    """Import a .py file and return the named attr (default: `SCHEMA`).

    Files loaded this way may also call `register()` at import time;
    the registry catches any side effects.

    A module may legitimately expose no top-level ``SCHEMA`` (or set
    ``SCHEMA = None``) and instead ``register(...)`` one-or-more schemas by name
    at import time.
    When ``allow_register_only`` is True, such a module returns ``None`` instead
    of raising, so the caller can fall back to a registry lookup. An explicit
    ``attr`` (the user asked for ``:NAME``) that is present-but-wrong-type is
    still a hard error regardless.
    """
    spec = importlib.util.spec_from_file_location(f"_labvla_schema_{path.stem}", path)
    if spec is None or spec.loader is None:
        raise ImportError(f"Cannot load Python schema file {path}")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)  # may call register() as side effect
    explicit_attr = attr is not None
    if attr is None:
        attr = "SCHEMA"
    if hasattr(mod, attr):
        val = getattr(mod, attr)
        if isinstance(val, DatasetSchema):
            return val
        # ``SCHEMA = None`` + register(...) is a valid register-only module.
        if val is None and not explicit_attr and allow_register_only:
            return None
        raise TypeError(
            f"{path}: attribute `{attr}` is {type(val).__name__}, expected DatasetSchema"
        )
    # No SCHEMA attr. If the file registered schemas by name instead, let the
    # caller retry a registry lookup; otherwise surface the missing attr.
    if allow_register_only and not explicit_attr:
        return None
    raise AttributeError(f"{path}: no top-level `{attr}` DatasetSchema found")


def resolve(spec: str) -> DatasetSchema:
    """Resolve `spec` to a DatasetSchema.

    Three forms (tried in order):
      1. Registered name     "labutopia_level3_transport_beaker"
      2. Dotted module[:ATTR] "my_pkg.my_schemas.foo"  or ":FOO"
      3. Absolute file[:ATTR] "/abs/path/schema.py:SCHEMA"

    Raises SchemaSpecError with a fuzzy suggestion on miss.
    """
    _ensure_autoload()

    # Form 3: absolute file path (possibly with :ATTR suffix).
    file_part, attr_part = _split_attr(spec)
    if file_part.endswith(".py") and Path(file_part).is_absolute():
        path = Path(file_part)
        if not path.exists():
            raise SchemaSpecError(f"schema file not found: {path}")
        # A register-only file (SCHEMA absent/None) imports its register()
        # side effects and returns None; fall through to the registry lookup /
        # miss-error below (which lists the names it registered) instead of
        # raising AttributeError.
        _pre = set(_REGISTRY)
        loaded = _load_file(path, attr_part, allow_register_only=True)
        if loaded is not None:
            return loaded
        # Register-only modules resolve by file path. Diff the registry around
        # the import: exactly one newly registered schema -> that's the answer;
        # several -> ambiguous, demand an explicit name.
        _new = [k for k in _REGISTRY if k not in _pre]
        if not _new:
            # In-repo modules are pre-registered by the autoload pass, so the
            # diff is empty β€” attribute by the registered schemas' source_path.
            _new = [k for k, v in _REGISTRY.items()
                    if getattr(v, "source_path", None)
                    and Path(v.source_path).resolve() == path.resolve()]
        if len(_new) == 1:
            return _REGISTRY[_new[0]]
        if len(_new) > 1:
            raise SchemaSpecError(
                f"{spec}: register-only file registered {len(_new)} schemas "
                f"({sorted(_new)}); pass the schema NAME instead of the file "
                f"path to disambiguate."
            )

    # Form 1: registry lookup.
    key = _ALIASES.get(spec, spec)
    if key in _REGISTRY:
        return _REGISTRY[key]

    # Form 2: dotted module path.
    if ":" in spec or "." in spec:
        mod_part, attr_part = _split_attr(spec)
        _pre_mod = set(_REGISTRY)
        try:
            mod = importlib.import_module(mod_part)
        except ModuleNotFoundError:
            pass
        else:
            attr_name = attr_part or "SCHEMA"
            explicit_attr = attr_part is not None
            if hasattr(mod, attr_name):
                val = getattr(mod, attr_name)
                if isinstance(val, DatasetSchema):
                    return val
                # ``SCHEMA = None`` (or a non-DatasetSchema SCHEMA) alongside
                # register(...) is a valid register-only module. When the user
                # did NOT ask for a specific ``:ATTR``, fall back to a registry
                # lookup (the import above ran register()) before treating the
                # wrong-type attr as a hard error.
                if not (val is None and not explicit_attr):
                    raise SchemaSpecError(
                        f"{spec}: `{attr_name}` is {type(val).__name__}, "
                        f"expected DatasetSchema"
                    )
            # Module imported but may have registered under a name.
            if spec in _REGISTRY:
                return _REGISTRY[spec]
            if spec in _ALIASES and _ALIASES[spec] in _REGISTRY:
                return _REGISTRY[_ALIASES[spec]]
            # Register-only module (SCHEMA=None + register() side effects) β€”
            # resolve via the registry diff like the file-path form. Exactly
            # one new schema -> return it; several -> demand an explicit name.
            _new_mod = [k for k in _REGISTRY if k not in _pre_mod]
            if not _new_mod and getattr(mod, "__file__", None):
                # In-repo modules are pre-registered by the autoload pass β€”
                # attribute by source_path instead of the (empty) diff.
                _mod_file = Path(mod.__file__).resolve()
                _new_mod = [k for k, v in _REGISTRY.items()
                            if getattr(v, "source_path", None)
                            and Path(v.source_path).resolve() == _mod_file]
            if len(_new_mod) == 1:
                return _REGISTRY[_new_mod[0]]
            if len(_new_mod) > 1:
                raise SchemaSpecError(
                    f"{spec}: register-only module registered {len(_new_mod)} "
                    f"schemas ({sorted(_new_mod)}); pass the schema NAME "
                    f"instead of the module path to disambiguate."
                )

    # Miss. Before reporting "unknown schema", surface any in-repo autoload
    # import failure β€” the spec may be missing precisely because the module
    # that would have registered it failed to import.
    _raise_if_autoload_failed()

    # Suggest closest names.
    candidates = registered_names()
    suggestions = difflib.get_close_matches(spec, candidates, n=3, cutoff=0.5)
    hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
    raise SchemaSpecError(
        f"unknown --dataset_schema {spec!r}.{hint} "
        f"Known names: {candidates}. "
        f"You can also pass a dotted module `pkg.mod:ATTR` or an absolute path "
        f"`/abs/path/file.py[:ATTR]`."
    )


def _split_attr(spec: str) -> tuple[str, Optional[str]]:
    """Split `'module:ATTR'` or `'/abs/path.py:ATTR'` into `(head, attr)`.

    Splits on the **rightmost** `:` so that absolute paths like
    `/a/b.py:SCHEMA` parse correctly. Only treats the right half as an attr
    when it's a valid Python identifier β€” otherwise returns `(spec, None)`.
    """
    if ":" not in spec:
        return spec, None
    idx = spec.rfind(":")
    right = spec[idx + 1:]
    if right.isidentifier():
        return spec[:idx], right
    return spec, None


__all__ = [
    "register",
    "resolve",
    "registered_names",
    "SchemaSpecError",
]