|
12 | 12 | """ |
13 | 13 |
|
14 | 14 | import json |
| 15 | +import warnings |
15 | 16 | from copy import deepcopy |
16 | 17 | from datetime import datetime, timezone |
17 | 18 | from pathlib import Path |
|
33 | 34 | from .ido import ndi_ido |
34 | 35 |
|
35 | 36 |
|
| 37 | +def _definition_to_doc_type(definition: str) -> str: |
| 38 | + """Turn a superclass ``definition`` path into a document type name. |
| 39 | +
|
| 40 | + Definitions name their target through a path VARIABLE: |
| 41 | + ``$NDIDOCUMENTPATH/element.json`` for NDI's own documents, |
| 42 | + ``$NDICALCDOCUMENTPATH/vision/contrast_tuning.json`` for ones shipped |
| 43 | + by NDIcalc-vis-matlab. MATLAB registers both as DID globals |
| 44 | + (ndi.common.PathConstants), so both resolve there. |
| 45 | +
|
| 46 | + Here they resolve to the same folder, because ndi_install.py copies |
| 47 | + each dependency's ``ndi_common`` tree into NDI-python's own. So every |
| 48 | + variable is stripped rather than only ``$NDIDOCUMENTPATH`` -- which is |
| 49 | + what previously left ``$NDICALCDOCUMENTPATH/...`` as a literal path, |
| 50 | + unfindable, and silently skipped. |
| 51 | + """ |
| 52 | + import re |
| 53 | + |
| 54 | + return re.sub(r"^\$[A-Z]+PATH/", "", definition).replace(".json", "") |
| 55 | + |
| 56 | + |
36 | 57 | class ndi_document: |
37 | 58 | """NDI document class for database storage. |
38 | 59 |
|
@@ -496,9 +517,7 @@ def doc_superclass(self) -> list[str]: |
496 | 517 | definition = sc.get("definition", "") |
497 | 518 | if definition: |
498 | 519 | try: |
499 | | - sc_doc = ndi_document( |
500 | | - definition.replace("$NDIDOCUMENTPATH/", "").replace(".json", "") |
501 | | - ) |
| 520 | + sc_doc = ndi_document(_definition_to_doc_type(definition)) |
502 | 521 | sc_names.append(sc_doc.doc_class()) |
503 | 522 | except Exception: |
504 | 523 | pass |
@@ -771,11 +790,33 @@ def read_blank_definition(document_type: str) -> dict: |
771 | 790 | Returns: |
772 | 791 | Dictionary with blank document structure. |
773 | 792 | """ |
774 | | - # Try to find the JSON definition |
775 | | - json_path = ndi_common_PathConstants.DOCUMENT_PATH / f"{document_type}.json" |
| 793 | + root = ndi_common_PathConstants.DOCUMENT_PATH |
| 794 | + json_path = root / f"{document_type}.json" |
| 795 | + |
| 796 | + if not json_path.exists() and "/" not in document_type: |
| 797 | + # Fall back to a recursive search for a BARE class name. |
| 798 | + # |
| 799 | + # MATLAB resolves a bare name against the whole document tree, |
| 800 | + # so ndi.document('image') finds data/image.json. Here only the |
| 801 | + # top level was tried, so the same call needed 'data/image' -- |
| 802 | + # a difference affecting every document in a subdirectory |
| 803 | + # (image, generic_file, the whole stimulus family) and one that |
| 804 | + # made otherwise-symmetric code diverge at the call site. |
| 805 | + # |
| 806 | + # Only for a bare name: a caller who wrote 'data/image' |
| 807 | + # expects that exact file, and should not silently get a |
| 808 | + # same-named document from elsewhere in the tree. |
| 809 | + matches = sorted(root.rglob(f"{document_type}.json")) |
| 810 | + if len(matches) == 1: |
| 811 | + json_path = matches[0] |
| 812 | + elif len(matches) > 1: |
| 813 | + rel = ", ".join(str(m.relative_to(root)) for m in matches) |
| 814 | + raise FileNotFoundError( |
| 815 | + f"ndi_document type {document_type!r} is ambiguous: {rel}. " |
| 816 | + f"Give the path, e.g. {matches[0].relative_to(root).with_suffix('')}." |
| 817 | + ) |
776 | 818 |
|
777 | 819 | if not json_path.exists(): |
778 | | - # Try without path constants (for testing) |
779 | 820 | raise FileNotFoundError( |
780 | 821 | f"ndi_document definition not found: {json_path}. " |
781 | 822 | f"Make sure NDI is properly installed." |
@@ -808,19 +849,36 @@ def read_blank_definition(document_type: str) -> dict: |
808 | 849 | continue |
809 | 850 |
|
810 | 851 | entry = dict(sc) if isinstance(sc, dict) else {"definition": sc_def} |
| 852 | + merged_superclasses.append(entry) |
811 | 853 |
|
812 | | - # Extract document type from definition path |
813 | | - sc_type = sc_def.replace("$NDIDOCUMENTPATH/", "").replace(".json", "") |
| 854 | + # Strip whatever $...PATH/ prefix the definition carries, not |
| 855 | + # just $NDIDOCUMENTPATH: calculator documents name their |
| 856 | + # superclasses through $NDICALCDOCUMENTPATH, and ndi_install.py |
| 857 | + # merges every dependency's ndi_common into one tree. |
| 858 | + sc_type = _definition_to_doc_type(sc_def) |
814 | 859 | try: |
815 | 860 | sc_props = ndi_document.read_blank_definition(sc_type) |
816 | 861 | except FileNotFoundError: |
817 | | - # Skip missing superclass definitions, but keep the entry: |
818 | | - # dropping it would silently shorten the superclass list. |
819 | | - merged_superclasses.append(entry) |
| 862 | + # Warn rather than swallow. A declared superclass that |
| 863 | + # cannot be found is a broken definition, and the document |
| 864 | + # silently loses everything that superclass would have |
| 865 | + # contributed -- including 'base'. That surfaced far away |
| 866 | + # as KeyError: 'base' at construction and took a bisect to |
| 867 | + # trace back here. Raising would be better still, but is a |
| 868 | + # wider behavioural change than this fix. |
| 869 | + # |
| 870 | + # The entry stays in the list either way: dropping it would |
| 871 | + # silently shorten the superclass list the schema is |
| 872 | + # validated against, trading one quiet failure for another. |
| 873 | + warnings.warn( |
| 874 | + f"superclass {sc_def!r} of {document_type!r} could " |
| 875 | + f"not be resolved (looked for {sc_type!r}); the " |
| 876 | + f"document will be missing whatever it defines", |
| 877 | + RuntimeWarning, |
| 878 | + stacklevel=2, |
| 879 | + ) |
820 | 880 | continue |
821 | 881 |
|
822 | | - merged_superclasses.append(entry) |
823 | | - |
824 | 882 | # The superclass's own superclasses join ours. sc_props is |
825 | 883 | # already merged, so this is the whole transitive closure. |
826 | 884 | sc_class = sc_props.get("document_class") or {} |
|
0 commit comments