"""Schema entry classes representing individual nodes in the HED vocabulary."""
from __future__ import annotations
import itertools
import math
from typing import Any
import inflect
from hed.schema.hed_schema_constants import HedKey, HedSectionKey
pluralize = inflect.engine()
pluralize.defnoun("hertz", "hertz")
[docs]
class HedSchemaEntry:
"""A single node in the HED schema vocabulary.
Every term, unit, unit class, value class, attribute, and property that
appears in a loaded :class:`~hed.schema.HedSchema` is represented as a
``HedSchemaEntry`` (or one of its subclasses). The entry stores the node's
name, all declared attributes (e.g. ``takesValue``, ``allowedCharacter``),
its description, and a back-reference to its containing
:class:`~hed.schema.HedSchemaSection`.
Concrete subclasses add section-specific state:
- :class:`HedTagEntry` — vocabulary tag nodes.
- :class:`UnitClassEntry` — unit class nodes (e.g. *time*, *mass*).
- :class:`UnitEntry` — individual unit nodes (e.g. *second*, *gram*).
**Use this class (or its subclasses) directly when you need to:**
- Introspect schema vocabulary (e.g. list all tags with ``takesValue``).
- Build schema validators, schema browsers, or schema-diff tools.
- Implement custom HED annotation tooling that looks up tag metadata.
**Most users never need this class** — :meth:`~hed.schema.HedSchema.get_tag_entry`
and :meth:`~hed.schema.HedSchema.get_all_schema_tags` are sufficient for the
common lookup patterns.
"""
[docs]
def __init__(self, name, section):
"""Constructor for HedSchemaEntry.
Parameters:
name (str): The name of the entry.
section (HedSchemaSection): The section to which it belongs.
"""
self.name = name
# key: property/attribute name, value = property value. Will often be a bool
self.attributes = {}
self.description = None
self._section = section
# This section is largely unused. It will only be filled in when we try to add an attribute
# that isn't valid in this section.
self._unknown_attributes = None
[docs]
def finalize_entry(self, schema):
"""Called once after loading to set internal state.
Parameters:
schema (HedSchema): The schema that holds the rules.
"""
# Clear out any known attributes from the unknown section
to_remove = []
if self._unknown_attributes:
for attribute in self._unknown_attributes:
if attribute in self._section.valid_attributes:
to_remove.append(attribute)
for item in to_remove:
self._unknown_attributes.pop(item)
[docs]
def has_attribute(self, attribute, return_value=False) -> bool | Any:
"""Checks for the existence of an attribute in this entry.
Parameters:
attribute (str): The attribute to check for.
return_value (bool): If True, returns the actual value of the attribute.
If False, returns a boolean indicating the presence of the attribute.
Returns:
Union[bool, any]: If return_value is False, returns True if the attribute exists and False otherwise.
If return_value is True, returns the value of the attribute if it exists, else returns None.
Notes:
- The existence of an attribute does not guarantee its validity.
"""
if return_value:
return self.attributes.get(attribute, None)
else:
return attribute in self.attributes
[docs]
def attribute_has_property(self, attribute, property_name) -> bool:
"""Return True if attribute has property.
Parameters:
attribute (str): Attribute name to check for property_name.
property_name (str): The property value to return.
Returns:
bool: Returns True if this entry has the property.
"""
attr_entry = self._section.valid_attributes.get(attribute)
if attr_entry and attr_entry.has_attribute(property_name):
return True
return False
def _set_attribute_value(self, attribute, attribute_value):
"""Add attribute and set its value.
Parameters:
attribute (str): The name of the schema entry attribute.
attribute_value (bool or str): The value of the attribute.
Notes:
- If this an invalid attribute name, it will be also added as an unknown attribute.
"""
if not attribute_value:
return
# todo: remove this patch and redo the code
# This check doesn't need to be done if the schema is valid.
if attribute not in self._section.valid_attributes:
if self._unknown_attributes is None:
self._unknown_attributes = {}
self._unknown_attributes[attribute] = attribute_value
self.attributes[attribute] = attribute_value
@property
def section_key(self):
"""Returns the HedSectionKey identifying which schema section owns this entry.
Returns:
HedSectionKey: The section key for this entry's parent section.
"""
return self._section.section_key
def __eq__(self, other):
if self.name != other.name:
return False
if not self._compare_attributes_no_order(self.attributes, other.attributes):
return False
if self.description != other.description:
return False
return True
def __hash__(self):
return hash(self.name)
def __str__(self):
return self.name
@staticmethod
def _compare_attributes_no_order(left, right):
if left != right:
left = {
name: (set(value.split(",")) if isinstance(value, str) else value) for (name, value) in left.items()
}
right = {
name: (set(value.split(",")) if isinstance(value, str) else value) for (name, value) in right.items()
}
return left == right
[docs]
class UnitClassEntry(HedSchemaEntry):
"""A unit class node in the HED schema (e.g. *time*, *mass*, *frequency*).
Extends :class:`HedSchemaEntry` with the set of :class:`UnitEntry` objects
that belong to the class and a pre-computed ``derivative_units`` dict that
maps every accepted surface form (including SI prefixes and plurals) to its
canonical :class:`UnitEntry`.
Typical access pattern::
unit_class = schema.get_tag_entry("time", HedSectionKey.UnitClasses)
for name, unit in unit_class.units.items():
print(name, unit.attributes)
Attributes:
units (dict[str, UnitEntry]): Map from unit name to entry after
:meth:`finalize_entry` is called.
derivative_units (dict[str, UnitEntry]): Map from every accepted
surface form (plural, SI-prefixed, etc.) to the base unit entry.
"""
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._units = []
self.units = []
self.derivative_units = {}
@property
def children(self):
"""Alias to get the units for this class
Returns:
unit_list(list): The unit list for this class
"""
return self.units
[docs]
def add_unit(self, unit_entry):
"""Add the given unit entry to this unit class.
Parameters:
unit_entry (HedSchemaEntry): Unit entry to add.
"""
self._units.append(unit_entry)
[docs]
def finalize_entry(self, schema):
"""Called once after schema load to set state.
Parameters:
schema (HedSchema): The object with the schema rules.
"""
super().finalize_entry(schema)
self.units = {unit_entry.name: unit_entry for unit_entry in self._units}
for unit_entry in self.units.values():
unit_entry.unit_class_entry = self
derivative_units = {}
for unit_entry in self.units.values():
derivative_units.update(dict.fromkeys(unit_entry.derivative_units.keys(), unit_entry))
self.derivative_units = derivative_units
def __eq__(self, other):
if not super().__eq__(other):
return False
if self.units != other.units:
return False
return True
[docs]
def get_derivative_unit_entry(self, units):
"""Gets the (derivative) unit entry if it exists
All unit strings are case-sensitive, so this is a single exact lookup: unit names may be pluralized
and carry an SI modifier, unit symbols may carry a modifier but are never pluralized, and none of
them may change case (``milliseconds`` and ``uV`` match; ``Milliseconds``, ``MS`` and ``UV`` do not).
Parameters:
units (str): The unit string as written, possibly plural or with a modifier.
Returns:
Union[UnitEntry, None]: The unit entry if it exists.
"""
return self.derivative_units.get(units)
[docs]
class UnitEntry(HedSchemaEntry):
"""A single unit node in the HED schema (e.g. *second*, *gram*, *hertz*).
Extends :class:`HedSchemaEntry` with the list of SI unit modifiers that
apply to this unit, a pre-computed ``derivative_units`` mapping (surface
form → conversion factor), and a back-reference to the parent
:class:`UnitClassEntry`.
Attributes:
unit_modifiers (list[HedSchemaEntry]): SI modifier entries (e.g. *milli*, *kilo*).
derivative_units (dict[str, float]): Map from every accepted surface
form to its numeric conversion factor relative to the SI base unit.
unit_class_entry (UnitClassEntry): The parent unit class.
"""
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.unit_modifiers = []
self.derivative_units = {}
self.unit_class_entry = None
[docs]
def finalize_entry(self, schema):
"""Called once after loading to set internal state.
Parameters:
schema (HedSchema): The schema rules come from.
Notes:
Unit strings are case-sensitive, so every key is built from the name exactly as listed.
Unit names may be pluralized; unit symbols never are. A compound SI unit (one whose name
contains ``-per-`` or ``^``, such as ``m-per-s^2``) is expanded component-wise: each
component may carry one SI modifier, so ``cm-per-us`` and ``mm^3`` are accepted while
``kmm-per-s`` is not. Every other unit accepts one modifier in front of the whole name.
"""
super().finalize_entry(schema)
self.unit_modifiers = schema._get_modifiers_for_unit(self.name)
components = self._compound_components()
if components is not None:
self.derivative_units = self._compound_derivative_units(components)
return
derivative_units = {}
if self.has_attribute(HedKey.UnitSymbol):
base_plural_units = {self.name}
else:
base_plural_units = {self.name, pluralize.plural(self.name)}
base_factor = self._parse_factor(self.attributes)
for derived_unit in base_plural_units:
derivative_units[derived_unit] = base_factor
for modifier in self.unit_modifiers:
derivative_units[modifier.name + derived_unit] = base_factor * self._parse_factor(modifier.attributes)
self.derivative_units = derivative_units
def _compound_components(self):
"""Split a compound SI unit name into its components.
Returns:
Union[list[tuple[str, int]], None]: ``(base, exponent)`` per component, in name order, when this
unit has SIUnit and its name contains ``-per-`` or ``^``; None for every other unit. The
first component is the numerator, the rest are denominators. None is also returned when
a component is not of the form ``base`` or ``base^n`` with an integer ``n``, so a name the
rule does not describe falls back to the whole-string path.
"""
if not self.has_attribute(HedKey.SIUnit) or ("-per-" not in self.name and "^" not in self.name):
return None
components = []
for component in self.name.split("-per-"):
base, _, exponent = component.partition("^")
if not base:
return None
if not exponent:
components.append((base, 1))
continue
try:
components.append((base, int(exponent)))
except ValueError:
return None
return components
def _compound_derivative_units(self, components):
"""Build the derivative map of a compound unit, one optional modifier per component.
Parameters:
components (list[tuple[str, int]]): Output of :meth:`_compound_components`.
Returns:
dict[str, float]: Every accepted surface form mapped to its conversion factor. The factor is
the listed factor times the product of each component's modifier factor raised to the
component's exponent, negative for denominator components: ``cm-per-us`` in speed is
``1.0 * 0.01 * (1e-6) ** -1 = 10000`` m-per-s and ``mm^3`` is ``1e-9`` m^3.
"""
base_factor = self._parse_factor(self.attributes)
modifier_choices = [("", 1.0)] + [
(modifier.name, self._parse_factor(modifier.attributes)) for modifier in self.unit_modifiers
]
derivative_units = {}
for choice in itertools.product(modifier_choices, repeat=len(components)):
parts = []
factor = base_factor
for index, ((base, exponent), (prefix, modifier_factor)) in enumerate(
zip(components, choice, strict=False)
):
parts.append(prefix + base + (f"^{exponent}" if exponent != 1 else ""))
signed_exponent = exponent if index == 0 else -exponent
factor *= math.pow(modifier_factor, signed_exponent)
derivative_units["-per-".join(parts)] = factor
return derivative_units
@staticmethod
def _parse_factor(attributes):
"""Return the conversionFactor in *attributes* as a float, or 1.0 when absent or unparsable."""
try:
return float(attributes.get(HedKey.ConversionFactor, "1.0").replace("^", "e"))
except (ValueError, AttributeError):
return 1.0
[docs]
def get_conversion_factor(self, unit_name):
"""Returns the conversion factor from combining this unit with the specified modifier
Parameters:
unit_name (str or None): the full name of the unit with modifier
Returns:
Union[float, None]: Returns the conversion factor, or None if this unit has no conversionFactor
or unit_name is not one of its accepted forms.
"""
if HedKey.ConversionFactor in self.attributes:
factor = self.derivative_units.get(unit_name)
if factor is not None:
return float(factor)
return None
[docs]
class HedTagEntry(HedSchemaEntry):
"""A vocabulary tag node in the HED schema.
Extends :class:`HedSchemaEntry` with full/short tag name forms, value-class
and unit-class associations, and helper methods for tag-path traversal.
Typical access pattern::
entry = schema.get_tag_entry("Sensory-event")
print(entry.long_tag_name) # "Event/Sensory-event"
print(entry.takes_value_child) # child "#" entry if tag takes a value
Attributes:
unit_classes (dict[str, UnitClassEntry]): Unit classes accepted by this
tag\'s value (non-empty only if ``takesValue`` is set).
value_classes (dict[str, HedSchemaEntry]): Value classes that constrain
the value format.
long_tag_name (str): The full slash-separated path from the schema root,
with any trailing ``/#`` stripped.
short_tag_name (str): The final component of the tag path (short form).
"""
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.unit_classes = {}
self.value_classes = {}
# These always have any /# stripped off the end, so they can easily be used with normal code.
self.long_tag_name = None
self.short_tag_name = None
self.takes_value_child_entry = None # this is a child takes value tag, if one exists
self._parent_tag = None
self.tag_terms = ()
# During setup, it's better to have attributes shadow inherited before getting its own copy later.
self.inherited_attributes = self.attributes
# Descendent tags below this one
self.children = {}
def __eq__(self, other):
if not super().__eq__(other):
return False
if not self._compare_attributes_no_order(self.inherited_attributes, other.inherited_attributes):
return False
return True
[docs]
def has_attribute(self, attribute, return_value=False):
"""Returns th existence or value of an attribute in this entry.
This also checks parent tags for inheritable attributes like ExtensionAllowed.
Parameters:
attribute (str): The attribute to check for.
return_value (bool): If True, returns the actual value of the attribute.
If False, returns a boolean indicating the presence of the attribute.
Returns:
Union[bool, any]: If return_value is False, returns True if the attribute exists and False otherwise.
If return_value is True, returns the value of the attribute if it exists, else returns None.
Notes:
- The existence of an attribute does not guarantee its validity.
"""
val = self.inherited_attributes.get(attribute)
if not return_value:
val = val is not None
return val
def _check_inherited_attribute_internal(self, attribute):
"""Gather up all instances of an attribute from this entry and any parent entries"""
attribute_values = []
iter_entry = self
while iter_entry is not None:
if iter_entry.takes_value_child_entry:
break
if attribute in iter_entry.attributes:
attribute_values.append(iter_entry.attributes[attribute])
iter_entry = iter_entry._parent_tag
return attribute_values
def _check_inherited_attribute(self, attribute, return_value=False):
"""
Checks for the existence of an attribute in this entry and its parents.
Parameters:
attribute (str): The attribute to check for.
return_value (bool): If True, returns the actual value of the attribute.
If False, returns a boolean indicating the presence of the attribute.
Returns:
Union[bool, any]: Depending on the flag return_value,
returns either the presence of the attribute, or its value.
Notes:
- The existence of an attribute does not guarantee its validity.
- For string attributes, the values are joined with a comma as a delimiter from all ancestors.
- For other attributes, only the value closest to the leaf is returned
"""
attribute_values = self._check_inherited_attribute_internal(attribute)
if return_value:
if not attribute_values:
return None
try:
return ",".join(attribute_values)
except TypeError:
return attribute_values[0] # Return the lowest level attribute if we don't want the union
return bool(attribute_values)
[docs]
def base_tag_has_attribute(self, tag_attribute):
"""Check if the base tag has a specific attribute.
Parameters:
tag_attribute (str): A tag attribute.
Returns:
bool: True if the tag has the specified attribute. False, if otherwise.
Notes:
This mostly is relevant for takes value tags.
"""
base_entry = self
if self.has_attribute(HedKey.TakesValue):
base_entry = base_entry._parent_tag
return base_entry.has_attribute(tag_attribute)
@property
def parent(self):
"""Get the parent entry of this tag"""
return self._parent_tag
@property
def parent_name(self):
"""Gets the parent tag entry name"""
if self._parent_tag:
return self._parent_tag.name
parent_name, _, child_name = self.name.rpartition("/")
return parent_name
def _finalize_classes(self, schema, attribute_key, section_key):
result = {}
if attribute_key in self.attributes:
for attribute_name in self.attributes[attribute_key].split(","):
entry = schema._get_tag_entry(attribute_name, section_key)
if entry:
result[attribute_name] = entry
return result
def _finalize_takes_value_tag(self, schema):
if self.name.endswith("/#"):
self.unit_classes = self._finalize_classes(schema, HedKey.UnitClass, HedSectionKey.UnitClasses)
self.value_classes = self._finalize_classes(schema, HedKey.ValueClass, HedSectionKey.ValueClasses)
def _finalize_inherited_attributes(self):
# Replace the list with a copy we can modify.
self.inherited_attributes = self.attributes.copy()
for attribute in self._section.inheritable_attributes:
value = self._check_inherited_attribute(attribute, return_value=True)
# None means "not found in the hierarchy"; attribute values themselves are never None.
if value is not None:
self.inherited_attributes[attribute] = value
[docs]
def finalize_entry(self, schema):
"""Called once after schema loading to set state.
Parameters:
schema (HedSchema): The schema that the rules come from.
"""
super().finalize_entry(schema)
# Set the parent and child pointers. Child is just for "takes value"
parent_name, _, child_name = self.name.rpartition("/")
parent_tag = None
if parent_name:
parent_tag = schema._get_tag_entry(parent_name)
self._parent_tag = parent_tag
if self._parent_tag:
self._parent_tag.children[self.short_tag_name] = self
self.takes_value_child_entry = schema._get_tag_entry(self.name + "/#")
self.tag_terms = tuple(self.long_tag_name.casefold().split("/"))
self._finalize_inherited_attributes()
self._finalize_takes_value_tag(schema)