Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
- [x] DatasetSchema: Allow regex-based variable or coordinate name matching
- [ ] AttrSchema: Support string input in the type field when deserializing
- [ ] AttrSchema: Add regex-based string validation for attributes
- [ ] AttrSchema: Add pint-based unit validation system
- [x] AttrSchema: Add pint-based unit validation system
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Repository = "https://github.com/leroyvn/xarray-validate/"
[project.optional-dependencies]
dask = ["dask"]
yaml = ["ruamel-yaml"]
units = ["pint"]

[dependency-groups]
lint = ["ruff>=0.14.0"]
Expand Down
121 changes: 120 additions & 1 deletion src/xarray_validate/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,17 +578,37 @@ class AttrSchema(BaseSchema):

value : Any
Attribute value definition. ``None`` may be used as a wildcard.

units : str, optional
Exact unit validation (tolerates different spellings/abbreviations).
Uses pint to validate that the attribute value represents the same unit.
For example, ``units="metre"`` accepts "metre", "m", or "meter".
Requires pint to be installed.

units_compatible : str, optional
Compatible units validation (allows unit conversions).
Uses pint to validate that the attribute value is compatible with the
specified unit. For example, ``units_compatible="metre"`` accepts
"meter", "kilometre", "millimetre", etc.
Requires pint to be installed.
"""

type: Optional[Type] = _attrs.field(
default=None,
validator=_attrs.validators.optional(_attrs.validators.instance_of(type)),
)
value: Optional[Any] = _attrs.field(default=None)
units: Optional[str] = _attrs.field(default=None)
units_compatible: Optional[str] = _attrs.field(default=None)

def serialize(self) -> dict:
# Inherit docstring
return {"type": self.type, "value": self.value}
return {
"type": self.type,
"value": self.value,
"units": self.units,
"units_compatible": self.units_compatible,
}

@classmethod
def deserialize(cls, obj):
Expand Down Expand Up @@ -656,6 +676,105 @@ def validate(self, attr: Any, context: ValidationContext | None = None):
else:
raise error

# Unit validation
if self.units is not None or self.units_compatible is not None:
# Ensure attr is a string
if not isinstance(attr, str):
error = SchemaError(
"Unit validation requires attribute to be a string, got "
f"{type(attr).__name__}"
)
if context:
context.handle_error(error)
else:
raise error
return

# Try to import pint
try:
import pint
except ImportError as e:
error = SchemaError(
"Unit validation requires the pint library. "
"Install with: pip install pint"
)
if context:
context.handle_error(error)
else:
raise error from e
return

# Get the application registry
ureg = pint.get_application_registry()

# Parse the attribute value as a unit
try:
attr_unit = ureg.Unit(attr)
except (
pint.UndefinedUnitError,
pint.errors.DefinitionSyntaxError,
) as e:
error = SchemaError(f"Invalid unit '{attr}': {e}")
if context:
context.handle_error(error)
else:
raise error from e
return

# Validate exact unit match
if self.units is not None:
try:
expected_unit = ureg.Unit(self.units)
except (
pint.UndefinedUnitError,
pint.errors.DefinitionSyntaxError,
) as e:
error = SchemaError(f"Invalid expected unit '{self.units}': {e}")
if context:
context.handle_error(error)
else:
raise error from e
return

if attr_unit != expected_unit:
error = SchemaError(
f"Unit mismatch: expected '{self.units}' "
f"(or equivalent like '{expected_unit:~}'), got '{attr}'"
)
if context:
context.handle_error(error)
else:
raise error

# Validate compatible units
if self.units_compatible is not None:
try:
expected_unit = ureg.Unit(self.units_compatible)
except (
pint.UndefinedUnitError,
pint.errors.DefinitionSyntaxError,
) as e:
error = SchemaError(
f"Invalid expected unit '{self.units_compatible}': {e}"
)
if context:
context.handle_error(error)
else:
raise error from e
return

if not attr_unit.is_compatible_with(expected_unit):
error = SchemaError(
f"Unit '{attr}' is not compatible with "
f"'{self.units_compatible}'. "
f"Expected dimensionality: {expected_unit.dimensionality}, "
f"got: {attr_unit.dimensionality}"
)
if context:
context.handle_error(error)
else:
raise error


@_attrs.define(on_setattr=[_attrs.setters.convert, _attrs.setters.validate])
class AttrsSchema(BaseSchema):
Expand Down
Loading