diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eff80fad..245c5b2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,14 +41,33 @@ jobs: steps: - uses: actions/checkout@v3 + + # we can't actually do the codegen on python3.7 + # (there's an issue with jinja template ordering) + # so we build wheel before installing python 3.7 + - name: Build Wheel + if: matrix.python-version == '3.7' + run: | + pip install -U pip build + python -m build --wheel + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + + - name: Install (3.7) + if: matrix.python-version == '3.7' + run: | + whl=$(ls dist/*.whl) + python -m pip install "${whl}[test,dev]" + + - name: Install + if: matrix.python-version != '3.7' run: | python -m pip install -U pip python -m pip install .[test,dev] + - name: Test run: pytest --cov --cov-report=xml diff --git a/pyproject.toml b/pyproject.toml index bc49a7ad..062b11fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,7 +210,6 @@ exclude_lines = [ source = ["ome_types", "ome_autogen"] omit = ["src/ome_types/_autogenerated/*", "/private/var/folders/*"] - # Entry points -- REMOVE ONCE XSDATA-PYDANTIC-BASEMODEL IS SEPARATE [project.entry-points."xsdata.plugins.class_types"] xsdata_pydantic_basemodel = "xsdata_pydantic_basemodel.hooks.class_type" diff --git a/src/ome_autogen/__main__.py b/src/ome_autogen/__main__.py index db2840fd..962e7b52 100644 --- a/src/ome_autogen/__main__.py +++ b/src/ome_autogen/__main__.py @@ -1,4 +1,3 @@ -# pragma: no cover -from ome_autogen.main import build_model +from ome_autogen.main import build_model # pragma: no cover -build_model() +build_model() # pragma: no cover diff --git a/src/ome_autogen/_config.py b/src/ome_autogen/_config.py index 8789bd33..2c28561d 100644 --- a/src/ome_autogen/_config.py +++ b/src/ome_autogen/_config.py @@ -7,14 +7,16 @@ from ome_autogen._generator import OmeGenerator from ome_autogen._util import camel_to_snake +KindedTypes = "(Shape|ManufacturerSpec|Annotation)" + + MIXIN_MODULE = "ome_types._mixins" MIXINS: list[tuple[str, str, bool]] = [ (".*", f"{MIXIN_MODULE}._base_type.OMEType", False), # base type on every class ("OME", f"{MIXIN_MODULE}._ome.OMEMixin", True), ("Instrument", f"{MIXIN_MODULE}._instrument.InstrumentMixin", False), ("Reference", f"{MIXIN_MODULE}._reference.ReferenceMixin", True), - ("BinData", f"{MIXIN_MODULE}._bin_data.BinDataMixin", True), - ("Pixels", f"{MIXIN_MODULE}._pixels.PixelsMixin", True), + (KindedTypes, f"{MIXIN_MODULE}._kinded.KindMixin", True), ] ALLOW_RESERVED_NAMES = {"type", "Type", "Union"} diff --git a/src/ome_autogen/_generator.py b/src/ome_autogen/_generator.py index 7904a778..c7aa16c4 100644 --- a/src/ome_autogen/_generator.py +++ b/src/ome_autogen/_generator.py @@ -3,7 +3,7 @@ import sys from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Iterator, NamedTuple, cast +from typing import TYPE_CHECKING, Callable, Iterator, NamedTuple, cast from xsdata.formats.dataclass.filters import Filters from xsdata.formats.dataclass.generator import DataclassGenerator @@ -22,6 +22,29 @@ # avoiding import to avoid build-time dependency on the ome-types package AUTO_SEQUENCE = "__auto_sequence__" +# Methods and/or validators to add to generated classes +# (predicate, method) where predicate returns True if the method should be added +# to the given class. Note: imports for these methods are added in +# IMPORT_PATTERNS below. +ADDED_METHODS: list[tuple[Callable[[Class], bool], str]] = [ + ( + lambda c: c.name == "BinData", + "\n\n_v = root_validator(pre=True)(bin_data_root_validator)", + ), + ( + lambda c: c.name == "Value", + "\n\n_v = validator('any_elements', each_item=True)(any_elements_validator)", + ), + ( + lambda c: c.name == "Pixels", + "\n\n_v = root_validator(pre=True)(pixels_root_validator)", + ), + ( + lambda c: c.name == "XMLAnnotation", + "\n\n_v = validator('value', pre=True)(xml_value_validator)", + ), +] + class Override(NamedTuple): element_name: str # name of the attribute in the XSD @@ -55,6 +78,18 @@ class Override(NamedTuple): for o in CLASS_OVERRIDES if o.module_name } +IMPORT_PATTERNS.update( + { + "ome_types._mixins._util": {"new_uuid": ["default_factory=new_uuid"]}, + "datetime": {"datetime": ["datetime"]}, + "ome_types._mixins._validators": { + "any_elements_validator": ["any_elements_validator"], + "bin_data_root_validator": ["bin_data_root_validator"], + "pixels_root_validator": ["pixels_root_validator"], + "xml_value_validator": ["xml_value_validator"], + }, + } +) class OmeGenerator(DataclassGenerator): @@ -99,7 +134,8 @@ def register(self, env: Environment) -> None: # add our own templates dir to the search path tpl_dir = Path(__file__).parent.joinpath("templates") cast("FileSystemLoader", env.loader).searchpath.insert(0, str(tpl_dir)) - return super().register(env) + super().register(env) + env.filters.update({"methods": self.methods}) def __init__(self, config: GeneratorConfig): super().__init__(config) @@ -190,9 +226,13 @@ def field_type(self, attr: Attr, parents: list[str]) -> str: @classmethod def build_import_patterns(cls) -> dict[str, dict]: patterns = super().build_import_patterns() + patterns.setdefault("pydantic", {}).update( + { + "validator": ["validator("], + "root_validator": ["root_validator("], + } + ) patterns.update(IMPORT_PATTERNS) - patterns["ome_types._mixins._util"] = {"new_uuid": ["default_factory=new_uuid"]} - patterns["datetime"] = {"datetime": ["datetime"]} return {key: patterns[key] for key in sorted(patterns)} def field_default_value(self, attr: Attr, ns_map: dict | None = None) -> str: @@ -221,3 +261,9 @@ def constant_name(self, name: str, class_name: str) -> str: # use the enum names found in appinfo/xsdfu/enum return self.appinfo.enums[class_name][name].enum return super().constant_name(name, class_name) + + def methods(self, obj: Class) -> list[str]: + for predicate, code in ADDED_METHODS: + if predicate(obj): + return [code] + return [] diff --git a/src/ome_autogen/main.py b/src/ome_autogen/main.py index 2e44fad2..cff7faa2 100644 --- a/src/ome_autogen/main.py +++ b/src/ome_autogen/main.py @@ -10,6 +10,9 @@ from ome_autogen._config import get_config from ome_autogen._transformer import OMETransformer +BLACK_LINE_LENGTH = 88 +BLACK_TARGET_VERSION = "py37" +BLACK_SKIP_TRAILING_COMMA = False # use trailing commas as a reason to split lines? OUTPUT_PACKAGE = "ome_types._autogenerated.ome_2016_06" DO_MYPY = os.environ.get("OME_AUTOGEN_MYPY", "0") == "1" or "--mypy" in sys.argv SRC_PATH = Path(__file__).parent.parent @@ -57,13 +60,21 @@ def build_model( def _fix_formatting(package_dir: str, ruff_ignore: list[str] = RUFF_IGNORE) -> None: _print_gray("Running black and ruff ...") - black = ["black", package_dir, "-q", "--line-length=88"] - subprocess.check_call(black) # noqa S - ruff = ["ruff", "-q", "--fix", package_dir] ruff.extend(f"--ignore={ignore}" for ignore in ruff_ignore) subprocess.check_call(ruff) # noqa S + black = [ + "black", + "-q", + f"--line-length={BLACK_LINE_LENGTH}", + f"--target-version={BLACK_TARGET_VERSION}", + ] + if BLACK_SKIP_TRAILING_COMMA: # pragma: no cover + black.append("--skip-magic-trailing-comma") + black.extend([str(x) for x in Path(package_dir).rglob("*.py")]) + subprocess.check_call(black) # noqa S + def _check_mypy(package_dir: str) -> None: _print_gray("Running mypy ...") diff --git a/src/ome_autogen/templates/class.jinja2 b/src/ome_autogen/templates/class.jinja2 new file mode 100644 index 00000000..41b177be --- /dev/null +++ b/src/ome_autogen/templates/class.jinja2 @@ -0,0 +1,59 @@ +{% set level = level|default(0) -%} +{% set help | format_docstring(level + 1) %} + {%- include "docstrings." + docstring_name + ".jinja2" -%} +{% endset -%} +{% set parent_namespace = obj.namespace if obj.namespace is not none else parent_namespace|default(None) -%} +{% set parents = parents|default([obj.name]) -%} +{% set class_name = obj.name|class_name -%} +{% set class_annotations = obj | class_annotations(class_name) -%} +{% set global_type = level == 0 and not obj.local_type -%} +{% set local_name = obj.meta_name or obj.name -%} +{% set local_name = None if class_name == local_name or not global_type else local_name -%} +{% set base_classes = obj | class_bases(class_name) | join(', ')-%} +{% set target_namespace = obj.target_namespace if global_type and module_namespace != obj.target_namespace else None %} + +{{ class_annotations | join('\n') }} +class {{ class_name }}{{"({})".format(base_classes) if base_classes }}: +{%- if help %} +{{ help|indent(4, first=True) }} +{%- endif -%} +{%- if local_name or obj.is_nillable or obj.namespace is not none or target_namespace or obj.local_type %} + class Meta: + {%- if obj.local_type %} + global_type = False + {%- endif -%} + {%- if local_name %} + name = "{{ local_name }}" + {%- endif -%} + {%- if obj.is_nillable %} + nillable = True + {%- endif -%} + {%- if obj.namespace is not none %} + namespace = "{{ obj.namespace }}" + {%- endif %} + {%- if target_namespace and target_namespace != obj.namespace %} + target_namespace = "{{ target_namespace }}" + {%- endif %} +{% elif obj.attrs|length == 0 and not help %} + pass +{%- endif -%} +{%- for attr in obj.attrs %} + {%- set field_typing = attr|field_type(parents) %} + {%- set field_definition = attr|field_definition(obj.ns_map, parent_namespace, parents) %} + {{ attr.name|field_name(obj.name) }}: {{ field_typing }} = {{ field_definition }} +{%- endfor -%} +{%- for inner in obj.inner %} + {%- set tpl = "enum.jinja2" if inner.is_enumeration else "class.jinja2" -%} + {%- set inner_parents = parents + [inner.name] -%} + {%- filter indent(4) -%} + {%- with obj=inner, parents=inner_parents, level=(level + 1) -%} + {% include tpl %} + {%- endwith -%} + {%- endfilter -%} +{%- endfor -%} +{# This is the only reason we're overriding this file #} +{%- for method in obj|methods %} + {%- filter indent(4) -%} + {{ method }} + {%- endfilter -%} +{%- endfor -%} diff --git a/src/ome_types/__init__.py b/src/ome_types/__init__.py index 791f4236..8d1ddcaa 100644 --- a/src/ome_types/__init__.py +++ b/src/ome_types/__init__.py @@ -8,7 +8,10 @@ except PackageNotFoundError: # pragma: no cover __version__ = "unknown" -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ome_types.units import ureg # noqa: TCH004 from ome_types import model from ome_types._conversion import from_tiff, from_xml, to_dict, to_xml diff --git a/src/ome_types/_conversion.py b/src/ome_types/_conversion.py index a41ae7d9..cb305565 100644 --- a/src/ome_types/_conversion.py +++ b/src/ome_types/_conversion.py @@ -107,9 +107,15 @@ def from_xml( validate: bool | None = None, parser: Any = None, parser_kwargs: ParserKwargs | None = None, -) -> OME: +) -> OME: # Not totally true, see note below """Generate an OME object from an XML document. + NOTE: Technically, this can return any ome-types instance, (not just OME) but it's + by far the most common type that will come out of this function, and the type + annotation will be more useful to most users. For those who pass in an xml document + that isn't just a root tag, they can cast the result to the correct type + themselves. + Parameters ---------- xml : Path | str | bytes @@ -127,7 +133,7 @@ def from_xml( Returns ------- OME - The OME object parsed from the XML document. + The OME object parsed from the XML document. (See NOTE above.) """ if parser is not None: # pragma: no cover warnings.warn( @@ -144,10 +150,7 @@ def from_xml( if isinstance(xml, Path): xml = str(xml) - # this cast is a lie... but it's by far the most common type that will - # come out of this function, and will be more useful to most users. - # For those who pass in an xml document that isn't just a root tag, - # they can cast the result to the correct type themselves. + # this cast is a lie... see NOTE above. OME_type = cast("type[OME]", _get_ome_type(xml)) parser_ = XmlParser(**(parser_kwargs or {})) @@ -159,7 +162,7 @@ def from_xml( def to_xml( - ome: OME, + ome: OMEType, *, # exclude_defaults takes precedence over exclude_unset # if a value equals the default, it will be excluded @@ -173,6 +176,36 @@ def to_xml( canonicalize: bool = False, validate: bool = False, ) -> str: + """Generate an XML document from an OME object. + + Parameters + ---------- + ome : OMEType + Instance of an ome-types model class. + exclude_defaults : bool, optional + Whether to exclude attributes that are set to their default value, + by default False. + exclude_unset : bool, optional + Whether to exclude attributes that are not explicitly set, + by default True. + indent : int, optional + Number of spaces to indent the XML document, by default 2. + include_namespace : bool | None, optional + Whether to include the OME namespace in the root element. If `None`, will + be set to the value of `canonicalize`, by default None. + include_schema_location : bool, optional + Whether to include the schema location in the root element, by default True. + canonicalize : bool, optional + Whether to canonicalize the XML output, by default False. + validate : bool, optional + Whether to validate the XML document against the OME schema, after rendering. + (In most cases, this will be redundant and unnecessary.) + + Returns + ------- + str + The XML document as a string. + """ config = SerializerConfig( pretty_print=(indent > 0) and not canonicalize, # canonicalize does it for us pretty_print_indent=" " * indent, diff --git a/src/ome_types/_mixins/_base_type.py b/src/ome_types/_mixins/_base_type.py index 7c1f029a..f6f7bd73 100644 --- a/src/ome_types/_mixins/_base_type.py +++ b/src/ome_types/_mixins/_base_type.py @@ -1,8 +1,21 @@ import warnings from datetime import datetime from enum import Enum +from pathlib import Path from textwrap import indent -from typing import Any, ClassVar, Dict, Optional, Sequence, Set, Tuple +from typing import ( + Any, + ClassVar, + Dict, + Optional, + Sequence, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) from pydantic import BaseModel, validator @@ -13,6 +26,7 @@ except ImportError: add_quantity_properties = lambda cls: None # noqa: E731 +T = TypeVar("T", bound="OMEType") # Default value to support automatic numbering for id field values. AUTO_SEQUENCE = "__auto_sequence__" @@ -76,11 +90,13 @@ class Config: _v = validator("id", pre=True, always=True, check_fields=False)(validate_id) def __init__(self, **data: Any) -> None: + warn_extra = data.pop("warn_extra", True) field_names = set(self.__fields__.keys()) _move_deprecated_fields(data, field_names) super().__init__(**data) kwargs = set(data.keys()) - if kwargs - field_names: + extra = kwargs - field_names + if extra and warn_extra: warnings.warn( f"Unrecognized fields for type {type(self)}: {kwargs - field_names}", stacklevel=2, @@ -135,6 +151,30 @@ def __getattr__(self, key: str) -> Any: return getattr(self, new_key) raise AttributeError(f"{cls_name} object has no attribute {key!r}") + def to_xml(self, **kwargs: Any) -> str: + """Serialize this object to XML. + + See docstring of [`ome_types.to_xml`][] for kwargs. + """ + from ome_types._conversion import to_xml + + return to_xml(self, **kwargs) + + @classmethod + def from_xml(cls: Type[T], xml: Union[Path, str], **kwargs: Any) -> T: + """Read an ome-types class from XML. + + See docstring of [`ome_types.from_xml`][] for kwargs. + + Note: this will return an instance of whatever the top node is in the XML. + so technically, the return type here could be incorrect. But when used + properly (`Image.from_xml()`, `Pixels.from_xml()`, etc.) on an unusual xml + document it can provide additional typing information. + """ + from ome_types._conversion import from_xml + + return cast(T, from_xml(xml, **kwargs)) + class _RawRepr: """Helper class to allow repr to show raw values for fields that are sequences.""" diff --git a/src/ome_types/_mixins/_bin_data.py b/src/ome_types/_mixins/_bin_data.py deleted file mode 100644 index 8be9e457..00000000 --- a/src/ome_types/_mixins/_bin_data.py +++ /dev/null @@ -1,22 +0,0 @@ -import warnings -from typing import Any, Dict - -from pydantic import root_validator - -from ome_types._mixins._base_type import OMEType - - -class BinDataMixin(OMEType): - @root_validator(pre=True) - def _v(cls, values: dict) -> Dict[str, Any]: - # This catches the case of , where the parser may have - # omitted value from the dict, and sets value to b"" - # seems like it could be done in a default_factory, but that would - # require more modification of xsdata I think - if "value" not in values: - if values.get("length") != 0: # pragma: no cover - warnings.warn( - "BinData length is non-zero but value is missing", stacklevel=2 - ) - values["value"] = b"" - return values diff --git a/src/ome_types/_mixins/_kinded.py b/src/ome_types/_mixins/_kinded.py new file mode 100644 index 00000000..8787bd0e --- /dev/null +++ b/src/ome_types/_mixins/_kinded.py @@ -0,0 +1,20 @@ +from typing import Any, Dict + +from pydantic import BaseModel + + +class KindMixin(BaseModel): + """This mixin adds a `kind` field to the dict output. + + This helps for casting a dict to a specific subclass, when the fields are + otherwise identical. + """ + + def __init__(self, **data: Any) -> None: + data.pop("kind", None) + return super().__init__(**data) + + def dict(self, **kwargs: Any) -> Dict[str, Any]: + d = super().dict(**kwargs) + d["kind"] = self.__class__.__name__.lower() + return d diff --git a/src/ome_types/_mixins/_ome.py b/src/ome_types/_mixins/_ome.py index 8e538ce0..6addb5da 100644 --- a/src/ome_types/_mixins/_ome.py +++ b/src/ome_types/_mixins/_ome.py @@ -2,7 +2,7 @@ import warnings import weakref -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from ome_types._mixins._base_type import OMEType from ome_types._mixins._ids import CONVERTED_IDS @@ -36,22 +36,15 @@ def __setstate__(self, state: dict[str, Any]) -> None: self._link_refs() @classmethod - def from_xml(cls, xml: Path | str) -> OME: - from ome_types._conversion import from_xml + def from_tiff(cls, path: Path | str, **kwargs: Any) -> OME: + """Return an OME object from the metadata in a TIFF file. - return from_xml(xml) - - @classmethod - def from_tiff(cls, path: Path | str) -> OME: + See docstring of [`ome_types.from_tiff`][] for kwargs. + """ from ome_types._conversion import from_tiff return from_tiff(path) - def to_xml(self, **kwargs: Any) -> str: - from ome_types._conversion import to_xml - - return to_xml(cast("OME", self), **kwargs) - def collect_ids(value: Any) -> dict[str, OMEType]: """Return a map of all model objects contained in value, keyed by id. diff --git a/src/ome_types/_mixins/_pixels.py b/src/ome_types/_mixins/_pixels.py deleted file mode 100644 index ff8892b0..00000000 --- a/src/ome_types/_mixins/_pixels.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import root_validator - -from ome_types._mixins._base_type import OMEType - - -class PixelsMixin(OMEType): - @root_validator(pre=True) - def _validate_root(cls, values: dict) -> dict: - if "metadata_only" in values: - if isinstance(values["metadata_only"], bool): - if not values["metadata_only"]: - values.pop("metadata_only") - else: - # type ignore in case the autogeneration hasn't been built - from ome_types.model import MetadataOnly # type: ignore - - values["metadata_only"] = MetadataOnly() - - return values diff --git a/src/ome_types/_mixins/_validators.py b/src/ome_types/_mixins/_validators.py new file mode 100644 index 00000000..06648d8f --- /dev/null +++ b/src/ome_types/_mixins/_validators.py @@ -0,0 +1,69 @@ +"""These validators are added to the generated classes in ome_types._autogenerated. + +that logic is in the `methods` method in ome_autogen/_generator.py +""" +import warnings +from typing import TYPE_CHECKING, Any, Dict + +if TYPE_CHECKING: + from ome_types.model import BinData, Pixels, XMLAnnotation # type: ignore + from xsdata_pydantic_basemodel.compat import AnyElement + + +# @root_validator(pre=True) +def bin_data_root_validator(cls: "BinData", values: dict) -> Dict[str, Any]: + # This catches the case of , where the parser may have + # omitted value from the dict, and sets value to b"" + # seems like it could be done in a default_factory, but that would + # require more modification of xsdata I think + if "value" not in values: + if values.get("length") != 0: # pragma: no cover + warnings.warn( + "BinData length is non-zero but value is missing", stacklevel=2 + ) + values["value"] = b"" + return values + + +# @root_validator(pre=True) +def pixels_root_validator(cls: "Pixels", values: dict) -> dict: + if "metadata_only" in values: + if isinstance(values["metadata_only"], bool): + if not values["metadata_only"]: + values.pop("metadata_only") + else: + # type ignore in case the autogeneration hasn't been built + from ome_types.model import MetadataOnly # type: ignore + + values["metadata_only"] = MetadataOnly() + + return values + + +# @validator("any_elements", each_item=True) +def any_elements_validator(cls: "XMLAnnotation.Value", v: Any) -> "AnyElement": + # This validator is used because XMLAnnotation.Value.any_elements is + # annotated as List[object]. So pydantic won't coerce dicts to AnyElement + # automatically (which is important when constructing OME objects from dicts) + if isinstance(v, dict): + # this needs to be delayed until runtime because of circular imports + from xsdata_pydantic_basemodel.compat import AnyElement + + return AnyElement(**v) + return v + + +# @validator('value', pre=True) +def xml_value_validator(cls: "XMLAnnotation", v: Any) -> "XMLAnnotation.Value": + if isinstance(v, str): + # FIXME: this is a hack to support passing a string to XMLAnnotation.value + # there must be a more direct way to do this... + # (the type ignores here are because the model might not be built yet) + from ome_types._conversion import OME_2016_06_URI + from ome_types.model import XMLAnnotation # type: ignore + from xsdata_pydantic_basemodel.bindings import XmlParser + + template = '{}' + xml = template.format(OME_2016_06_URI, v) + return XmlParser().from_string(xml, XMLAnnotation).value # type: ignore + return v diff --git a/src/ome_types/model/_shape_union.py b/src/ome_types/model/_shape_union.py index 80fb544e..1f4238ba 100644 --- a/src/ome_types/model/_shape_union.py +++ b/src/ome_types/model/_shape_union.py @@ -70,7 +70,7 @@ def _validate_root(cls, v: ShapeType) -> ShapeType: for cls_ in _ShapeCls: with suppress(ValidationError): - return cls_(**v) + return cls_(warn_extra=False, **v) raise ValueError(f"Invalid shape: {v}") # pragma: no cover def __repr__(self) -> str: diff --git a/src/ome_types/model/_structured_annotations.py b/src/ome_types/model/_structured_annotations.py index 19d398ab..b13929ec 100644 --- a/src/ome_types/model/_structured_annotations.py +++ b/src/ome_types/model/_structured_annotations.py @@ -33,6 +33,7 @@ TermAnnotation, MapAnnotation, ) +_KINDS = {cls.__name__.lower(): cls for cls in AnnotationTypes} class StructuredAnnotationList(OMEType, UserSequence[Annotation]): # type: ignore[misc] @@ -70,6 +71,8 @@ def _validate_root(cls, v: Annotation) -> Annotation: if isinstance(v, AnnotationTypes): return v if isinstance(v, dict): + if "kind" in v: + return _KINDS[v.pop("kind")](**v) for cls_ in AnnotationTypes: with suppress(ValidationError): return cls_(**v) diff --git a/src/xsdata_pydantic_basemodel/compat.py b/src/xsdata_pydantic_basemodel/compat.py index d5202b92..b087ea17 100644 --- a/src/xsdata_pydantic_basemodel/compat.py +++ b/src/xsdata_pydantic_basemodel/compat.py @@ -35,7 +35,7 @@ class AnyElement(BaseModel): qname: Optional[str] = Field(default=None) text: Optional[str] = Field(default=None) tail: Optional[str] = Field(default=None) - children: List[object] = Field( + children: List["AnyElement"] = Field( default_factory=list, metadata={"type": XmlType.WILDCARD} ) attributes: Dict[str, str] = Field( diff --git a/tests/test_model.py b/tests/test_model.py index bb2b15ed..8cac8e88 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -6,8 +6,9 @@ import pytest from pydantic import ValidationError -from ome_types import from_tiff, from_xml, model +from ome_types import from_tiff, from_xml, model, to_xml from ome_types._conversion import _get_ome_type +from ome_types.validation import OME_URI DATA = Path(__file__).parent / "data" VALIDATE = [False] @@ -15,7 +16,7 @@ @pytest.mark.parametrize("validate", VALIDATE) def test_from_valid_xml(valid_xml: Path, validate: bool) -> None: - ome = from_xml(valid_xml, validate=validate) + ome = model.OME.from_xml(valid_xml, validate=validate) # class method for coverage assert ome assert repr(ome) @@ -40,6 +41,8 @@ def test_from_tiff(validate: bool) -> None: assert ome.images[0].pixels.size_x == 6 assert ome.images[0].pixels.channels[0].samples_per_pixel == 1 + assert ome == model.OME.from_tiff(_path) # class method for coverage + def test_no_id() -> None: """Test that ids are optional, and auto-increment.""" @@ -73,17 +76,19 @@ def test_with_ome_ns() -> None: def test_get_ome_type() -> None: - URI_OME = "http://www.openmicroscopy.org/Schemas/OME/2016-06" - - t = _get_ome_type(f'') + t = _get_ome_type(f'') assert t is model.Image with pytest.raises(ValueError): _get_ome_type("") # this can be used to instantiate XML with a non OME root type: - project = from_xml(f'') - assert isinstance(project, model.Project) + obj = from_xml(f'') + assert isinstance(obj, model.Project) + obj = from_xml( + f'' + ) + assert isinstance(obj, model.XMLAnnotation) def test_datetimes() -> None: @@ -155,3 +160,15 @@ def test_colors() -> None: assert model.Shape().fill_color is None assert model.Shape().stroke_color is None + + +def test_xml_annotation() -> None: + from xsdata_pydantic_basemodel.compat import AnyElement + + raw_xml = '' + xml_ann = model.XMLAnnotation(description="Some description", value=raw_xml) + assert xml_ann.description == "Some description" + assert isinstance(xml_ann.value, model.XMLAnnotation.Value) + assert isinstance(xml_ann.value.any_elements[0], AnyElement) + + assert raw_xml in to_xml(xml_ann, indent=0) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 76302d44..9bd27665 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -64,33 +64,14 @@ def test_serialization(valid_xml: Path) -> None: assert ome == deserialized -def test_roundtrip_inverse(valid_xml: Path, tmp_path: Path) -> None: - """both variants have been touched by the model, here...""" +def test_dict_roundtrip(valid_xml: Path) -> None: + # Test round-trip through to_dict and from_dict ome1 = from_xml(valid_xml) - - # FIXME: - # there is a small difference in the XML output when using xml instead of lxml - # that makes the text of an xml annotation in `xmlannotation-multi-value` be - # 'B\n ' instead of 'B'. - # we should investigate this and fix it, but here we just use indent=0 to avoid it. - xml = to_xml(ome1, indent=0) - out = tmp_path / "test.xml" - out.write_bytes(xml.encode()) - ome2 = from_xml(out) - - assert ome1 == ome2 - - -@pytest.mark.xfail -def test_to_dict(valid_xml: Path) -> None: - ome1 = from_xml(valid_xml) - d = to_dict(ome1) - ome2 = OME(**d) - assert ome1 == ome2 + assert ome1 == OME(**to_dict(ome1)) @pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher") -def test_roundtrip(valid_xml: Path) -> None: +def test_xml_roundtrip(valid_xml: Path) -> None: """Ensure we can losslessly round-trip XML through the model and back.""" if true_stem(valid_xml) in SKIP_ROUNDTRIP: pytest.xfail("known issues with canonicalization") @@ -98,7 +79,7 @@ def test_roundtrip(valid_xml: Path) -> None: original = _canonicalize(valid_xml.read_bytes()) ome = from_xml(valid_xml) - rexml = to_xml(ome) + rexml = to_xml(ome, validate=True) new = _canonicalize(rexml) if new != original: Path("original.xml").write_text(original) @@ -106,6 +87,26 @@ def test_roundtrip(valid_xml: Path) -> None: raise AssertionError +def test_xml_roundtrip_inverse(valid_xml: Path, tmp_path: Path) -> None: + """when xml->OME1->xml->OME2, assert OME1 == OME2. + + both variants have been touched by the model, here. + """ + ome1 = from_xml(valid_xml) + + # FIXME: + # there is a small difference in the XML output when using xml instead of lxml + # that makes the text of an xml annotation in `xmlannotation-multi-value` be + # 'B\n ' instead of 'B'. + # we should investigate this and fix it, but here we just use indent=0 to avoid it. + xml = to_xml(ome1, indent=0) + out = tmp_path / "test.xml" + out.write_bytes(xml.encode()) + ome2 = from_xml(out) + + assert ome1 == ome2 + + # ########## Canonicalization utils for testing ########## diff --git a/tests/test_units.py b/tests/test_units.py index b6d1b7e5..9d1e5ba5 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -8,8 +8,8 @@ from pydantic import ValidationError +from ome_types import ureg from ome_types.model import Channel, Laser, Plane, simple_types -from ome_types.units import ureg def test_quantity_math() -> None: