Source code for hed.models.hed_tag

"""A single HED tag."""

from __future__ import annotations

import copy
from typing import TYPE_CHECKING

from hed.models.model_constants import DefTagNames
from hed.schema.hed_schema_constants import HedKey

if TYPE_CHECKING:
    from hed.models.hed_group import HedGroup


[docs] class HedTag: """A single HED tag. Notes: - HedTag is a smart class in that it keeps track of its original value and positioning as well as pointers to the relevant HED schema information, if relevant. """
[docs] def __init__(self, hed_string, hed_schema, span=None, def_dict=None): """Creates a HedTag. Parameters: hed_string (str): Source HED string for this tag. hed_schema (HedSchema): A parameter for calculating canonical forms on creation. span (int, int): The start and end indexes of the tag in the hed_string. def_dict (DefinitionDict or None): The def dict to use to identify def/def expand tags. """ self._hed_string = hed_string if span is None: span = (0, len(hed_string)) # This is the span into the original HED string for this tag self.span = span # If this is present, use this as the org tag for most purposes. # This is not generally used anymore, but you can use it to replace a tag in place. self._tag = None self._namespace = self._get_schema_namespace(self.org_tag) # This is the schema this tag was converted to. self._schema = None self._schema_entry = None self._extension_value = "" self._parent = None self._expandable = None self._expanded = False self.tag_terms = None # tuple of all the terms in this tag Lowercase. self._calculate_to_canonical_forms(hed_schema) self._def_entry = None if def_dict: if self.short_base_tag in {DefTagNames.DEF_KEY, DefTagNames.DEF_EXPAND_KEY}: self._def_entry = def_dict.get_definition_entry(self)
[docs] def copy(self) -> HedTag: """Return a deep copy of this tag. Returns: HedTag: The copied group. """ save_parent = self._parent self._parent = None return_copy = copy.deepcopy(self) self._parent = save_parent return return_copy
@property def schema_namespace(self) -> str: """Library namespace for this tag if one exists. Returns: str: The library namespace, including the colon. """ return self._namespace @property def short_tag(self) -> str: """Short form including value or extension. Returns: str: The short form of the tag, including value or extension. """ if self._schema_entry: return f"{self._namespace}{self._schema_entry.short_tag_name}{self._extension_value}" return str(self) @property def base_tag(self) -> str: """Long form without value or extension. Returns: str: The long form of the tag, without value or extension. """ if self._schema_entry: return self._schema_entry.long_tag_name return str(self) @property def short_base_tag(self) -> str: """Short form without value or extension. Returns: str: The short non-extension port of a tag. Notes: - ParentNodes/Def/DefName would return just "Def". """ if self._schema_entry: return self._schema_entry.short_tag_name return str(self) @short_base_tag.setter def short_base_tag(self, new_tag_val): """Change base tag, leaving extension or value. Parameters: new_tag_val (str): The new short_base_tag for this tag. Raises: ValueError: If the tag wasn't already identified. Note: - Generally this is used to swap def to def-expand. """ if self._schema_entry: tag_entry = None if self._schema: if self.is_takes_value_tag(): new_tag_val = new_tag_val + "/#" tag_entry = self._schema.get_tag_entry(new_tag_val, schema_namespace=self.schema_namespace) self._schema_entry = tag_entry else: raise ValueError("Cannot set unidentified tags") @property def org_base_tag(self) -> str: """Original form without value or extension. Returns: str: The original form of the tag, without value or extension. Notes: - Warning: This could be empty if the original tag had a name_prefix prepended. e.g. a column where "Label/" is prepended, thus the column value has zero base portion. """ if self._schema_entry: extension_len = len(self._extension_value) if not extension_len: return self.tag org_len = len(self.tag) if org_len == extension_len: return "" return self.tag[: org_len - extension_len] return str(self)
[docs] def tag_modified(self) -> bool: """Return True if tag has been modified from original. Returns: bool: Return True if the tag is modified. Notes: - Modifications can include adding a column name_prefix. """ return bool(self._tag)
@property def tag(self) -> str: """Returns the tag or the original tag if no user form set. Returns: str: The custom set user form of the tag. """ if self._tag: return self._tag return self.org_tag @tag.setter def tag(self, new_tag_val): """Allow you to overwrite the tag output text. Parameters: new_tag_val (str): New (implicitly long form) of tag to set. Notes: - You probably don't actually want to call this. """ self._tag = new_tag_val self._schema_entry = None self._calculate_to_canonical_forms(self._schema) @property def extension(self) -> str: """Get the extension or value of tag. Generally this is just the portion after the last slash. Returns an empty string if no extension or value. Returns: str: The tag name. Notes: - This tag must have been computed first. """ if self._extension_value: return self._extension_value[1:] return "" @extension.setter def extension(self, x): """Set the extension or value of this tag. Parameters: x (str): The new extension value (without a leading slash). """ self._extension_value = f"/{x}" @property def long_tag(self) -> str: """Long form including value or extension. Returns: str: The long form of this tag. """ if self._schema_entry: return f"{self._namespace}{self._schema_entry.long_tag_name}{self._extension_value}" return str(self) @property def org_tag(self) -> str: """Return the original unmodified tag. Returns: str: The original unmodified tag. """ return self._hed_string[self.span[0] : self.span[1]] @property def expanded(self) -> bool: """Return if this is currently expanded or not. Will always be False unless expandable is set. This is primarily used for Def/Def-expand tags at present. Returns: bool: True if this is currently expanded. """ return self._expanded @property def expandable(self) -> HedGroup | HedTag | None: """Return what this expands to. This is primarily used for Def/Def-expand tags at present. Lazily set the first time it's called. Returns: Union[HedGroup,HedTag,None]: Returns the expanded form of this tag. """ if self._expandable is None and self._def_entry: save_parent = self._parent tag_label, _, placeholder = self.extension.partition("/") def_contents = self._def_entry.get_definition(self, placeholder_value=placeholder) self._parent = save_parent if def_contents is not None: self._expandable = def_contents self._expanded = self.short_base_tag == DefTagNames.DEF_EXPAND_KEY return self._expandable
[docs] def is_column_ref(self) -> bool: """Return if this tag is a column reference from a sidecar. You should only see these if you are directly accessing sidecar strings, tools should remove them otherwise. Returns: bool: True if this is a column ref. """ return self.org_tag.startswith("{") and self.org_tag.endswith("}")
[docs] def __str__(self) -> str: """Convert this HedTag to a string. Returns: str: The original tag if we haven't set a new tag.(e.g. short to long). """ if self._schema_entry: return self.short_tag if self._tag: return self._tag return self._hed_string[self.span[0] : self.span[1]]
[docs] def lower(self) -> str: """Convenience function, equivalent to str(self).lower().""" return str(self).lower()
[docs] def casefold(self) -> str: """Convenience function, equivalent to str(self).casefold().""" return str(self).casefold()
def _calculate_to_canonical_forms(self, hed_schema) -> list: """Update internal state based on schema. Parameters: hed_schema (HedSchema or HedSchemaGroup): The schema to use to validate this tag. Returns: list[dict]: A list of issues found during conversion. Each element is a dictionary. """ tag_entry, remainder, tag_issues = hed_schema.find_tag_entry(self, self.schema_namespace) self._schema_entry = tag_entry self._schema = hed_schema if self._schema_entry: self.tag_terms = self._schema_entry.tag_terms if remainder: self._extension_value = remainder else: self.tag_terms = () return tag_issues
[docs] def get_stripped_unit_value(self, extension_text) -> tuple[str | None, str | None]: """Return the extension divided into value and units, if the units are valid. Parameters: extension_text (str): The text to split, in case it's a portion of a tag. Returns: Union[str, None]: The extension portion with the units removed or None if invalid units. Union[str, None]: The units or None if no units of the right unit class are found. Examples: 'Duration/3 ms' will return ('3', 'ms') """ tag_unit_classes = self.unit_classes stripped_value, units, match = HedTag._get_tag_units_portion(extension_text, tag_unit_classes) if stripped_value and match: return stripped_value, units elif units and not match: return None, units return extension_text, None
[docs] def value_as_default_unit(self) -> float | None: """Return the value converted to default units if possible or None if invalid. Returns: Union[float, None]: The extension value in default units. A value written without a unit is assumed to be in default units already. None if the value is not numeric, the unit is not valid for this tag, or the unit has no conversionFactor (for example 'Duration/3 month' in HED 8.4.0), since no conversion is possible then. Examples: 'Duration/300 ms' will return .3 """ tag_unit_classes = self.unit_classes stripped_value, unit, unit_entry = HedTag._get_tag_units_portion(self.extension, tag_unit_classes) if not stripped_value: return None try: value = float(stripped_value) except ValueError: return None if not unit: return value if not unit_entry: return None conversion_factor = unit_entry.get_conversion_factor(unit) if conversion_factor is None: return None return value * conversion_factor
@property def unit_classes(self) -> dict: """Return a dict of all the unit classes this tag accepts. Returns: dict: A dict of unit classes this tag accepts. Notes: - Returns empty dict if this is not a unit class tag. - The dictionary has unit name as the key and HedSchemaEntry as value. """ if self._schema_entry: return self._schema_entry.unit_classes return {} @property def value_classes(self) -> dict: """Return a dict of all the value classes this tag accepts. Returns: dict: A dictionary of HedSchemaEntry value classes this tag accepts. Notes: - Returns empty dict if this is not a value class. - The dictionary has unit name as the key and HedSchemaEntry as value. """ if self._schema_entry: return self._schema_entry.value_classes return {} @property def attributes(self) -> dict: """Return a dict of all the attributes this tag has or empty dict if this is not a value tag. Returns: dict: A dict of attributes this tag has. Notes: - Returns empty dict if this is not a unit class tag. - The dictionary has unit name as the key and HedSchemaEntry as value. """ if self._schema_entry: return self._schema_entry.attributes return {}
[docs] def tag_exists_in_schema(self) -> bool: """Return whether the schema entry for this tag exists. Returns: bool: True if this tag exists. Notes: - This does NOT assure this is a valid tag. """ return bool(self._schema_entry)
[docs] def is_takes_value_tag(self) -> bool: """Return True if this is a takes value tag. Returns: bool: True if this is a takes value tag. """ if self._schema_entry: return self._schema_entry.has_attribute(HedKey.TakesValue) return False
[docs] def is_unit_class_tag(self) -> bool: """Return True if this is a unit class tag. Returns: bool: True if this is a unit class tag. """ if self._schema_entry: return bool(self._schema_entry.unit_classes) return False
[docs] def is_value_class_tag(self) -> bool: """Return True if this is a value class tag. Returns: bool: True if this is a tag with a value class. """ if self._schema_entry: return bool(self._schema_entry.value_classes) return False
[docs] def is_basic_tag(self) -> bool: """Return True if a known tag with no extension or value. Returns: bool: True if this is a known tag without extension or value. """ return bool(self._schema_entry and not self.extension)
[docs] def has_attribute(self, attribute) -> bool: """Return True if this is an attribute this tag has. Parameters: attribute (str): Name of the attribute. Returns: bool: True if this tag has the attribute. """ if self._schema_entry: return self._schema_entry.has_attribute(attribute) return False
[docs] def get_tag_unit_class_units(self) -> list: """Get the unit class units associated with a particular tag. Returns: list: A list containing the unit class units associated with a particular tag or an empty list. """ units = [] unit_classes = self.unit_classes for unit_class_entry in unit_classes.values(): units += unit_class_entry.units.keys() return units
@property def default_unit(self): """Get the default unit class unit for this tag. Only a tag with a single unit class can have default units. Returns: unit(UnitEntry or None): the default unit entry for this tag, or None Notes: defaultUnits may name a derived form such as mA or kOhm (HED 8.5.0 and later), in which case the entry of the unit it derives from (A, Ohm) is returned. A unit listed explicitly wins, so HED 8.4.0 still returns its own uV entry rather than V. """ # todo: Make this cached unit_classes = self.unit_classes.values() if len(unit_classes) == 1: first_unit_class_entry = list(unit_classes)[0] default_unit = first_unit_class_entry.has_attribute(HedKey.DefaultUnits, return_value=True) if not default_unit: return None unit_entry = first_unit_class_entry.units.get(default_unit) if unit_entry is None: unit_entry = first_unit_class_entry.get_derivative_unit_entry(default_unit) return unit_entry return None
[docs] def base_tag_has_attribute(self, tag_attribute) -> bool: """Check to see if the tag has a specific attribute. This is primarily used to check for things like TopLevelTag on Definitions and similar. Parameters: tag_attribute (str): A tag attribute. Returns: bool: True if the tag has the specified attribute. False, if otherwise. """ if not self._schema_entry: return False return self._schema_entry.base_tag_has_attribute(tag_attribute)
@staticmethod def _get_schema_namespace(org_tag) -> str: """Finds the library namespace for the tag. Parameters: org_tag (str): A string representing a tag. Returns: str: Library namespace string or empty. """ first_slash = org_tag.find("/") first_colon = org_tag.find(":") if first_colon != -1: if first_slash != -1 and first_colon > first_slash: return "" return org_tag[: first_colon + 1] return "" @staticmethod def _get_tag_units_portion(extension_text, tag_unit_classes): """Split a value portion into value, units and its valid unitEntry (if any). Parameters: extension_text (str): A string representing the value portion of a tag with unit classes. tag_unit_classes (dict): Dictionary of valid UnitClassEntry objects for this tag. Returns: stripped_value (str or None): The value with the units removed. This is filled in if there are no units as well. units (str or None); The units string or None if no units. unitEntry (UnitEntry or None): The matching unit entry if one is found Notes: value, None, None -- value portion has no units. value, units, unitEntry -- value portion has value and valid units. value, units, None -- value portion has a value and invalid units. """ value, _, units = extension_text.partition(" ") if not units: return value, None, None for unit_class_entry in tag_unit_classes.values(): possible_match = unit_class_entry.get_derivative_unit_entry(units) if possible_match: return value, units, possible_match return value, units, None
[docs] def is_placeholder(self) -> bool: """Returns if this tag has a placeholder in it. Returns: bool: True if it has a placeholder. """ if "#" in self.org_tag or "#" in self._extension_value: return True return False
[docs] def replace_placeholder(self, placeholder_value): """If tag has a placeholder character(#), replace with value. Parameters: placeholder_value (str): Value to replace placeholder with. """ if self.is_placeholder(): if self._schema_entry: tag = self.tag.replace("#", placeholder_value) self._extension_value = self._extension_value.replace("#", placeholder_value) self.tag = tag else: self._tag = self.tag.replace("#", placeholder_value)
[docs] def get_normalized_str(self): """Return a case-folded, canonical string used for hashing and equality comparison. Uses the schema short tag name when available; falls back to the raw tag text. Returns: str: Lowercase canonical form of the tag including any extension or value. """ if self._schema_entry: return self._namespace + self._schema_entry.short_tag_name.casefold() + self._extension_value.casefold() else: return self.casefold()
def __hash__(self): return hash(self.get_normalized_str()) def __eq__(self, other): if self is other: return True if isinstance(other, str): return self.casefold() == other.casefold() if not isinstance(other, HedTag): return False if self.short_tag == other.short_tag: return True if self.org_tag.casefold() == other.org_tag.casefold(): return True return False def __deepcopy__(self, memo): # Check if the object has already been copied. if id(self) in memo: return memo[id(self)] # create a new instance of HedTag class new_tag = self.__class__.__new__(self.__class__) new_tag.__dict__.update(self.__dict__) # add the new object to the memo dictionary memo[id(self)] = new_tag # Deep copy the attributes that need it(most notably, we don't copy schema/schema entry) new_tag._parent = copy.deepcopy(self._parent, memo) new_tag._expandable = copy.deepcopy(self._expandable, memo) new_tag._expanded = copy.deepcopy(self._expanded, memo) return new_tag