Source code for valueset

import json
import re
from pathlib import Path
from odmlib.exceptions import OdmlibValidationError


class _UnknownAttribute:
    """Sentinel returned by :meth:`ValueSet.value_set` when an attribute key
    is absent from the resolved version *and* every fallback version.

    Distinct from an unknown *version*, which still raises
    ``OdmlibValidationError``. Returning a sentinel (rather than raising)
    lets ``validate()`` report ``False`` so the ``SKIP_VALUESET`` permissive
    guard in ``ValidValues.__set__`` can take effect for unregistered keys.
    """

    __slots__ = ()

    def __repr__(self):
        return "<ValueSet.UNKNOWN_ATTRIBUTE>"


UNKNOWN_ATTRIBUTE = _UnknownAttribute()


[docs] class ValueSetLoader: """Loads and caches valueset data from JSON file""" _cache = None # Class variable for singleton cache _version_map = None # Maps module paths to versions
[docs] @classmethod def load_valuesets(cls): """Load JSON once, cache in memory (lazy loading)""" if cls._cache is None: data_dir = Path(__file__).parent / 'data' json_path = data_dir / 'valuesets.json' with open(json_path, 'r', encoding='utf-8') as f: cls._cache = json.load(f) cls._build_version_map() return cls._cache
@classmethod def _build_version_map(cls): """Map module paths to version keys""" cls._version_map = { 'odmlib.odm_1_3_2.model': 'odm_1_3_2', 'odmlib.odm_2_0.model': 'odm_2_0', 'odmlib.define_2_0.model': 'define_2_0', 'odmlib.define_2_1.model': 'define_2_1', 'odmlib.arm_1_0.model': 'define_2_1', # ARM extends Define-XML 2.1 'odmlib.ct_1_1_1.model': 'ct_1_1_1', 'odmlib.dataset_1_0_1.model': 'dataset_1_0_1', # Add test models 'tests.model_extended': 'odm_1_3_2', # Test extension uses base } # Pattern markers checked against unknown module paths, in priority order. # Order matters: 'odm_2_0' must be tried before 'odm_1_3_2' substrings, etc. _MODULE_PATTERNS = ( ('odm_2_0', 'odm_2_0'), ('define_2_1', 'define_2_1'), ('arm_1_0', 'define_2_1'), ('define_2_0', 'define_2_0'), ('ct_1_1_1', 'ct_1_1_1'), ('dataset_1_0_1', 'dataset_1_0_1'), )
[docs] @classmethod def get_version_for_module(cls, module_path, instance_class=None): """Determine the valueset version for a module path. Resolution order: 1. Exact match in the version map. 2. Substring marker match against the module path. 3. If ``instance_class`` is provided, walk its MRO and recurse on each base class's module — this lets local models that subclass a shipped odmlib model inherit the correct version. 4. Fallback: ``odm_1_3_2`` for backward compatibility. """ if cls._version_map is None: cls.load_valuesets() if module_path in cls._version_map: return cls._version_map[module_path] for marker, version in cls._MODULE_PATTERNS: if marker in module_path: return version if instance_class is not None: for base in instance_class.__mro__[1:]: base_module = getattr(base, '__module__', '') if not base_module or base_module == 'builtins': continue if base_module in cls._version_map: return cls._version_map[base_module] for marker, version in cls._MODULE_PATTERNS: if marker in base_module: return version return 'odm_1_3_2'
[docs] class ValueSet: """Backward-compatible interface to valueset data. Supports three entry types in valuesets.json: - **list**: A closed set of allowed string values (e.g., ``["Yes", "No"]``) - **regex dict**: A dict with ``_regex`` key and optional ``_description`` (e.g., ``{"_regex": "^2\\\\.[01](\\\\.\\\\d+)?$", "_description": "..."}``). - **open dict**: A dict with ``_values`` and ``_open: true`` for an *extensible* vocabulary — the XSD models these as a union of an enumeration with bare ``xs:string``, so any value is permitted while the listed terms remain the documented ones (e.g., ``{"_values": ["Form", "Dataset"], "_open": true}``). .. versionadded:: 0.2.1 The open-dict form. ODM 2.0 uses extensible unions for ``ItemGroupDef/@Type``, ``TrialPhase/@Value`` and ``Standard/@Status``; enforcing them as closed lists rejected schema-valid documents. """ #: Sentinel returned by :meth:`value_set` for an unknown attribute key #: (see :class:`_UnknownAttribute`). Exposed for callers/tests. UNKNOWN_ATTRIBUTE = UNKNOWN_ATTRIBUTE _compiled_regex_cache = {} # (version, attribute) -> compiled re.Pattern # Versions tried (in order) when an attribute is missing from the resolved # version. Hybrid local models (e.g. an ODM 1.3.2 base extended with # Define-XML 2.x attributes) need this to find Define-only keys. _FALLBACK_VERSIONS = ( 'define_2_1', 'define_2_0', 'odm_2_0', 'odm_1_3_2', 'ct_1_1_1', 'dataset_1_0_1', ) @classmethod def _resolve_version(cls, version, instance): """Resolve the version string from explicit value or instance.""" if version is None and instance is not None: instance_class = type(instance) module_path = instance_class.__module__ return ValueSetLoader.get_version_for_module( module_path, instance_class=instance_class, ) elif version is None: return 'odm_1_3_2' return version
[docs] @classmethod def value_set(cls, attribute, version=None, instance=None): """ Get the raw valueset entry for an attribute. Args: attribute: "ClassName.AttributeName" format version: Explicit version (e.g., "odm_2_0") - optional instance: ODMElement instance for automatic version detection - optional Returns: list or dict: A list of valid values, or a dict with ``_regex`` key for pattern-based validation. Returns the :data:`UNKNOWN_ATTRIBUTE` sentinel if the attribute key is not registered for the resolved version or any fallback version. Raises: OdmlibValidationError: If the *version* is not found in the valueset. An unknown *attribute* no longer raises — it returns the :data:`UNKNOWN_ATTRIBUTE` sentinel instead. """ version = cls._resolve_version(version, instance) # Load valuesets (cached) valuesets = ValueSetLoader.load_valuesets() # Get version-specific valueset if version not in valuesets: raise OdmlibValidationError( f"Unknown version {version} in ValueSet", hint=f"Valid versions are: {list(valuesets.keys())}", ) version_valueset = valuesets[version] # Get attribute values if attribute in version_valueset: return version_valueset[attribute] # Cross-version fallback: hybrid local models (e.g. an ODM 1.3.2 base # extended with def: attributes) may carry attribute keys that only # exist in another shipped version's valueset. Search the others. for other in cls._FALLBACK_VERSIONS: if other == version: continue other_valueset = valuesets.get(other) if other_valueset is not None and attribute in other_valueset: return other_valueset[attribute] # Unknown *attribute* (absent here and in every fallback version): # return a sentinel rather than raising, so validate() can report # False and the SKIP_VALUESET permissive guard can take effect. # (Unknown *version* still raises, above.) return UNKNOWN_ATTRIBUTE
[docs] @classmethod def validate(cls, attribute, value, version=None, instance=None): """ Check whether a value is valid for the given attribute. Args: attribute: "ClassName.AttributeName" format value: The string value to validate version: Explicit version (e.g., "odm_2_0") - optional instance: ODMElement instance for automatic version detection - optional Returns: bool: True if the value is valid, False otherwise. """ version = cls._resolve_version(version, instance) entry = cls.value_set(attribute, version=version) if entry is UNKNOWN_ATTRIBUTE: # No registered value set for this attribute: not provably # valid -> False. This lets ValidValues.__set__ reach the # SKIP_VALUESET permissive guard instead of the old raise # propagating out of value_set(). return False if isinstance(entry, list): return value in entry # Open (extensible) vocabulary: the listed values are documentation, # any string is permitted. if isinstance(entry, dict) and entry.get("_open"): return isinstance(value, str) # Regex dict entry if isinstance(entry, dict) and "_regex" in entry: cache_key = (version, attribute) if cache_key not in cls._compiled_regex_cache: cls._compiled_regex_cache[cache_key] = re.compile(entry["_regex"]) pattern = cls._compiled_regex_cache[cache_key] return pattern.fullmatch(value) is not None return False
[docs] @classmethod def describe(cls, attribute, version=None, instance=None): """ Return a human-readable description of valid values for an attribute. Args: attribute: "ClassName.AttributeName" format version: Explicit version (e.g., "odm_2_0") - optional instance: ODMElement instance for automatic version detection - optional Returns: str: Description suitable for error messages. """ version = cls._resolve_version(version, instance) entry = cls.value_set(attribute, version=version) if entry is UNKNOWN_ATTRIBUTE: return f"No registered value set for {attribute}" if isinstance(entry, list): return f"Value must be one of: {', '.join(entry)}" if isinstance(entry, dict) and entry.get("_open"): if "_description" in entry: return entry["_description"] listed = ", ".join(entry.get("_values", [])) return (f"Extensible value set — the defined terms are: {listed}. " f"Other values are permitted.") if isinstance(entry, dict) and "_regex" in entry: if "_description" in entry: return entry["_description"] return f"Value must match pattern: {entry['_regex']}" return "Unknown valueset format"