From 80e4b6a2f5b3ec4eb3e58ab4703f3cf8349c27d8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 28 Dec 2023 12:12:10 -0500 Subject: [PATCH] refactor: different pattern for structured annotations list and shapes union (#237) * fix: try different pattern for structured annotations * style(pre-commit.ci): auto fixes [...] * remove generated * fix build * move validator * style(pre-commit.ci): auto fixes [...] * lint * fix lint * use stock StructuredAnnotations * fix py37 * more generic mixin * remove unused file * Revert "remove unused file" This reverts commit 33449201c1f44c1183b7b1ce9ca4013260596395. * remove correct file * remove generic * use similar pattern for shape union * remove extra docs types * fix paquo * rename module * go back to generic * expose name Union * add extend method * fix docs * fix hint --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/API/ome_types.model.md | 6 +- docs/migration.md | 6 +- src/ome_autogen/_config.py | 6 + src/ome_autogen/_generator.py | 31 +++- src/ome_types/_mixins/_collections.py | 123 ++++++++++++++ src/ome_types/_mixins/_validators.py | 37 ++++ src/ome_types/model/__init__.py | 24 ++- src/ome_types/model/_shape_union.py | 158 ----------------- .../model/_structured_annotations.py | 160 ------------------ src/ome_types/model/_user_sequence.py | 58 ------- tests/test_paquo.py | 4 + tests/test_rois.py | 18 ++ 12 files changed, 235 insertions(+), 396 deletions(-) create mode 100644 src/ome_types/_mixins/_collections.py delete mode 100644 src/ome_types/model/_shape_union.py delete mode 100644 src/ome_types/model/_structured_annotations.py delete mode 100644 src/ome_types/model/_user_sequence.py create mode 100644 tests/test_rois.py diff --git a/docs/API/ome_types.model.md b/docs/API/ome_types.model.md index 36566346..dd375333 100644 --- a/docs/API/ome_types.model.md +++ b/docs/API/ome_types.model.md @@ -8,8 +8,4 @@ ## Extra types -::: ome_types.model._structured_annotations - -::: ome_types.model._shape_union - -::: ome_types.model._color \ No newline at end of file +::: ome_types.model._color diff --git a/docs/migration.md b/docs/migration.md index 060849e5..27966480 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -52,8 +52,8 @@ your code to the new version. ### Added classes - [`MetadataOnly`][ome_types.model.MetadataOnly] -- [`ShapeUnion`][ome_types.model.ShapeUnion] -- [`StructuredAnnotationList`][ome_types.model.StructuredAnnotationList] +- `ROI.Union` +- [`StructuredAnnotations`][ome_types.model.StructuredAnnotations] ### Removed classes @@ -223,7 +223,7 @@ your code to the new version. ### [`OME`][ome_types.model.OME] -- **`structured_annotations`** - type changed from `List[Annotation]` to `StructuredAnnotationList` +- **`structured_annotations`** - type changed from `List[Annotation]` to `StructuredAnnotations` - **`uuid`** - type changed from `Optional[UniversallyUniqueIdentifier]` to `Optional[ConstrainedStrValue]` ### [`Objective`][ome_types.model.Objective] diff --git a/src/ome_autogen/_config.py b/src/ome_autogen/_config.py index 81a5ca1e..29534f84 100644 --- a/src/ome_autogen/_config.py +++ b/src/ome_autogen/_config.py @@ -19,6 +19,12 @@ ("OME", f"{MIXIN_MODULE}._ome.OMEMixin", True), ("Instrument", f"{MIXIN_MODULE}._instrument.InstrumentMixin", False), ("Reference", f"{MIXIN_MODULE}._reference.ReferenceMixin", True), + ("Union", f"{MIXIN_MODULE}._collections.ShapeUnionMixin", True), + ( + "StructuredAnnotations", + f"{MIXIN_MODULE}._collections.StructuredAnnotationsMixin", + True, + ), ("(Shape|ManufacturerSpec|Annotation)", f"{MIXIN_MODULE}._kinded.KindMixin", True), ] diff --git a/src/ome_autogen/_generator.py b/src/ome_autogen/_generator.py index 82599127..34ee0e18 100644 --- a/src/ome_autogen/_generator.py +++ b/src/ome_autogen/_generator.py @@ -47,6 +47,14 @@ lambda c: c.name == "PixelType", "\n\nnumpy_dtype = property(pixel_type_to_numpy_dtype)", ), + ( + lambda c: c.name == "OME", + "\n\n_v_structured_annotations = field_validator('structured_annotations', mode='before')(validate_structured_annotations)", # noqa: E501 + ), + ( + lambda c: c.name == "ROI", + "\n\n_v_shape_union = field_validator('union', mode='before')(validate_shape_union)", # noqa: E501 + ), ] @@ -60,18 +68,17 @@ class Override(NamedTuple): Override("FillColor", "Color", "ome_types.model._color"), Override("StrokeColor", "Color", "ome_types.model._color"), Override("Color", "Color", "ome_types.model._color"), - Override("Union", "ShapeUnion", "ome_types.model._shape_union"), - Override( - "StructuredAnnotations", - "StructuredAnnotationList", - "ome_types.model._structured_annotations", - ), + # make the type annotation Non-Optional for structured annotations + Override("StructuredAnnotations", "StructuredAnnotations", None), ] # classes that should never be optional, but always have default_factories NO_OPTIONAL = {"Union", "StructuredAnnotations"} # if these names are found as default=..., turn them into default_factory=... -FACTORIZE = set([x.class_name for x in CLASS_OVERRIDES] + ["StructuredAnnotations"]) +FACTORIZE = set( + [x.class_name for x in CLASS_OVERRIDES] + + ["StructuredAnnotations", "lambda: ROI.Union()"] +) # prebuilt maps for usage in code below OVERRIDE_ELEM_TO_CLASS = {o.element_name: o.class_name for o in CLASS_OVERRIDES} @@ -96,6 +103,8 @@ class Override(NamedTuple): "pixels_root_validator": ["pixels_root_validator"], "xml_value_validator": ["xml_value_validator"], "pixel_type_to_numpy_dtype": ["pixel_type_to_numpy_dtype"], + "validate_structured_annotations": ["validate_structured_annotations"], + "validate_shape_union": ["validate_shape_union"], }, } ) @@ -250,6 +259,14 @@ def field_default_value(self, attr: Attr, ns_map: dict | None = None) -> str: if attr.name == override.element_name: if not self._attr_is_optional(attr): return override.class_name + + # HACK + # Two special cases to make ROI.Union and OME.StructuredAnnotations + # have default_factory=... + if attr.name == "Union": + return "lambda: ROI.Union()" + if attr.name == "StructuredAnnotations": + return "StructuredAnnotations" return super().field_default_value(attr, ns_map) def format_arguments(self, kwargs: dict, indent: int = 0) -> str: diff --git a/src/ome_types/_mixins/_collections.py b/src/ome_types/_mixins/_collections.py new file mode 100644 index 00000000..99ff7bd2 --- /dev/null +++ b/src/ome_types/_mixins/_collections.py @@ -0,0 +1,123 @@ +import itertools +from typing import Any, Generic, Iterator, List, TypeVar, Union, cast, no_type_check + +from pydantic import BaseModel + +# for circular import reasons... +from ome_types._autogenerated.ome_2016_06.boolean_annotation import BooleanAnnotation +from ome_types._autogenerated.ome_2016_06.comment_annotation import CommentAnnotation +from ome_types._autogenerated.ome_2016_06.double_annotation import DoubleAnnotation + +# for circular import reasons... +from ome_types._autogenerated.ome_2016_06.ellipse import Ellipse +from ome_types._autogenerated.ome_2016_06.file_annotation import FileAnnotation +from ome_types._autogenerated.ome_2016_06.label import Label +from ome_types._autogenerated.ome_2016_06.line import Line +from ome_types._autogenerated.ome_2016_06.list_annotation import ListAnnotation +from ome_types._autogenerated.ome_2016_06.long_annotation import LongAnnotation +from ome_types._autogenerated.ome_2016_06.map_annotation import MapAnnotation +from ome_types._autogenerated.ome_2016_06.mask import Mask +from ome_types._autogenerated.ome_2016_06.point import Point +from ome_types._autogenerated.ome_2016_06.polygon import Polygon +from ome_types._autogenerated.ome_2016_06.polyline import Polyline +from ome_types._autogenerated.ome_2016_06.rectangle import Rectangle +from ome_types._autogenerated.ome_2016_06.tag_annotation import TagAnnotation +from ome_types._autogenerated.ome_2016_06.term_annotation import TermAnnotation +from ome_types._autogenerated.ome_2016_06.timestamp_annotation import ( + TimestampAnnotation, +) +from ome_types._autogenerated.ome_2016_06.xml_annotation import XMLAnnotation + +T = TypeVar("T") + + +class CollectionMixin(BaseModel, Generic[T]): + """Mixin to be used for classes that behave like collections. + + Notably: ROI.Union and StructuredAnnotations. + All the fields in these types list[SomeType], and they collectively behave like + a list with the union of all field types. + """ + + @no_type_check + def __iter__(self) -> Iterator[T]: + return itertools.chain(*(getattr(self, f) for f in self.model_fields)) + + def __len__(self) -> int: + return sum(1 for _ in self) + + def append(self, item: T) -> None: + """Append an item to the appropriate field list.""" + cast(list, getattr(self, self._field_name(item))).append(item) + + def extend(self, items: List[T]) -> None: + """Extend the appropriate field list with the given items.""" + for item in items: + self.append(item) + + def remove(self, item: T) -> None: + """Remove an item from the appropriate field list.""" + cast(list, getattr(self, self._field_name(item))).remove(item) + + # This one is a bit hacky... perhaps deprecate and remove + def __getitem__(self, i: int) -> T: + # return the ith item in the __iter__ sequence + return next(itertools.islice(self, i, None)) + + # perhaps deprecate and remove + def __eq__(self, _value: object) -> bool: + if isinstance(_value, list): + return list(self) == _value + return super().__eq__(_value) + + @classmethod + def _field_name(cls, item: T) -> str: + """Return the name of the field that should contain the given item. + + Must be implemented by subclasses. + """ + raise NotImplementedError() # pragma: no cover + + +# ------------------------ StructuredAnnotations ------------------------ + +AnnotationType = Union[ + XMLAnnotation, + FileAnnotation, + ListAnnotation, + LongAnnotation, + DoubleAnnotation, + CommentAnnotation, + BooleanAnnotation, + TimestampAnnotation, + TagAnnotation, + TermAnnotation, + MapAnnotation, +] +# get_args wasn't available until Python 3.8 +AnnotationInstances = AnnotationType.__args__ # type: ignore + + +class StructuredAnnotationsMixin(CollectionMixin[AnnotationType]): + @classmethod + def _field_name(cls, item: Any) -> str: + if not isinstance(item, AnnotationInstances): + raise TypeError( # pragma: no cover + f"Expected an instance of {AnnotationInstances}, got {item!r}" + ) + # where 10 is the length of "Annotation" + return item.__class__.__name__[:-10].lower() + "_annotations" + + +ShapeType = Union[Rectangle, Mask, Point, Ellipse, Line, Polyline, Polygon, Label] +ShapeInstances = ShapeType.__args__ # type: ignore + + +class ShapeUnionMixin(CollectionMixin[ShapeType]): + @classmethod + def _field_name(cls, item: Any) -> str: + if not isinstance(item, ShapeInstances): + raise TypeError( # pragma: no cover + f"Expected an instance of {ShapeInstances}, got {item!r}" + ) + return item.__class__.__name__.lower() + "s" diff --git a/src/ome_types/_mixins/_validators.py b/src/ome_types/_mixins/_validators.py index ae2be08d..1a7259f4 100644 --- a/src/ome_types/_mixins/_validators.py +++ b/src/ome_types/_mixins/_validators.py @@ -7,9 +7,12 @@ if TYPE_CHECKING: from ome_types.model import ( # type: ignore + OME, + ROI, BinData, Pixels, PixelType, + StructuredAnnotations, XMLAnnotation, ) from xsdata_pydantic_basemodel.compat import AnyElement @@ -86,3 +89,37 @@ def pixel_type_to_numpy_dtype(self: "PixelType") -> str: "bit": "bool", # ? } return m.get(self.value, self.value) + + +# @field_validator("structured_annotations", mode="before") +def validate_structured_annotations(cls: "OME", v: Any) -> "StructuredAnnotations": + """Convert list input for OME.structured_annotations to dict.""" + from ome_types.model import StructuredAnnotations + + if isinstance(v, StructuredAnnotations): + return v + if isinstance(v, list): + # convert list[AnnotationType] to dict with keys matching the + # fields in StructuredAnnotations + _values: dict = {} + for item in v: + _values.setdefault(StructuredAnnotations._field_name(item), []).append(item) + v = _values + return v + + +# @field_validator("union", mode="before") +def validate_shape_union(cls: "ROI", v: Any) -> "ROI.Union": + """Convert list input for OME.structured_annotations to dict.""" + from ome_types.model import ROI + + if isinstance(v, ROI.Union): + return v + if isinstance(v, list): + # convert list[AnnotationType] to dict with keys matching the + # fields in StructuredAnnotations + _values: dict = {} + for item in v: + _values.setdefault(ROI.Union._field_name(item), []).append(item) + v = _values + return v diff --git a/src/ome_types/model/__init__.py b/src/ome_types/model/__init__.py index 7cf4a457..bf000f50 100644 --- a/src/ome_types/model/__init__.py +++ b/src/ome_types/model/__init__.py @@ -4,7 +4,7 @@ import sys from importlib.abc import Loader, MetaPathFinder from pathlib import Path -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING, Any, Sequence from ome_types._autogenerated.ome_2016_06 import * # noqa @@ -13,10 +13,6 @@ from ome_types._autogenerated.ome_2016_06 import OME as OME from ome_types._autogenerated.ome_2016_06 import Reference as Reference from ome_types.model._color import Color as Color -from ome_types.model._shape_union import ShapeUnion as ShapeUnion -from ome_types.model._structured_annotations import ( - StructuredAnnotationList as StructuredAnnotationList, -) if TYPE_CHECKING: from importlib.machinery import ModuleSpec @@ -83,3 +79,21 @@ def find_spec( register_converters() del register_converters + + +def __getattr__(name: str) -> Any: + if name == "StructuredAnnotationList": + import warnings + + warnings.warn( + "StructuredAnnotationList has been renamed to StructuredAnnotations. ", + stacklevel=2, + ) + from ome_types._autogenerated.ome_2016_06 import StructuredAnnotations + + return StructuredAnnotations + if name in ("ShapeUnion", "Union"): + from ome_types._autogenerated.ome_2016_06 import ROI + + return ROI.Union + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ome_types/model/_shape_union.py b/src/ome_types/model/_shape_union.py deleted file mode 100644 index d3ac5f09..00000000 --- a/src/ome_types/model/_shape_union.py +++ /dev/null @@ -1,158 +0,0 @@ -from contextlib import suppress -from typing import Dict, Iterator, List, Sequence, Type, Union - -import pydantic.version -from pydantic import Field, ValidationError, validator - -# for circular import reasons... -from ome_types._autogenerated.ome_2016_06.ellipse import Ellipse -from ome_types._autogenerated.ome_2016_06.label import Label -from ome_types._autogenerated.ome_2016_06.line import Line -from ome_types._autogenerated.ome_2016_06.mask import Mask -from ome_types._autogenerated.ome_2016_06.point import Point -from ome_types._autogenerated.ome_2016_06.polygon import Polygon -from ome_types._autogenerated.ome_2016_06.polyline import Polyline -from ome_types._autogenerated.ome_2016_06.rectangle import Rectangle -from ome_types._mixins._base_type import OMEType -from ome_types.model._user_sequence import UserSequence - -ShapeType = Union[Rectangle, Mask, Point, Ellipse, Line, Polyline, Polygon, Label] -_KINDS: Dict[str, Type[ShapeType]] = { - "rectangle": Rectangle, - "mask": Mask, - "point": Point, - "ellipse": Ellipse, - "line": Line, - "polyline": Polyline, - "polygon": Polygon, - "label": Label, -} - -_ShapeCls = tuple(_KINDS.values()) - -PYDANTIC2 = pydantic.version.VERSION.startswith("2") - -if PYDANTIC2: - from pydantic import RootModel, field_validator - - class ShapeUnion(OMEType, RootModel, UserSequence[ShapeType]): # type: ignore[misc] - """A mutable sequence of [`ome_types.model.Shape`][]. - - Members of this sequence must be one of the following types: - - - [`ome_types.model.Rectangle`][] - - [`ome_types.model.Mask`][] - - [`ome_types.model.Point`][] - - [`ome_types.model.Ellipse`][] - - [`ome_types.model.Line`][] - - [`ome_types.model.Polyline`][] - - [`ome_types.model.Polygon`][] - - [`ome_types.model.Label`][] - """ - - # NOTE: in reality, this is List[ShapeGroupType]... but - # for some reason that messes up xsdata data binding - # see also, HACK in xsdata_pydantic_basemodel/pydantic_compat.py - root: List[object] = Field( - default_factory=list, - json_schema_extra={ - "type": "Elements", - "choices": tuple( # type: ignore - (("name", kind.title()), ("type", cls.__name__)) - for kind, cls in _KINDS.items() - ), - }, - ) - - @field_validator("root") - def _validate_root(cls, value: ShapeType) -> ShapeType: - if not isinstance(value, Sequence): # pragma: no cover - raise ValueError(f"Value must be a sequence, not {type(value)}") - - items = [] - for v in value: - if isinstance(v, _ShapeCls): - items.append(v) - elif isinstance(v, dict): - # NOTE: this is here to preserve the v1 behavior of passing a dict - # like {"kind": "label", "x": 0, "y": 0} - # to create a label rather than a point - if "kind" in v: - kind = v.pop("kind").lower() - items.append(_KINDS[kind](**v)) - else: - for cls_ in _ShapeCls: - with suppress(ValidationError): - items.append(cls_(warn_extra=False, **v)) - break - else: # pragma: no cover - raise ValueError(f"Invalid shape: {v}") # pragma: no cover - return items - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.root!r})" - - # overriding BaseModel.__iter__ to behave more like a real Sequence - def __iter__(self) -> Iterator[ShapeType]: # type: ignore[override] - yield from self.root # type: ignore[misc] # see NOTE above - - def __eq__(self, _value: object) -> bool: - return _value == self.root - -else: - - class ShapeUnion(OMEType, UserSequence[ShapeType]): # type: ignore - """A mutable sequence of [`ome_types.model.Shape`][]. - - Members of this sequence must be one of the following types: - - - [`ome_types.model.Rectangle`][] - - [`ome_types.model.Mask`][] - - [`ome_types.model.Point`][] - - [`ome_types.model.Ellipse`][] - - [`ome_types.model.Line`][] - - [`ome_types.model.Polyline`][] - - [`ome_types.model.Polygon`][] - - [`ome_types.model.Label`][] - """ - - # NOTE: in reality, this is List[ShapeGroupType]... but - # for some reason that messes up xsdata data binding - # see also, HACK in xsdata_pydantic_basemodel/pydantic_compat.py - __root__: List[object] = Field( - default_factory=list, - metadata={ # type: ignore[call-arg] - "type": "Elements", - "choices": tuple( - (("name", kind.title()), ("type", cls.__name__)) - for kind, cls in _KINDS.items() - ), - }, - ) - - @validator("__root__", each_item=True) - def _validate_root(cls, v: ShapeType) -> ShapeType: - if isinstance(v, _ShapeCls): - return v - if isinstance(v, dict): - # NOTE: this is here to preserve the v1 behavior of passing a dict like - # {"kind": "label", "x": 0, "y": 0} - # to create a label rather than a point - if "kind" in v: - kind = v.pop("kind").lower() - return _KINDS[kind](**v) - - for cls_ in _ShapeCls: - with suppress(ValidationError): - return cls_(warn_extra=False, **v) - raise ValueError(f"Invalid shape: {v}") # pragma: no cover - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.__root__!r})" - - # overriding BaseModel.__iter__ to behave more like a real Sequence - def __iter__(self) -> Iterator[ShapeType]: # type: ignore[override] - yield from self.__root__ # type: ignore[misc] # see NOTE above - - def __eq__(self, _value: object) -> bool: - return _value == self.__root__ diff --git a/src/ome_types/model/_structured_annotations.py b/src/ome_types/model/_structured_annotations.py deleted file mode 100644 index a46d66c2..00000000 --- a/src/ome_types/model/_structured_annotations.py +++ /dev/null @@ -1,160 +0,0 @@ -from contextlib import suppress -from typing import Iterator, List, Sequence - -import pydantic.version -from pydantic import Field, ValidationError, validator - -# for circular import reasons... -from ome_types._autogenerated.ome_2016_06.annotation import Annotation -from ome_types._autogenerated.ome_2016_06.boolean_annotation import BooleanAnnotation -from ome_types._autogenerated.ome_2016_06.comment_annotation import CommentAnnotation -from ome_types._autogenerated.ome_2016_06.double_annotation import DoubleAnnotation -from ome_types._autogenerated.ome_2016_06.file_annotation import FileAnnotation -from ome_types._autogenerated.ome_2016_06.list_annotation import ListAnnotation -from ome_types._autogenerated.ome_2016_06.long_annotation import LongAnnotation -from ome_types._autogenerated.ome_2016_06.map_annotation import MapAnnotation -from ome_types._autogenerated.ome_2016_06.tag_annotation import TagAnnotation -from ome_types._autogenerated.ome_2016_06.term_annotation import TermAnnotation -from ome_types._autogenerated.ome_2016_06.timestamp_annotation import ( - TimestampAnnotation, -) -from ome_types._autogenerated.ome_2016_06.xml_annotation import XMLAnnotation -from ome_types._mixins._base_type import OMEType -from ome_types.model._user_sequence import UserSequence - -AnnotationTypes = ( - XMLAnnotation, - FileAnnotation, - ListAnnotation, - LongAnnotation, - DoubleAnnotation, - CommentAnnotation, - BooleanAnnotation, - TimestampAnnotation, - TagAnnotation, - TermAnnotation, - MapAnnotation, -) -_KINDS = {cls.__name__.lower(): cls for cls in AnnotationTypes} - - -if pydantic.version.VERSION.startswith("2"): - from pydantic import RootModel, field_validator - - class StructuredAnnotationList(OMEType, RootModel, UserSequence[Annotation]): # type: ignore[misc] - """A mutable sequence of [`ome_types.model.Annotation`][]. - - Members of this sequence must be one of the following types: - - - [`ome_types.model.XMLAnnotation`][] - - [`ome_types.model.FileAnnotation`][] - - [`ome_types.model.ListAnnotation`][] - - [`ome_types.model.LongAnnotation`][] - - [`ome_types.model.DoubleAnnotation`][] - - [`ome_types.model.CommentAnnotation`][] - - [`ome_types.model.BooleanAnnotation`][] - - [`ome_types.model.TimestampAnnotation`][] - - [`ome_types.model.TagAnnotation`][] - - [`ome_types.model.TermAnnotation`][] - - [`ome_types.model.MapAnnotation`][] - """ - - # NOTE: in reality, this is List[StructuredAnnotationTypes]... but - # for some reason that messes up xsdata data binding - # see also, HACK in xsdata_pydantic_basemodel/pydantic_compat.py - root: List[object] = Field( - default_factory=list, - json_schema_extra={ - "type": "Elements", - "choices": tuple( # type: ignore - (("name", cls.__name__), ("type", cls.__name__)) - for cls in AnnotationTypes - ), - }, - ) - - @field_validator("root") - def _validate_root(cls, v: List[object]) -> List[Annotation]: - if not isinstance(v, Sequence): # pragma: no cover - raise ValueError(f"Value must be a sequence, not {type(v)}") - items: List[Annotation] = [] - for item in v: - if isinstance(item, AnnotationTypes): - items.append(item) - elif isinstance(item, dict): - if "kind" in item: - items.append(_KINDS[item.pop("kind")](**item)) - else: - for cls_ in AnnotationTypes: - with suppress(ValidationError): - items.append(cls_(**item)) - break - else: # pragma: no cover - raise ValueError(f"Invalid Annotation: {item} of type {type(item)}") - return items - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.root!r})" - - # overriding BaseModel.__iter__ to behave more like a real Sequence - def __iter__(self) -> Iterator[Annotation]: # type: ignore[override] - yield from self.root # type: ignore[misc] # see NOTE above - - def __eq__(self, _value: object) -> bool: - return _value == self.root - -else: - - class StructuredAnnotationList(OMEType, UserSequence[Annotation]): # type: ignore - """A mutable sequence of [`ome_types.model.Annotation`][]. - - Members of this sequence must be one of the following types: - - - [`ome_types.model.XMLAnnotation`][] - - [`ome_types.model.FileAnnotation`][] - - [`ome_types.model.ListAnnotation`][] - - [`ome_types.model.LongAnnotation`][] - - [`ome_types.model.DoubleAnnotation`][] - - [`ome_types.model.CommentAnnotation`][] - - [`ome_types.model.BooleanAnnotation`][] - - [`ome_types.model.TimestampAnnotation`][] - - [`ome_types.model.TagAnnotation`][] - - [`ome_types.model.TermAnnotation`][] - - [`ome_types.model.MapAnnotation`][] - """ - - # NOTE: in reality, this is List[StructuredAnnotationTypes]... but - # for some reason that messes up xsdata data binding - # see also, HACK in xsdata_pydantic_basemodel/pydantic_compat.py - __root__: List[object] = Field( - default_factory=list, - metadata={ # type: ignore[call-arg] - "type": "Elements", - "choices": tuple( - (("name", cls.__name__), ("type", cls.__name__)) - for cls in AnnotationTypes - ), - }, - ) - - @validator("__root__", each_item=True) - 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) - raise ValueError(f"Invalid Annotation: {v} of type {type(v)}") - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.__root__!r})" - - # overriding BaseModel.__iter__ to behave more like a real Sequence - def __iter__(self) -> Iterator[Annotation]: # type: ignore[override] - yield from self.__root__ # type: ignore[misc] # see NOTE above - - def __eq__(self, _value: object) -> bool: - return _value == self.__root__ diff --git a/src/ome_types/model/_user_sequence.py b/src/ome_types/model/_user_sequence.py deleted file mode 100644 index 1b73ba31..00000000 --- a/src/ome_types/model/_user_sequence.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Iterable, List, MutableSequence, TypeVar, Union, overload - -import pydantic.version - -T = TypeVar("T") - -if pydantic.version.VERSION.startswith("2"): - ROOT_NAME = "root" -else: - ROOT_NAME = "__root__" - - -class UserSequence(MutableSequence[T]): - """Generric Mutable sequence, that expects the real list at __root__.""" - - if pydantic.version.VERSION.startswith("2"): - root: List[object] - else: - __root__: List[object] - - def __repr__(self) -> str: - return repr(getattr(self, ROOT_NAME)) - - def __delitem__(self, _idx: Union[int, slice]) -> None: - del getattr(self, ROOT_NAME)[_idx] - - @overload - def __getitem__(self, _idx: int) -> T: - ... - - @overload - def __getitem__(self, _idx: slice) -> List[T]: - ... - - def __getitem__(self, _idx: Union[int, slice]) -> Union[T, List[T]]: - return getattr(self, ROOT_NAME)[_idx] # type: ignore[return-value] - - def __len__(self) -> int: - return len(getattr(self, ROOT_NAME)) - - @overload - def __setitem__(self, _idx: int, _val: T) -> None: - ... - - @overload - def __setitem__(self, _idx: slice, _val: Iterable[T]) -> None: - ... - - def __setitem__(self, _idx: Union[int, slice], _val: Union[T, Iterable[T]]) -> None: - getattr(self, ROOT_NAME)[_idx] = _val # type: ignore[index] - - def insert(self, index: int, value: T) -> None: - getattr(self, ROOT_NAME).insert(index, value) - - # for some reason, without overloading this... append() adds things to the - # beginning of the list instead of the end - def append(self, value: T) -> None: - getattr(self, ROOT_NAME).append(value) diff --git a/tests/test_paquo.py b/tests/test_paquo.py index c8165748..0bf1cf32 100644 --- a/tests/test_paquo.py +++ b/tests/test_paquo.py @@ -6,7 +6,11 @@ from ome_types import validate_xml # to run this test locally, you can download QuPath.app as follows: +# pip install paquo +# mkdir -p qupath/apps qupath/download +# brew install p7zip # python -m paquo get_qupath --install-path ./qupath/apps --download-path ./qupath/download 0.4.3 # noqa: E501 +# export PAQUO_QUPATH_DIR=./qupath/apps/QuPath-0.4.3.app # (this is done automatically on CI) if "PAQUO_QUPATH_DIR" not in os.environ: qupath_apps = Path(__file__).parent.parent / "qupath" / "apps" diff --git a/tests/test_rois.py b/tests/test_rois.py new file mode 100644 index 00000000..44ff1e8f --- /dev/null +++ b/tests/test_rois.py @@ -0,0 +1,18 @@ +from ome_types import OME, model + + +def test_roi_shapes() -> None: + # this test is similar to what paquo does + + ome = OME() + + # --- create the roi + roi = model.ROI(name="class_name") + + ome_shape = model.Rectangle(height=1.0, width=1.0, x=1.0, y=1.0) + roi.union.append(ome_shape) + assert ome_shape in roi.union.rectangles + + # --- add the annotation to the ome structure + ome.rois.append(roi) + assert ome.to_xml()