From d921d4370838005ec3155a7f3acf727a88823910 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 12 Jul 2023 11:28:01 -0400 Subject: [PATCH 1/6] fix migration guide --- docs/migration.md | 212 ++++++++++++++++++++++++++----- docs/scripts/write_v2_changes.py | 6 +- 2 files changed, 185 insertions(+), 33 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index accde7cc..15841a4e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -65,11 +65,11 @@ your code to the new version. ### [`Instrument`][ome_types.model.Instrument] - **`light_source_group`** - name removed -- **`generic_excitation_sources`** - name added -- **`light_emitting_diodes`** - name added -- **`filaments`** - name added - **`arcs`** - name added +- **`filaments`** - name added +- **`generic_excitation_sources`** - name added - **`lasers`** - name added +- **`light_emitting_diodes`** - name added - **`annotation_ref`** - name changed to `annotation_refs` ### `XMLAnnotation.Value` @@ -79,16 +79,18 @@ your code to the new version. ### [`Annotation`][ome_types.model.Annotation] - **`annotation_ref`** - name changed to `annotation_refs` +- **`annotator`** - type changed from `Optional[ExperimenterID]` to `Optional[ConstrainedStrValue]` ### [`Channel`][ome_types.model.Channel] - **`annotation_ref`** - name changed to `annotation_refs` - -### [`LightPath`][ome_types.model.LightPath] - -- **`annotation_ref`** - name changed to `annotation_refs` -- **`emission_filter_ref`** - name changed to `emission_filters` -- **`excitation_filter_ref`** - name changed to `excitation_filters` +- **`acquisition_mode`** - type changed from `Optional[AcquisitionMode]` to `Optional[Channel_AcquisitionMode]` +- **`color`** - type changed from `Optional[Color]` to `Color` +- **`contrast_method`** - type changed from `Optional[ContrastMethod]` to `Optional[Channel_ContrastMethod]` +- **`emission_wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`excitation_wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`illumination_type`** - type changed from `Optional[IlluminationType]` to `Optional[Channel_IlluminationType]` +- **`pinhole_size_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` ### [`Dataset`][ome_types.model.Dataset] @@ -98,16 +100,13 @@ your code to the new version. ### [`Detector`][ome_types.model.Detector] - **`annotation_ref`** - name changed to `annotation_refs` +- **`type`** - type changed from `Optional[Type]` to `Optional[Detector_Type]` +- **`voltage_unit`** - type changed from `Optional[UnitsElectricPotential]` to `UnitsElectricPotential` ### [`Dichroic`][ome_types.model.Dichroic] - **`annotation_ref`** - name changed to `annotation_refs` -### [`MicrobeamManipulation`][ome_types.model.MicrobeamManipulation] - -- **`light_source_settings`** - name changed to `light_source_settings_combinations` -- **`roi_ref`** - name changed to `roi_refs` - ### [`Experimenter`][ome_types.model.Experimenter] - **`annotation_ref`** - name changed to `annotation_refs` @@ -121,6 +120,7 @@ your code to the new version. ### [`Filter`][ome_types.model.Filter] - **`annotation_ref`** - name changed to `annotation_refs` +- **`type`** - type changed from `Optional[Type]` to `Optional[Filter_Type]` ### [`FilterSet`][ome_types.model.FilterSet] @@ -134,64 +134,216 @@ your code to the new version. - **`image_ref`** - name changed to `image_refs` - **`roi_ref`** - name changed to `roi_refs` +### [`Image`][ome_types.model.Image] + +- **`annotation_ref`** - name changed to `annotation_refs` +- **`microbeam_manipulation_ref`** - name changed to `microbeam_manipulation_refs` +- **`roi_ref`** - name changed to `roi_refs` + +### [`LightPath`][ome_types.model.LightPath] + +- **`annotation_ref`** - name changed to `annotation_refs` +- **`emission_filter_ref`** - name changed to `emission_filters` +- **`excitation_filter_ref`** - name changed to `excitation_filters` + +### [`LightSource`][ome_types.model.LightSource] + +- **`annotation_ref`** - name changed to `annotation_refs` +- **`power_unit`** - type changed from `Optional[UnitsPower]` to `UnitsPower` + ### [`Map`][ome_types.model.Map] - **`m`** - name changed to `ms` -### [`Image`][ome_types.model.Image] +### [`MicrobeamManipulation`][ome_types.model.MicrobeamManipulation] -- **`annotation_ref`** - name changed to `annotation_refs` -- **`microbeam_manipulation_ref`** - name changed to `microbeam_manipulation_refs` +- **`light_source_settings`** - name changed to `light_source_settings_combinations` - **`roi_ref`** - name changed to `roi_refs` +- **`type`** - type changed from `List[Type]` to `List[MicrobeamManipulation_value]` + +### [`Objective`][ome_types.model.Objective] + +- **`annotation_ref`** - name changed to `annotation_refs` +- **`correction`** - type changed from `Optional[Correction]` to `Optional[Objective_Correction]` +- **`immersion`** - type changed from `Optional[Immersion]` to `Optional[Objective_Immersion]` +- **`working_distance_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` ### [`Pixels`][ome_types.model.Pixels] - **`bin_data`** - name changed to `bin_data_blocks` +- **`dimension_order`** - type changed from `DimensionOrder` to `Pixels_DimensionOrder` +- **`metadata_only`** - type changed from `bool` to `Optional[MetadataOnly]` +- **`physical_size_x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`physical_size_y_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`physical_size_z_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`time_increment_unit`** - type changed from `Optional[UnitsTime]` to `UnitsTime` ### [`Plane`][ome_types.model.Plane] - **`annotation_ref`** - name changed to `annotation_refs` +- **`delta_t_unit`** - type changed from `Optional[UnitsTime]` to `UnitsTime` +- **`exposure_time_unit`** - type changed from `Optional[UnitsTime]` to `UnitsTime` +- **`hash_sha1`** - type changed from `Optional[Hex40]` to `Optional[bytes]` +- **`position_x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`position_y_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`position_z_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` -### [`Objective`][ome_types.model.Objective] +### [`Plate`][ome_types.model.Plate] - **`annotation_ref`** - name changed to `annotation_refs` +- **`well_origin_x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`well_origin_y_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` -### [`LightSource`][ome_types.model.LightSource] +### [`PlateAcquisition`][ome_types.model.PlateAcquisition] - **`annotation_ref`** - name changed to `annotation_refs` +- **`well_sample_ref`** - name changed to `well_sample_refs` ### [`Project`][ome_types.model.Project] - **`annotation_ref`** - name changed to `annotation_refs` - **`dataset_ref`** - name changed to `dataset_refs` -### [`Plate`][ome_types.model.Plate] - -- **`annotation_ref`** - name changed to `annotation_refs` - -### [`Well`][ome_types.model.Well] +### [`ROI`][ome_types.model.ROI] - **`annotation_ref`** - name changed to `annotation_refs` +- **`union`** - type changed from `List[Union[Rectangle, Mask, Point, Ellipse, Line, Polyline, Polygon, Label]]` to `ShapeUnion` -### [`PlateAcquisition`][ome_types.model.PlateAcquisition] +### [`Reagent`][ome_types.model.Reagent] - **`annotation_ref`** - name changed to `annotation_refs` -- **`well_sample_ref`** - name changed to `well_sample_refs` ### [`Screen`][ome_types.model.Screen] - **`annotation_ref`** - name changed to `annotation_refs` - **`plate_ref`** - name changed to `plate_refs` -### [`Reagent`][ome_types.model.Reagent] +### [`Shape`][ome_types.model.Shape] - **`annotation_ref`** - name changed to `annotation_refs` +- **`fill_rule`** - type changed from `Optional[FillRule]` to `Optional[Shape_FillRule]` +- **`font_family`** - type changed from `Optional[FontFamily]` to `Optional[Shape_FontFamily]` +- **`font_size_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`font_style`** - type changed from `Optional[FontStyle]` to `Optional[Shape_FontStyle]` +- **`stroke_width_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` -### [`ROI`][ome_types.model.ROI] +### [`Well`][ome_types.model.Well] - **`annotation_ref`** - name changed to `annotation_refs` +- **`color`** - type changed from `Optional[Color]` to `Color` -### [`Shape`][ome_types.model.Shape] +### [`Arc`][ome_types.model.Arc] -- **`annotation_ref`** - name changed to `annotation_refs` +- **`type`** - type changed from `Optional[Type]` to `Optional[Arc_Type]` + +### [`BinData`][ome_types.model.BinData] + +- **`compression`** - type changed from `Optional[Compression]` to `BinData_Compression` +- **`length`** - type changed from `int` to `ConstrainedIntValue` +- **`value`** - type changed from `str` to `bytes` + +### [`BinaryFile`][ome_types.model.BinaryFile] + +- **`size`** - type changed from `NonNegativeLong` to `ConstrainedIntValue` + +### `OME.BinaryOnly` + +- **`uuid`** - type changed from `UniversallyUniqueIdentifier` to `ConstrainedStrValue` + +### [`DetectorSettings`][ome_types.model.DetectorSettings] + +- **`read_out_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` +- **`voltage_unit`** - type changed from `Optional[UnitsElectricPotential]` to `UnitsElectricPotential` + +### [`Experiment`][ome_types.model.Experiment] + +- **`type`** - type changed from `List[Type]` to `List[Experiment_value]` + +### [`External`][ome_types.model.External] + +- **`compression`** - type changed from `Optional[Compression]` to `External_Compression` +- **`sha1`** - type changed from `Hex40` to `bytes` + +### [`Filament`][ome_types.model.Filament] + +- **`type`** - type changed from `Optional[Type]` to `Optional[Filament_Type]` + +### [`ImagingEnvironment`][ome_types.model.ImagingEnvironment] + +- **`air_pressure_unit`** - type changed from `Optional[UnitsPressure]` to `UnitsPressure` +- **`co2_percent`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`humidity`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`temperature_unit`** - type changed from `Optional[UnitsTemperature]` to `UnitsTemperature` + +### [`Laser`][ome_types.model.Laser] + +- **`laser_medium`** - type changed from `Optional[LaserMedium]` to `Optional[Laser_LaserMedium]` +- **`pulse`** - type changed from `Optional[Pulse]` to `Optional[Laser_Pulse]` +- **`repetition_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` +- **`type`** - type changed from `Optional[Type]` to `Optional[Laser_Type]` +- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + +### [`LightSourceSettings`][ome_types.model.LightSourceSettings] + +- **`attenuation`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + +### `Map.M` + +- **`k`** - type changed from `str` to `Optional[str]` + +### [`Microscope`][ome_types.model.Microscope] + +- **`type`** - type changed from `Optional[Type]` to `Optional[Microscope_Type]` + +### [`OME`][ome_types.model.OME] + +- **`structured_annotations`** - type changed from `List[Annotation]` to `StructuredAnnotationList` +- **`uuid`** - type changed from `Optional[UniversallyUniqueIdentifier]` to `Optional[ConstrainedStrValue]` + +### [`ObjectiveSettings`][ome_types.model.ObjectiveSettings] + +- **`medium`** - type changed from `Optional[Medium]` to `Optional[ObjectiveSettings_Medium]` + +### [`StageLabel`][ome_types.model.StageLabel] + +- **`x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`y_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`z_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + +### [`StructuredAnnotations`][ome_types.model.StructuredAnnotations] + +- **`boolean_annotations`** - type changed from `Optional[BooleanAnnotation]` to `List[BooleanAnnotation]` +- **`comment_annotations`** - type changed from `Optional[CommentAnnotation]` to `List[CommentAnnotation]` +- **`double_annotations`** - type changed from `Optional[DoubleAnnotation]` to `List[DoubleAnnotation]` +- **`file_annotations`** - type changed from `Optional[FileAnnotation]` to `List[FileAnnotation]` +- **`list_annotations`** - type changed from `Optional[ListAnnotation]` to `List[ListAnnotation]` +- **`long_annotations`** - type changed from `Optional[LongAnnotation]` to `List[LongAnnotation]` +- **`map_annotations`** - type changed from `Optional[MapAnnotation]` to `List[MapAnnotation]` +- **`tag_annotations`** - type changed from `Optional[TagAnnotation]` to `List[TagAnnotation]` +- **`term_annotations`** - type changed from `Optional[TermAnnotation]` to `List[TermAnnotation]` +- **`timestamp_annotations`** - type changed from `Optional[TimestampAnnotation]` to `List[TimestampAnnotation]` +- **`xml_annotations`** - type changed from `Optional[XMLAnnotation]` to `List[XMLAnnotation]` + +### [`TransmittanceRange`][ome_types.model.TransmittanceRange] + +- **`cut_in_tolerance_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`cut_in_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`cut_out_tolerance_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`cut_out_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`transmittance`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` + +### `TiffData.UUID` + +- **`file_name`** - type changed from `str` to `Optional[str]` +- **`value`** - type changed from `UniversallyUniqueIdentifier` to `ConstrainedStrValue` + +### [`WellSample`][ome_types.model.WellSample] + +- **`position_x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +- **`position_y_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + +### [`XMLAnnotation`][ome_types.model.XMLAnnotation] + +- **`value`** - type changed from `Element` to `Value` \ No newline at end of file diff --git a/docs/scripts/write_v2_changes.py b/docs/scripts/write_v2_changes.py index 0a4d2c9b..586d41ce 100644 --- a/docs/scripts/write_v2_changes.py +++ b/docs/scripts/write_v2_changes.py @@ -126,7 +126,7 @@ def dump_fields() -> None: def get_diffs(pth1: Path = V1) -> tuple[list, list, dict, dict]: data1 = json.loads(pth1.read_text()) - data2 = get_fields(ome_types.model) + data2 = json.loads(json.dumps(get_fields(ome_types.model), sort_keys=True)) diff = DeepDiff(data1, data2, ignore_order=True) removed = list(diff["dictionary_item_removed"]) @@ -138,7 +138,7 @@ def get_diffs(pth1: Path = V1) -> tuple[list, list, dict, dict]: if max_ratio > 85: keys_changed[key] = added.pop(ratios.index(max(ratios))) removed.remove(key) - return removed, added, keys_changed, diff.get("values_changed", {}) + return removed, added, keys_changed, diff["values_changed"] def _cls_field(key: str) -> tuple[str, str | None]: @@ -182,7 +182,6 @@ def gather_classes() -> tuple[dict, dict]: or ("NonNegativeFloat" in old and "ConstrainedFloatValue" in new) ): continue - cls_dict = class_changes.setdefault(cls_name, {}) cls_dict[field_name] = {"type": "type_changed", "from": old, "to": new} @@ -243,6 +242,7 @@ def markdown_changes(heading_level: int = 2) -> str: if __name__ == "__main__": + # dump_fields() migration = DOCS / "migration.md" current_text = migration.read_text() START = "" From 5056da6df3ff319811b50238fbe60487e8f8ae15 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 12 Jul 2023 11:33:40 -0400 Subject: [PATCH 2/6] sort docs --- docs/migration.md | 179 +++++++++++++++---------------- docs/scripts/write_v2_changes.py | 4 +- 2 files changed, 92 insertions(+), 91 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 15841a4e..060849e5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -62,24 +62,28 @@ your code to the new version. ## Class Field Changes -### [`Instrument`][ome_types.model.Instrument] +### [`Annotation`][ome_types.model.Annotation] -- **`light_source_group`** - name removed -- **`arcs`** - name added -- **`filaments`** - name added -- **`generic_excitation_sources`** - name added -- **`lasers`** - name added -- **`light_emitting_diodes`** - name added - **`annotation_ref`** - name changed to `annotation_refs` +- **`annotator`** - type changed from `Optional[ExperimenterID]` to `Optional[ConstrainedStrValue]` -### `XMLAnnotation.Value` +### [`Arc`][ome_types.model.Arc] -- **`None`** - name added +- **`type`** - type changed from `Optional[Type]` to `Optional[Arc_Type]` -### [`Annotation`][ome_types.model.Annotation] +### [`BinData`][ome_types.model.BinData] -- **`annotation_ref`** - name changed to `annotation_refs` -- **`annotator`** - type changed from `Optional[ExperimenterID]` to `Optional[ConstrainedStrValue]` +- **`compression`** - type changed from `Optional[Compression]` to `BinData_Compression` +- **`length`** - type changed from `int` to `ConstrainedIntValue` +- **`value`** - type changed from `str` to `bytes` + +### [`BinaryFile`][ome_types.model.BinaryFile] + +- **`size`** - type changed from `NonNegativeLong` to `ConstrainedIntValue` + +### `OME.BinaryOnly` + +- **`uuid`** - type changed from `UniversallyUniqueIdentifier` to `ConstrainedStrValue` ### [`Channel`][ome_types.model.Channel] @@ -103,10 +107,19 @@ your code to the new version. - **`type`** - type changed from `Optional[Type]` to `Optional[Detector_Type]` - **`voltage_unit`** - type changed from `Optional[UnitsElectricPotential]` to `UnitsElectricPotential` +### [`DetectorSettings`][ome_types.model.DetectorSettings] + +- **`read_out_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` +- **`voltage_unit`** - type changed from `Optional[UnitsElectricPotential]` to `UnitsElectricPotential` + ### [`Dichroic`][ome_types.model.Dichroic] - **`annotation_ref`** - name changed to `annotation_refs` +### [`Experiment`][ome_types.model.Experiment] + +- **`type`** - type changed from `List[Type]` to `List[Experiment_value]` + ### [`Experimenter`][ome_types.model.Experimenter] - **`annotation_ref`** - name changed to `annotation_refs` @@ -117,6 +130,15 @@ your code to the new version. - **`experimenter_ref`** - name changed to `experimenter_refs` - **`leader`** - name changed to `leaders` +### [`External`][ome_types.model.External] + +- **`compression`** - type changed from `Optional[Compression]` to `External_Compression` +- **`sha1`** - type changed from `Hex40` to `bytes` + +### [`Filament`][ome_types.model.Filament] + +- **`type`** - type changed from `Optional[Type]` to `Optional[Filament_Type]` + ### [`Filter`][ome_types.model.Filter] - **`annotation_ref`** - name changed to `annotation_refs` @@ -140,6 +162,31 @@ your code to the new version. - **`microbeam_manipulation_ref`** - name changed to `microbeam_manipulation_refs` - **`roi_ref`** - name changed to `roi_refs` +### [`ImagingEnvironment`][ome_types.model.ImagingEnvironment] + +- **`air_pressure_unit`** - type changed from `Optional[UnitsPressure]` to `UnitsPressure` +- **`co2_percent`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`humidity`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`temperature_unit`** - type changed from `Optional[UnitsTemperature]` to `UnitsTemperature` + +### [`Instrument`][ome_types.model.Instrument] + +- **`light_source_group`** - name removed +- **`arcs`** - name added +- **`filaments`** - name added +- **`generic_excitation_sources`** - name added +- **`lasers`** - name added +- **`light_emitting_diodes`** - name added +- **`annotation_ref`** - name changed to `annotation_refs` + +### [`Laser`][ome_types.model.Laser] + +- **`laser_medium`** - type changed from `Optional[LaserMedium]` to `Optional[Laser_LaserMedium]` +- **`pulse`** - type changed from `Optional[Pulse]` to `Optional[Laser_Pulse]` +- **`repetition_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` +- **`type`** - type changed from `Optional[Type]` to `Optional[Laser_Type]` +- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + ### [`LightPath`][ome_types.model.LightPath] - **`annotation_ref`** - name changed to `annotation_refs` @@ -151,6 +198,15 @@ your code to the new version. - **`annotation_ref`** - name changed to `annotation_refs` - **`power_unit`** - type changed from `Optional[UnitsPower]` to `UnitsPower` +### [`LightSourceSettings`][ome_types.model.LightSourceSettings] + +- **`attenuation`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` +- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` + +### `Map.M` + +- **`k`** - type changed from `str` to `Optional[str]` + ### [`Map`][ome_types.model.Map] - **`m`** - name changed to `ms` @@ -161,6 +217,15 @@ your code to the new version. - **`roi_ref`** - name changed to `roi_refs` - **`type`** - type changed from `List[Type]` to `List[MicrobeamManipulation_value]` +### [`Microscope`][ome_types.model.Microscope] + +- **`type`** - type changed from `Optional[Type]` to `Optional[Microscope_Type]` + +### [`OME`][ome_types.model.OME] + +- **`structured_annotations`** - type changed from `List[Annotation]` to `StructuredAnnotationList` +- **`uuid`** - type changed from `Optional[UniversallyUniqueIdentifier]` to `Optional[ConstrainedStrValue]` + ### [`Objective`][ome_types.model.Objective] - **`annotation_ref`** - name changed to `annotation_refs` @@ -168,6 +233,10 @@ your code to the new version. - **`immersion`** - type changed from `Optional[Immersion]` to `Optional[Objective_Immersion]` - **`working_distance_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` +### [`ObjectiveSettings`][ome_types.model.ObjectiveSettings] + +- **`medium`** - type changed from `Optional[Medium]` to `Optional[ObjectiveSettings_Medium]` + ### [`Pixels`][ome_types.model.Pixels] - **`bin_data`** - name changed to `bin_data_blocks` @@ -227,84 +296,6 @@ your code to the new version. - **`font_style`** - type changed from `Optional[FontStyle]` to `Optional[Shape_FontStyle]` - **`stroke_width_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` -### [`Well`][ome_types.model.Well] - -- **`annotation_ref`** - name changed to `annotation_refs` -- **`color`** - type changed from `Optional[Color]` to `Color` - -### [`Arc`][ome_types.model.Arc] - -- **`type`** - type changed from `Optional[Type]` to `Optional[Arc_Type]` - -### [`BinData`][ome_types.model.BinData] - -- **`compression`** - type changed from `Optional[Compression]` to `BinData_Compression` -- **`length`** - type changed from `int` to `ConstrainedIntValue` -- **`value`** - type changed from `str` to `bytes` - -### [`BinaryFile`][ome_types.model.BinaryFile] - -- **`size`** - type changed from `NonNegativeLong` to `ConstrainedIntValue` - -### `OME.BinaryOnly` - -- **`uuid`** - type changed from `UniversallyUniqueIdentifier` to `ConstrainedStrValue` - -### [`DetectorSettings`][ome_types.model.DetectorSettings] - -- **`read_out_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` -- **`voltage_unit`** - type changed from `Optional[UnitsElectricPotential]` to `UnitsElectricPotential` - -### [`Experiment`][ome_types.model.Experiment] - -- **`type`** - type changed from `List[Type]` to `List[Experiment_value]` - -### [`External`][ome_types.model.External] - -- **`compression`** - type changed from `Optional[Compression]` to `External_Compression` -- **`sha1`** - type changed from `Hex40` to `bytes` - -### [`Filament`][ome_types.model.Filament] - -- **`type`** - type changed from `Optional[Type]` to `Optional[Filament_Type]` - -### [`ImagingEnvironment`][ome_types.model.ImagingEnvironment] - -- **`air_pressure_unit`** - type changed from `Optional[UnitsPressure]` to `UnitsPressure` -- **`co2_percent`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` -- **`humidity`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` -- **`temperature_unit`** - type changed from `Optional[UnitsTemperature]` to `UnitsTemperature` - -### [`Laser`][ome_types.model.Laser] - -- **`laser_medium`** - type changed from `Optional[LaserMedium]` to `Optional[Laser_LaserMedium]` -- **`pulse`** - type changed from `Optional[Pulse]` to `Optional[Laser_Pulse]` -- **`repetition_rate_unit`** - type changed from `Optional[UnitsFrequency]` to `UnitsFrequency` -- **`type`** - type changed from `Optional[Type]` to `Optional[Laser_Type]` -- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` - -### [`LightSourceSettings`][ome_types.model.LightSourceSettings] - -- **`attenuation`** - type changed from `Optional[PercentFraction]` to `Optional[ConstrainedFloatValue]` -- **`wavelength_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` - -### `Map.M` - -- **`k`** - type changed from `str` to `Optional[str]` - -### [`Microscope`][ome_types.model.Microscope] - -- **`type`** - type changed from `Optional[Type]` to `Optional[Microscope_Type]` - -### [`OME`][ome_types.model.OME] - -- **`structured_annotations`** - type changed from `List[Annotation]` to `StructuredAnnotationList` -- **`uuid`** - type changed from `Optional[UniversallyUniqueIdentifier]` to `Optional[ConstrainedStrValue]` - -### [`ObjectiveSettings`][ome_types.model.ObjectiveSettings] - -- **`medium`** - type changed from `Optional[Medium]` to `Optional[ObjectiveSettings_Medium]` - ### [`StageLabel`][ome_types.model.StageLabel] - **`x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` @@ -338,6 +329,14 @@ your code to the new version. - **`file_name`** - type changed from `str` to `Optional[str]` - **`value`** - type changed from `UniversallyUniqueIdentifier` to `ConstrainedStrValue` +### `XMLAnnotation.Value` + + +### [`Well`][ome_types.model.Well] + +- **`annotation_ref`** - name changed to `annotation_refs` +- **`color`** - type changed from `Optional[Color]` to `Color` + ### [`WellSample`][ome_types.model.WellSample] - **`position_x_unit`** - type changed from `Optional[UnitsLength]` to `UnitsLength` diff --git a/docs/scripts/write_v2_changes.py b/docs/scripts/write_v2_changes.py index 586d41ce..6ac07552 100644 --- a/docs/scripts/write_v2_changes.py +++ b/docs/scripts/write_v2_changes.py @@ -207,7 +207,7 @@ def markdown_changes(heading_level: int = 2) -> str: lines.append(f"- `{removed}`") lines.extend(["", f"{hd1} Class Field Changes", ""]) - for cls_name, cls_changes in class_changes.items(): + for cls_name, cls_changes in sorted(class_changes.items()): # special casing, but don't care to make it more general if cls_name == "Value": link = "`XMLAnnotation.Value`" @@ -221,6 +221,8 @@ def markdown_changes(heading_level: int = 2) -> str: link = f"[`{cls_name}`][ome_types.model.{cls_name}]" lines.append(f"{hd2} {link}\n") for field_name, field_changes in cls_changes.items(): + if field_name is None: + continue change_type = field_changes["type"] if change_type == "name_removed": lines.append(f"- **`{field_name}`** - name removed") From 207fd0268946704215d99b9a004efb82212d0e91 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 13 Jul 2023 11:55:07 -0400 Subject: [PATCH 3/6] ci: add benchmarks --- tests/test_codspeed.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_codspeed.py b/tests/test_codspeed.py index c6c46527..319d5941 100644 --- a/tests/test_codspeed.py +++ b/tests/test_codspeed.py @@ -2,14 +2,19 @@ import sys from pathlib import Path +from typing import TYPE_CHECKING import pytest -from ome_types import from_tiff, from_xml, to_xml +from ome_types import OME, from_tiff, from_xml, to_dict, to_xml if all(x not in {"--codspeed", "tests/test_codspeed.py"} for x in sys.argv): pytest.skip("use --codspeed to run benchmarks", allow_module_level=True) +if TYPE_CHECKING: + from pytest_codspeed.plugin import BenchmarkFixture + + DATA = Path(__file__).parent / "data" TIFF = DATA / "ome.tiff" # 1KB SMALL = DATA / "multi-channel.ome.xml" # 1KB @@ -25,7 +30,7 @@ def test_time_from_xml(file: Path) -> None: @pytest.mark.parametrize("file", XML, ids=["small", "med", "large"]) -def test_time_to_xml(file: Path, benchmark) -> None: +def test_time_to_xml(file: Path, benchmark: BenchmarkFixture) -> None: ome = from_xml(file) benchmark(lambda: to_xml(ome)) @@ -33,3 +38,14 @@ def test_time_to_xml(file: Path, benchmark) -> None: @pytest.mark.benchmark def test_time_from_tiff() -> None: _ = from_tiff(TIFF) + + +@pytest.mark.parametrize("file", [SMALL, MED], ids=["small", "med"]) +def test_time_from_xml_to_dict(file: Path) -> None: + _ = to_dict(file) + + +@pytest.mark.parametrize("file", [SMALL, MED], ids=["small", "med"]) +def test_time_from_dict_to_ome(file: Path, benchmark: BenchmarkFixture) -> None: + d = to_dict(file) + benchmark(lambda: OME(**d)) From b963599f4a4541f5799a45a61033e5ddab027e4a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 14 Jul 2023 08:04:41 -0400 Subject: [PATCH 4/6] feat: support pydantic2 (#205) * wip * tests working! * fix typing * fix py37 * use pydantic-extra-types * fix mypy on autogen * fix for pydantic v1 * cross-compatible mode * cross-compat * ci: add test * fix: fix typing --- .github/workflows/test.yml | 35 ++++ .pre-commit-config.yaml | 2 +- pyproject.toml | 3 +- src/ome_autogen/_config.py | 18 +- src/ome_autogen/_generator.py | 13 +- src/ome_types/_conversion.py | 3 +- src/ome_types/_mixins/_base_type.py | 60 +++--- src/ome_types/_mixins/_ids.py | 6 +- src/ome_types/_mixins/_kinded.py | 15 ++ src/ome_types/_mixins/_ome.py | 5 +- src/ome_types/_mixins/_reference.py | 9 +- src/ome_types/_mixins/_validators.py | 32 ++-- src/ome_types/_pydantic_compat.py | 104 ++++++++++ src/ome_types/model/_color.py | 4 +- src/ome_types/model/_shape_union.py | 177 ++++++++++++------ .../model/_structured_annotations.py | 170 ++++++++++++----- src/ome_types/model/_user_sequence.py | 26 ++- src/ome_types/units.py | 7 +- src/xsdata_pydantic_basemodel/bindings.py | 7 +- src/xsdata_pydantic_basemodel/compat.py | 124 ++++++------ src/xsdata_pydantic_basemodel/config.py | 20 ++ src/xsdata_pydantic_basemodel/generator.py | 83 ++++++-- .../pydantic_compat.py | 133 +++++++++++++ tests/test_model.py | 4 +- tests/test_names.py | 5 +- tests/test_serialization.py | 3 +- 26 files changed, 796 insertions(+), 272 deletions(-) create mode 100644 src/ome_types/_pydantic_compat.py create mode 100644 src/xsdata_pydantic_basemodel/config.py create mode 100644 src/xsdata_pydantic_basemodel/pydantic_compat.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 542ca162..f49c75b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,41 @@ jobs: - uses: codecov/codecov-action@v3 if: matrix.python-version != '3.7' + test-pydantic: + name: Pydantic compat + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11"] + pydantic: ["v1", "v2", "both"] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: | + python -m pip install -U pip + python -m pip install .[test,dev] + env: + PYDANTIC_SUPPORT: ${{ matrix.pydantic }} + + - name: Test pydantic1 + if: matrix.pydantic == 'v1' || matrix.pydantic == 'both' + run: | + python -m pip install 'pydantic<2' + pytest --cov --cov-report=xml --cov-append + + - name: Test pydantic2 + if: matrix.pydantic == 'v2' || matrix.pydantic == 'both' + run: | + python -m pip install 'pydantic>=2' + pytest --cov --cov-report=xml --cov-append + + - uses: codecov/codecov-action@v3 + test-types: name: Typesafety runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06e756fd..81b87439 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: mypy exclude: ^tests|^docs|_napari_plugin|widgets additional_dependencies: - - pydantic<2 + - pydantic>=2 - xsdata - Pint - types-lxml; python_version > '3.8' diff --git a/pyproject.toml b/pyproject.toml index 8adee2a5..7b963c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "pydantic <2.0", + "pydantic", + "pydantic-extra-types", "xsdata >=23.5", # 23.6 necessary for codegen, but not for runtime "importlib_metadata; python_version < '3.8'", ] diff --git a/src/ome_autogen/_config.py b/src/ome_autogen/_config.py index 2c28561d..81a5ca1e 100644 --- a/src/ome_autogen/_config.py +++ b/src/ome_autogen/_config.py @@ -1,27 +1,27 @@ from __future__ import annotations +import os + from xsdata.codegen.writer import CodeWriter from xsdata.models import config as cfg from xsdata.utils import text from ome_autogen._generator import OmeGenerator from ome_autogen._util import camel_to_snake +from xsdata_pydantic_basemodel.config import GeneratorOutput -KindedTypes = "(Shape|ManufacturerSpec|Annotation)" - - +PYDANTIC_SUPPORT = os.getenv("PYDANTIC_SUPPORT", "both") +ALLOW_RESERVED_NAMES = {"type", "Type", "Union"} +OME_FORMAT = "OME" 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), - (KindedTypes, f"{MIXIN_MODULE}._kinded.KindMixin", True), + ("(Shape|ManufacturerSpec|Annotation)", f"{MIXIN_MODULE}._kinded.KindMixin", True), ] -ALLOW_RESERVED_NAMES = {"type", "Type", "Union"} -OME_FORMAT = "OME" - def get_config( package: str, kw_only: bool = True, compound_fields: bool = False @@ -51,7 +51,7 @@ def get_config( keep_case = cfg.NameConvention(cfg.NameCase.ORIGINAL, "type") return cfg.GeneratorConfig( - output=cfg.GeneratorOutput( + output=GeneratorOutput( package=package, # format.value lets us use our own generator # kw_only is important, it makes required fields actually be required @@ -59,6 +59,8 @@ def get_config( structure_style=cfg.StructureStyle.CLUSTERS, docstring_style=cfg.DocstringStyle.NUMPY, compound_fields=cfg.CompoundFields(enabled=compound_fields), + # whether to create models that work for both pydantic 1 and 2 + pydantic_support=PYDANTIC_SUPPORT, # type: ignore ), # Add our mixins extensions=cfg.GeneratorExtensions(mixins), diff --git a/src/ome_autogen/_generator.py b/src/ome_autogen/_generator.py index dd79ec79..549069a3 100644 --- a/src/ome_autogen/_generator.py +++ b/src/ome_autogen/_generator.py @@ -29,19 +29,19 @@ ADDED_METHODS: list[tuple[Callable[[Class], bool], str]] = [ ( lambda c: c.name == "BinData", - "\n\n_v = root_validator(pre=True)(bin_data_root_validator)", + "\n\n_vbindata = model_validator(mode='before')(bin_data_root_validator)", ), ( lambda c: c.name == "Value", - "\n\n_v = validator('any_elements', each_item=True)(any_elements_validator)", + "\n\n_vany = field_validator('any_elements')(any_elements_validator)", ), ( lambda c: c.name == "Pixels", - "\n\n_v = root_validator(pre=True)(pixels_root_validator)", + "\n\n_vpix = model_validator(mode='before')(pixels_root_validator)", ), ( lambda c: c.name == "XMLAnnotation", - "\n\n_v = validator('value', pre=True)(xml_value_validator)", + "\n\n_vval = field_validator('value', mode='before')(xml_value_validator)", ), ( lambda c: c.name == "PixelType", @@ -86,6 +86,10 @@ class Override(NamedTuple): { "ome_types._mixins._util": {"new_uuid": ["default_factory=new_uuid"]}, "datetime": {"datetime": ["datetime"]}, + "ome_types._pydantic_compat": { + "model_validator": ["model_validator("], + "field_validator": ["field_validator("], + }, "ome_types._mixins._validators": { "any_elements_validator": ["any_elements_validator"], "bin_data_root_validator": ["bin_data_root_validator"], @@ -234,7 +238,6 @@ def build_import_patterns(cls) -> dict[str, dict]: patterns.setdefault("pydantic", {}).update( { "validator": ["validator("], - "root_validator": ["root_validator("], } ) patterns.update(IMPORT_PATTERNS) diff --git a/src/ome_types/_conversion.py b/src/ome_types/_conversion.py index 82144da1..ac315f07 100644 --- a/src/ome_types/_conversion.py +++ b/src/ome_types/_conversion.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from xsdata.formats.dataclass.parsers.config import ParserConfig +from ome_types._pydantic_compat import model_dump from xsdata_pydantic_basemodel.bindings import ( SerializerConfig, XmlParser, @@ -209,7 +210,7 @@ def to_dict(source: OME | XMLSource) -> dict[str, Any]: A dictionary representation of the OME object or XML document. """ if isinstance(source, BaseModel): - return source.dict(exclude_defaults=True) + return model_dump(source, exclude_defaults=True) return from_xml( # type: ignore[return-value] source, diff --git a/src/ome_types/_mixins/_base_type.py b/src/ome_types/_mixins/_base_type.py index e062be1a..36a4757e 100644 --- a/src/ome_types/_mixins/_base_type.py +++ b/src/ome_types/_mixins/_base_type.py @@ -7,7 +7,6 @@ Any, ClassVar, Dict, - MutableSequence, Optional, Sequence, Set, @@ -17,9 +16,17 @@ cast, ) -from pydantic import BaseModel, validator +from pydantic import BaseModel from ome_types._mixins._ids import validate_id +from ome_types._pydantic_compat import ( + PYDANTIC2, + field_type, + field_validator, + model_dump, + model_fields, + update_set_fields, +) try: from ome_types.units import add_quantity_properties @@ -27,6 +34,8 @@ add_quantity_properties = lambda cls: None # noqa: E731 if TYPE_CHECKING: + from pydantic import ConfigDict + from ome_types._conversion import XMLSource @@ -68,6 +77,13 @@ def _move_deprecated_fields(data: Dict[str, Any], field_names: Set[str]) -> None data[DEPRECATED_NAMES[key]] = data.pop(key) +CONFIG: "ConfigDict" = { + "arbitrary_types_allowed": True, + "validate_assignment": True, + "validate_default": True, +} + + class OMEType(BaseModel): """The base class that all OME Types inherit from. @@ -81,21 +97,22 @@ class OMEType(BaseModel): # pydantic BaseModel configuration. # see: https://pydantic-docs.helpmanual.io/usage/model_config/ - class Config: - arbitrary_types_allowed = False - validate_assignment = True - underscore_attrs_are_private = True - use_enum_values = False - validate_all = True + + if PYDANTIC2: + model_config = CONFIG + else: + Config = type("Config", (), CONFIG) # type: ignore # allow use with weakref __slots__: ClassVar[Set[str]] = {"__weakref__"} # type: ignore - _v = validator("id", pre=True, always=True, check_fields=False)(validate_id) + _vid = field_validator("id", mode="before", 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()) + field_names = set(model_fields(self)) _move_deprecated_fields(data, field_names) super().__init__(**data) kwargs = set(data.keys()) @@ -116,14 +133,17 @@ def __init_subclass__(cls) -> None: def __repr_args__(self) -> Sequence[Tuple[Optional[str], Any]]: """Repr with only set values, and truncated sequences.""" args = [] - for k, v in self._iter(exclude_defaults=True): + for k, v in model_dump(self, exclude_defaults=True).items(): + if k == "kind": + continue if isinstance(v, Sequence) and not isinstance(v, str): if v == []: # skip empty lists continue # if this is a sequence with a long repr, just show the length # and type if len(repr(v).split(",")) > 5: - type_name = self.__fields__[k].type_.__name__ + ftype = field_type(model_fields(self)[k]) + type_name = getattr(field_type, "__name__", str(ftype)) v = _RawRepr(f"[<{len(v)} {type_name}>]") elif isinstance(v, Enum): v = v.value @@ -153,7 +173,8 @@ def __getattr__(self, key: str) -> Any: stacklevel=2, ) return getattr(self, new_key) - raise AttributeError(f"{cls_name} object has no attribute {key!r}") + + return super().__getattr__(key) # type: ignore def to_xml(self, **kwargs: Any) -> str: """Serialize this object to XML. @@ -187,18 +208,7 @@ def _update_set_fields(self) -> None: self.__fields_set__ attribute to reflect that. We assume that if an attribute is not None, and is not equal to the default value, then it has been set. """ - for field_name, field in self.__fields__.items(): - current = getattr(self, field_name) - if not current: - continue - if current != field.get_default(): - self.__fields_set__.add(field_name) - if isinstance(current, OMEType): - current._update_set_fields() - if isinstance(current, MutableSequence): - for item in current: - if isinstance(item, OMEType): - item._update_set_fields() + update_set_fields(self) class _RawRepr: diff --git a/src/ome_types/_mixins/_ids.py b/src/ome_types/_mixins/_ids.py index b68b46a6..02041415 100644 --- a/src/ome_types/_mixins/_ids.py +++ b/src/ome_types/_mixins/_ids.py @@ -5,6 +5,8 @@ from contextlib import suppress from typing import TYPE_CHECKING, Any, cast +from ome_types._pydantic_compat import field_regex + if TYPE_CHECKING: from pydantic import BaseModel from typing_extensions import Final @@ -23,10 +25,8 @@ def _get_id_name_and_pattern(cls: type[BaseModel]) -> tuple[str, str]: # let this raise if it doesn't exist... # this should only be used on classes that have an id field - id_field = cls.__fields__["id"] - id_pattern = cast(str, id_field.field_info.regex) + id_pattern = cast(str, field_regex(cls, "id")) id_name = id_pattern.split(":")[-3] - return id_name, id_pattern diff --git a/src/ome_types/_mixins/_kinded.py b/src/ome_types/_mixins/_kinded.py index 8787bd0e..9ec89b1a 100644 --- a/src/ome_types/_mixins/_kinded.py +++ b/src/ome_types/_mixins/_kinded.py @@ -2,6 +2,13 @@ from pydantic import BaseModel +from ome_types._pydantic_compat import PYDANTIC2 + +try: + from pydantic import model_serializer +except ImportError: + model_serializer = None # type: ignore + class KindMixin(BaseModel): """This mixin adds a `kind` field to the dict output. @@ -18,3 +25,11 @@ def dict(self, **kwargs: Any) -> Dict[str, Any]: d = super().dict(**kwargs) d["kind"] = self.__class__.__name__.lower() return d + + if PYDANTIC2: + + @model_serializer(mode="wrap") + def serialize_root(self, handler, _info) -> Dict: # type: ignore + d = handler(self) + 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 3151c1b0..c4c265c9 100644 --- a/src/ome_types/_mixins/_ome.py +++ b/src/ome_types/_mixins/_ome.py @@ -6,6 +6,7 @@ from ome_types._mixins._base_type import OMEType from ome_types._mixins._ids import CONVERTED_IDS +from ome_types._pydantic_compat import model_fields if TYPE_CHECKING: from pathlib import Path @@ -59,7 +60,7 @@ def collect_ids(value: Any) -> dict[str, OMEType]: for v in value: ids.update(collect_ids(v)) elif isinstance(value, OMEType): - for fname in value.__fields__: + for fname in model_fields(value): if fname == "id" and not isinstance(value, Reference): # We don't need to recurse on the id string, so just record it # and move on. @@ -87,7 +88,7 @@ def collect_references(value: Any) -> list[Reference]: for v in value: references.extend(collect_references(v)) elif isinstance(value, OMEType): - for f in value.__fields__: + for f in model_fields(value): references.extend(collect_references(getattr(value, f))) # Do nothing for uninteresting types return references diff --git a/src/ome_types/_mixins/_reference.py b/src/ome_types/_mixins/_reference.py index 97ee19eb..5998ebeb 100644 --- a/src/ome_types/_mixins/_reference.py +++ b/src/ome_types/_mixins/_reference.py @@ -1,11 +1,13 @@ import weakref from typing import Any, Dict, Optional, Union +from pydantic import PrivateAttr + from ome_types._mixins._base_type import OMEType class ReferenceMixin(OMEType): - _ref: Optional[weakref.ReferenceType] = None + _ref: Optional[weakref.ReferenceType] = PrivateAttr(None) @property def ref(self) -> Union[OMEType, None]: @@ -16,5 +18,8 @@ def ref(self) -> Union[OMEType, None]: def __getstate__(self: Any) -> Dict[str, Any]: """Support pickle of our weakref references.""" state = super().__getstate__() - state["__private_attribute_values__"].pop("_ref", None) + if "__private_attribute_values__" in state: + state["__private_attribute_values__"].pop("_ref", None) + elif "__pydantic_private__" in state: + state["__pydantic_private__"].pop("_ref", None) return state diff --git a/src/ome_types/_mixins/_validators.py b/src/ome_types/_mixins/_validators.py index e74799ab..ae2be08d 100644 --- a/src/ome_types/_mixins/_validators.py +++ b/src/ome_types/_mixins/_validators.py @@ -3,7 +3,7 @@ that logic is in the `methods` method in ome_autogen/_generator.py """ import warnings -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List, Sequence if TYPE_CHECKING: from ome_types.model import ( # type: ignore @@ -31,31 +31,33 @@ def bin_data_root_validator(cls: "BinData", values: dict) -> Dict[str, Any]: # @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") +def pixels_root_validator(cls: "Pixels", value: dict) -> dict: + if "metadata_only" in value: + if isinstance(value["metadata_only"], bool): + if not value["metadata_only"]: + value.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() + value["metadata_only"] = MetadataOnly() - return values + return value -# @validator("any_elements", each_item=True) -def any_elements_validator(cls: "XMLAnnotation.Value", v: Any) -> "AnyElement": +# @validator("any_elements") +def any_elements_validator( + cls: "XMLAnnotation.Value", v: List[Any] +) -> List["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 + if not isinstance(v, Sequence): + raise ValueError(f"any_elements must be a sequence, not {type(v)}") + # this needs to be delayed until runtime because of circular imports + from xsdata_pydantic_basemodel.compat import AnyElement - return AnyElement(**v) - return v + return [AnyElement(**v) if isinstance(v, dict) else v for v in v] # @validator('value', pre=True) diff --git a/src/ome_types/_pydantic_compat.py b/src/ome_types/_pydantic_compat.py new file mode 100644 index 00000000..2af88b29 --- /dev/null +++ b/src/ome_types/_pydantic_compat.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, MutableSequence, cast + +import pydantic.version +from pydantic import BaseModel + +if TYPE_CHECKING: + from pydantic.fields import FieldInfo + +PYDANTIC2 = pydantic.version.VERSION.startswith("2") + +__all__ = ["model_validator", "field_validator"] + +if PYDANTIC2: + from pydantic import functional_validators, model_validator + + try: + from pydantic_extra_types.color import Color as Color + except ImportError: + from pydantic.color import Color as Color + + def model_fields(obj: BaseModel | type[BaseModel]) -> dict[str, FieldInfo]: + return obj.model_fields + + def field_regex(obj: type[BaseModel], field_name: str) -> str | None: + field_info = obj.model_fields[field_name] + if field_info.json_schema_extra: + return field_info.json_schema_extra.get("pattern") + return None + + def fields_set(obj: BaseModel) -> set[str]: + return obj.model_fields_set + + def field_validator(*args: Any, **kwargs: Any) -> Callable[[Callable], Callable]: + kwargs.pop("always", None) + return functional_validators.field_validator(*args, **kwargs) + + def field_type(field: FieldInfo) -> Any: + return field.annotation + + def get_default(f: FieldInfo) -> Any: + return f.get_default(call_default_factory=True) + + def model_dump(obj: BaseModel, **kwargs: Any) -> dict[str, Any]: + return obj.model_dump(**kwargs) + +else: + from pydantic import root_validator, validator # type: ignore + from pydantic.color import Color as Color # type: ignore [no-redef] + + def model_fields( # type: ignore + obj: BaseModel | type[BaseModel], + ) -> dict[str, Any]: + return obj.__fields__ # type: ignore + + def field_type(field: Any) -> Any: # type: ignore + return field.type_ + + def field_regex(obj: type[BaseModel], field_name: str) -> str | None: + field = obj.__fields__[field_name] # type: ignore + return cast(str, field.field_info.regex) + + def fields_set(obj: BaseModel) -> set[str]: + return obj.__fields_set__ + + def model_validator(**kwargs: Any) -> Callable[[Callable], Callable]: # type: ignore # noqa + if kwargs.pop("mode", None) == "before": + kwargs["pre"] = True + return root_validator(**kwargs) + + def field_validator(*fields: str, **kwargs: Any) -> Callable[[Callable], Callable]: # type: ignore # noqa + if kwargs.pop("mode", None) == "before": + kwargs["pre"] = True + return validator(*fields, **kwargs) + return validator(*fields, **kwargs) + + def get_default(f: Any) -> Any: # type: ignore + return f.get_default() + + def model_dump(obj: BaseModel, **kwargs: Any) -> dict[str, Any]: + return obj.dict(**kwargs) + + +def update_set_fields(self: BaseModel) -> None: + """Update set fields with populated mutable sequences. + + Because pydantic isn't aware of mutations to sequences, it can't tell when + a field has been "set" by mutating a sequence. This method updates the + self.__fields_set__ attribute to reflect that. We assume that if an attribute + is not None, and is not equal to the default value, then it has been set. + """ + for field_name, field in model_fields(self).items(): + current = getattr(self, field_name) + if not current: + continue + if current != get_default(field): + fields_set(self).add(field_name) + if isinstance(current, BaseModel): + update_set_fields(current) + if isinstance(current, MutableSequence): + for item in current: + if isinstance(item, BaseModel): + update_set_fields(item) diff --git a/src/ome_types/model/_color.py b/src/ome_types/model/_color.py index 4e1e15c8..b7650e4d 100644 --- a/src/ome_types/model/_color.py +++ b/src/ome_types/model/_color.py @@ -1,7 +1,7 @@ from contextlib import suppress from typing import Tuple, Union -from pydantic import color +from ome_types import _pydantic_compat __all__ = ["Color"] @@ -9,7 +9,7 @@ ColorType = Union[Tuple[int, int, int], RGBA, str, int] -class Color(color.Color): +class Color(_pydantic_compat.Color): """A Pydantic Color subclass that converts to and from OME int32 types.""" def __init__(self, val: ColorType = -1) -> None: diff --git a/src/ome_types/model/_shape_union.py b/src/ome_types/model/_shape_union.py index 1f4238ba..09a57c4a 100644 --- a/src/ome_types/model/_shape_union.py +++ b/src/ome_types/model/_shape_union.py @@ -1,6 +1,7 @@ from contextlib import suppress -from typing import Dict, Iterator, List, Type, Union +from typing import Dict, Iterator, List, Sequence, Type, Union +import pydantic.version from pydantic import Field, ValidationError, validator from ome_types._autogenerated.ome_2016_06.ellipse import Ellipse @@ -28,57 +29,125 @@ _ShapeCls = tuple(_KINDS.values()) +PYDANTIC2 = pydantic.version.VERSION.startswith("2") -class ShapeUnion(OMEType, 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 - __root__: List[object] = Field( - default_factory=list, - metadata={ - "type": "Elements", - "choices": tuple( - {"name": kind.title(), "type": cls} 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__ +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 + root: List[object] = Field( + default_factory=list, + json_schema_extra={ + "type": "Elements", + "choices": tuple( + {"name": kind.title(), "type": cls} 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 + __root__: List[object] = Field( + default_factory=list, + metadata={ + "type": "Elements", + "choices": tuple( + {"name": kind.title(), "type": cls} 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 index b13929ec..32a824bf 100644 --- a/src/ome_types/model/_structured_annotations.py +++ b/src/ome_types/model/_structured_annotations.py @@ -1,6 +1,7 @@ from contextlib import suppress -from typing import Iterator, List +from typing import Iterator, List, Sequence +import pydantic.version from pydantic import Field, ValidationError, validator from ome_types._autogenerated.ome_2016_06.annotation import Annotation @@ -36,54 +37,119 @@ _KINDS = {cls.__name__.lower(): cls for cls in AnnotationTypes} -class StructuredAnnotationList(OMEType, 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 - __root__: List[object] = Field( - default_factory=list, - metadata={ - "type": "Elements", - "choices": tuple( - {"name": cls.__name__, "type": cls} 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__ +if pydantic.version.VERSION.startswith("2"): + from pydantic import RootModel, field_validator + + class StructuredAnnotationList(OMEType, RootModel, UserSequence[Annotation]): # type: ignore[misc] # noqa: E501 + """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 + root: List[object] = Field( + default_factory=list, + json_schema_extra={ + "type": "Elements", + "choices": tuple( + {"name": cls.__name__, "type": cls} 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 # noqa: E501 + """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 + __root__: List[object] = Field( + default_factory=list, + metadata={ + "type": "Elements", + "choices": tuple( + {"name": cls.__name__, "type": cls} 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 index 71153077..1b73ba31 100644 --- a/src/ome_types/model/_user_sequence.py +++ b/src/ome_types/model/_user_sequence.py @@ -1,18 +1,28 @@ 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__.""" - __root__: List[object] + if pydantic.version.VERSION.startswith("2"): + root: List[object] + else: + __root__: List[object] def __repr__(self) -> str: - return repr(self.__root__) + return repr(getattr(self, ROOT_NAME)) def __delitem__(self, _idx: Union[int, slice]) -> None: - del self.__root__[_idx] + del getattr(self, ROOT_NAME)[_idx] @overload def __getitem__(self, _idx: int) -> T: @@ -23,10 +33,10 @@ def __getitem__(self, _idx: slice) -> List[T]: ... def __getitem__(self, _idx: Union[int, slice]) -> Union[T, List[T]]: - return self.__root__[_idx] # type: ignore[return-value] + return getattr(self, ROOT_NAME)[_idx] # type: ignore[return-value] def __len__(self) -> int: - return len(self.__root__) + return len(getattr(self, ROOT_NAME)) @overload def __setitem__(self, _idx: int, _val: T) -> None: @@ -37,12 +47,12 @@ def __setitem__(self, _idx: slice, _val: Iterable[T]) -> None: ... def __setitem__(self, _idx: Union[int, slice], _val: Union[T, Iterable[T]]) -> None: - self.__root__[_idx] = _val # type: ignore[index] + getattr(self, ROOT_NAME)[_idx] = _val # type: ignore[index] def insert(self, index: int, value: T) -> None: - self.__root__.insert(index, value) + 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: - self.__root__.append(value) + getattr(self, ROOT_NAME).append(value) diff --git a/src/ome_types/units.py b/src/ome_types/units.py index beffd4ec..b0a53d7b 100644 --- a/src/ome_types/units.py +++ b/src/ome_types/units.py @@ -48,6 +48,9 @@ def add_quantity_properties(cls: type[BaseModel]) -> None: X_quantity property, where X is the name of the field. It returns a pint object. """ _QUANTITY_FIELD = "{}_quantity" - for field in cls.__fields__: - if _UNIT_FIELD.format(field) in cls.__fields__: + # for some odd reason, cls.model_fields isn't always ready to go yet at this point + # only in pydantic2... so use __annotations__ instead + field_names = set(cls.__annotations__) + for field in field_names: + if _UNIT_FIELD.format(field) in field_names: setattr(cls, _QUANTITY_FIELD.format(field), _quantity_property(field)) diff --git a/src/xsdata_pydantic_basemodel/bindings.py b/src/xsdata_pydantic_basemodel/bindings.py index 9a27798e..af2c0b81 100644 --- a/src/xsdata_pydantic_basemodel/bindings.py +++ b/src/xsdata_pydantic_basemodel/bindings.py @@ -11,6 +11,8 @@ from xsdata.utils import collections, namespaces from xsdata.utils.constants import EMPTY_MAP, return_input +from xsdata_pydantic_basemodel.pydantic_compat import fields_set + if TYPE_CHECKING: from pydantic import BaseModel from xsdata.formats.dataclass.models.elements import XmlMeta @@ -92,7 +94,7 @@ def write_dataclass( for var, value in self.next_value(obj, meta): # XXX: reason 2 for overriding. - if ignore_unset and var.name not in obj.__fields_set__: + if ignore_unset and var.name not in fields_set(obj): continue yield from self.write_value(value, var, namespace) @@ -124,7 +126,7 @@ def next_attribute( :return: """ - set_fields = obj.__fields_set__ if ignore_unset else set() + set_fields = fields_set(obj) if ignore_unset else set() vars_ = meta.get_attribute_vars() if attribute_sort_key is not None: vars_ = sorted(meta.get_attribute_vars(), key=attribute_sort_key) @@ -141,7 +143,6 @@ def next_attribute( or (ignore_unset and var.name not in set_fields) # new ): continue - yield var.qname, cls.encode(value, var) else: yield from getattr(obj, var.name, EMPTY_MAP).items() diff --git a/src/xsdata_pydantic_basemodel/compat.py b/src/xsdata_pydantic_basemodel/compat.py index bc823625..c743e2a2 100644 --- a/src/xsdata_pydantic_basemodel/compat.py +++ b/src/xsdata_pydantic_basemodel/compat.py @@ -1,31 +1,40 @@ +from typing import Any, Callable, Dict, Generic, List, Optional, Tuple, Type, TypeVar + try: from lxml import etree as ET except ImportError: import xml.etree.ElementTree as ET # type: ignore -from dataclasses import MISSING, field -from typing import ( - Any, - Callable, - Dict, - Generic, - List, - Optional, - Tuple, - Type, - TypeVar, - cast, -) - from pydantic import BaseModel, validators -from pydantic.fields import Field, ModelField, Undefined from xsdata.formats.dataclass.compat import Dataclasses, class_types from xsdata.formats.dataclass.models.elements import XmlType from xsdata.models.datatype import XmlDate, XmlDateTime, XmlDuration, XmlPeriod, XmlTime +from xsdata_pydantic_basemodel.pydantic_compat import ( + PYDANTIC2, + Field, + dataclass_fields, + model_config, + update_forward_refs, +) + T = TypeVar("T", bound=object) +# don't switch to exclude ... it makes it hard to add fields to the +# schema without breaking backwards compatibility +_config = model_config(arbitrary_types_allowed=True) + + +class _BaseModel(BaseModel): + """Base model for all types.""" + + if PYDANTIC2: + model_config = _config # type: ignore + else: + Config = _config # type: ignore + + class AnyElement(BaseModel): """Generic model to bind xml document data to wildcard fields. @@ -46,8 +55,10 @@ class AnyElement(BaseModel): default_factory=dict, metadata={"type": XmlType.ATTRIBUTES} ) - class Config: - arbitrary_types_allowed = True + if PYDANTIC2: + model_config = _config # type: ignore + else: + Config = _config # type: ignore def to_etree_element(self) -> "ET._Element": elem = ET.Element(self.qname or "", self.attributes) @@ -72,8 +83,10 @@ class DerivedElement(BaseModel, Generic[T]): value: T type: Optional[str] = None - class Config: - arbitrary_types_allowed = True + if PYDANTIC2: + model_config = _config # type: ignore + else: + Config = _config # type: ignore class PydanticBaseModel(Dataclasses): @@ -88,40 +101,13 @@ def derived_element(self) -> Type: def is_model(self, obj: Any) -> bool: clazz = obj if isinstance(obj, type) else type(obj) if issubclass(clazz, BaseModel): - clazz.update_forward_refs() + update_forward_refs(clazz) return True return False def get_fields(self, obj: Any) -> Tuple[Any, ...]: - _fields = cast("BaseModel", obj).__fields__.values() - return tuple(_pydantic_field_to_dataclass_field(field) for field in _fields) - - -def _pydantic_field_to_dataclass_field(pydantic_field: ModelField) -> Any: - if pydantic_field.default_factory is not None: - default_factory: Any = pydantic_field.default_factory - default = MISSING - else: - default_factory = MISSING - default = ( - MISSING - if pydantic_field.default in (Undefined, Ellipsis) - else pydantic_field.default - ) - - dataclass_field = field( # type: ignore - default=default, - default_factory=default_factory, - # init=True, - # hash=None, - # compare=True, - metadata=pydantic_field.field_info.extra.get("metadata", {}), - # kw_only=MISSING, - ) - dataclass_field.name = pydantic_field.name - dataclass_field.type = pydantic_field.type_ - return dataclass_field + return tuple(dataclass_fields(obj)) class_types.register("pydantic-basemodel", PydanticBaseModel()) @@ -140,22 +126,30 @@ def validator(value: Any) -> Any: return [validator] -if hasattr(validators, "_VALIDATORS"): - validators._VALIDATORS.extend( - [ - (XmlDate, make_validators(XmlDate, XmlDate.from_string)), - (XmlDateTime, make_validators(XmlDateTime, XmlDateTime.from_string)), - (XmlTime, make_validators(XmlTime, XmlTime.from_string)), - (XmlDuration, make_validators(XmlDuration, XmlDuration)), - (XmlPeriod, make_validators(XmlPeriod, XmlPeriod)), - (ET.QName, make_validators(ET.QName, ET.QName)), - ] - ) +_validators = { + XmlDate: make_validators(XmlDate, XmlDate.from_string), + XmlDateTime: make_validators(XmlDateTime, XmlDateTime.from_string), + XmlTime: make_validators(XmlTime, XmlTime.from_string), + XmlDuration: make_validators(XmlDuration, XmlDuration), + XmlPeriod: make_validators(XmlPeriod, XmlPeriod), + ET.QName: make_validators(ET.QName, ET.QName), +} + +if not PYDANTIC2: + validators._VALIDATORS.extend(list(_validators.items())) else: - import warnings + from pydantic import BaseModel + from pydantic_core import core_schema as cs - warnings.warn( - "Could not find pydantic.validators._VALIDATORS." - "xsdata-pydantic-basemodel may be incompatible with your pydantic version.", - stacklevel=2, - ) + def _make_get_core_schema(validator: Callable) -> Callable: + def get_core_schema(*args: Any) -> cs.PlainValidatorFunctionSchema: + return cs.general_plain_validator_function(validator) + + return get_core_schema + + for type_, val in _validators.items(): + get_schema = _make_get_core_schema(val[0]) + try: + type_.__get_pydantic_core_schema__ = get_schema # type: ignore + except TypeError as e: + print(e) diff --git a/src/xsdata_pydantic_basemodel/config.py b/src/xsdata_pydantic_basemodel/config.py new file mode 100644 index 00000000..1d940fda --- /dev/null +++ b/src/xsdata_pydantic_basemodel/config.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Literal + +from xsdata.models import config + + +@dataclass +class GeneratorOutput(config.GeneratorOutput): + # v1 will only support pydantic<2 + # v2 will only support pydantic>=2 + # auto will only support whatever pydantic is installed at codegen time + # both will support both pydantic versions + pydantic_support: Literal["v1", "v2", "auto", "both"] = "auto" + + def __post_init__(self) -> None: + if self.pydantic_support not in ("v1", "v2", "auto", "both"): + raise ValueError( + "pydantic_support must be one of 'v1', 'v2', 'auto', 'both', not " + f"{self.pydantic_support!r}" + ) diff --git a/src/xsdata_pydantic_basemodel/generator.py b/src/xsdata_pydantic_basemodel/generator.py index 8f83c530..77deafd1 100644 --- a/src/xsdata_pydantic_basemodel/generator.py +++ b/src/xsdata_pydantic_basemodel/generator.py @@ -7,6 +7,8 @@ from xsdata.utils.collections import unique_sequence from xsdata.utils.text import stop_words +from xsdata_pydantic_basemodel.pydantic_compat import PYDANTIC2 + if TYPE_CHECKING: from xsdata.codegen.models import Attr, Class from xsdata.models.config import GeneratorConfig, OutputFormat @@ -23,16 +25,20 @@ def init_filters(cls, config: GeneratorConfig) -> Filters: class PydanticBaseFilters(Filters): + def __init__(self, config: GeneratorConfig): + super().__init__(config) + self.pydantic_support = getattr(config.output, "pydantic_support", False) + if self.pydantic_support == "both": + self.import_patterns["pydantic"].pop("Field") + self.import_patterns["xsdata_pydantic_basemodel.pydantic_compat"] = { + "Field": {" = Field("} + } + @classmethod def build_import_patterns(cls) -> dict[str, dict]: patterns = Filters.build_import_patterns() patterns.update( - { - "pydantic": { - "Field": [" = Field("], - "BaseModel": ["BaseModel"], - } - } + {"pydantic": {"Field": [" = Field("], "BaseModel": ["BaseModel"]}} ) return {key: patterns[key] for key in sorted(patterns)} @@ -49,7 +55,7 @@ def field_definition( def format_arguments(self, kwargs: dict, indent: int = 0) -> str: # called by field_definition - self.move_metadata_to_pydantic_field(kwargs) + self.move_restrictions_to_pydantic_field(kwargs) return super().format_arguments(kwargs, indent) def class_bases(self, obj: Class, class_name: str) -> list[str]: @@ -58,24 +64,63 @@ def class_bases(self, obj: Class, class_name: str) -> list[str]: bases = super().class_bases(obj, class_name) return unique_sequence([*bases, "BaseModel"]) - def move_metadata_to_pydantic_field(self, kwargs: dict, pop: bool = False) -> None: + def move_restrictions_to_pydantic_field( + self, kwargs: dict, pop: bool = False + ) -> None: """Move metadata from the metadata dict to the pydantic Field kwargs.""" # XXX: can we pop them? or does xsdata need them in the metadata dict as well? if "metadata" not in kwargs: # pragma: no cover return + # The choice to use v1 syntax for cross-compatible mode has to do with + # https://docs.pydantic.dev/usage/schema/#unenforced-field-constraints + # There were more fields in v1 than in v2, so "min_length" is degenerate in v2 + if self.pydantic_support == "v2": + use_v2 = True + elif self.pydantic_support == "auto": + use_v2 = PYDANTIC2 + else: # v1 or both + use_v2 = False + + restriction_map = V2_RESTRICTION_MAP if use_v2 else V1_RESTRICTION_MAP + metadata: dict = kwargs["metadata"] getitem = metadata.pop if pop else metadata.get - for from_, to_ in [ - ("min_inclusive", "ge"), - ("min_exclusive", "gt"), - ("max_inclusive", "le"), - ("max_exclusive", "lt"), - ("min_occurs", "min_items"), - ("max_occurs", "max_items"), - ("pattern", "regex"), - ("min_length", "min_length"), - ("max_length", "max_length"), - ]: + for from_, to_ in restriction_map.items(): if from_ in metadata: kwargs[to_] = getitem(from_) + + if use_v2 and "metadata" in kwargs: + kwargs["json_schema_extra"] = kwargs.pop("metadata") + + +V1_RESTRICTION_MAP = { + "min_occurs": "min_items", # semantics are different + "max_occurs": "max_items", # semantics are different + "min_exclusive": "gt", + "min_inclusive": "ge", + "max_exclusive": "lt", + "max_inclusive": "le", + "min_length": "min_length", # only applies to strings + "max_length": "max_length", # only applies to strings + "pattern": "regex", + "fraction_digits": "decimal_places", + "total_digits": "max_digits", + # --- other restrictions that don't have a direct mapping --- + # "length": "...", + # "white_space": "...", + # "explicit_timezone": "...", + # "nillable": "...", + # "sequence": "...", + # "tokens": "...", + # "format": "...", + # "choice": "...", + # "group": "...", + # "path": "...", +} +V2_RESTRICTION_MAP = { + **V1_RESTRICTION_MAP, + "min_occurs": "min_length", # semantics are different + "max_occurs": "max_length", # semantics are different + "pattern": "pattern", +} diff --git a/src/xsdata_pydantic_basemodel/pydantic_compat.py b/src/xsdata_pydantic_basemodel/pydantic_compat.py new file mode 100644 index 00000000..1f0d26ff --- /dev/null +++ b/src/xsdata_pydantic_basemodel/pydantic_compat.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import MISSING, field +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +from pydantic import BaseModel, __version__ + +if TYPE_CHECKING: + from typing import Iterator, Literal + + from pydantic import Field + + +__all__ = ["Field"] +PYDANTIC2 = __version__.startswith("2") +M = TypeVar("M", bound=BaseModel) +C = TypeVar("C", bound=Callable[..., Any]) + + +# no-op for v1, put first for typing. +def model_validator(*, mode: Literal["wrap", "before", "after"]) -> Callable[[C], C]: + def decorator(func: C) -> C: + return func + + return decorator + + +if PYDANTIC2: + from pydantic import field_validator + from pydantic import model_validator as model_validator # type: ignore # noqa + from pydantic.fields import Field as _Field + from pydantic.fields import FieldInfo + from pydantic_core import PydanticUndefined as Undefined + + def Field(*args: Any, **kwargs: Any) -> Any: # type: ignore # noqa + if "metadata" in kwargs: + kwargs["json_schema_extra"] = kwargs.pop("metadata") + if "regex" in kwargs: + kwargs["pattern"] = kwargs.pop("regex") + if "min_items" in kwargs: + kwargs["min_length"] = kwargs.pop("min_items") + return _Field(*args, **kwargs) # type: ignore + + def validator(*args: Any, **kwargs: Any) -> Callable[[Callable], Callable]: + return field_validator(*args, **kwargs) + + def update_forward_refs(cls: type[M]) -> None: + try: + cls.model_rebuild() + except AttributeError: + pass + + def iter_fields(obj: type[M]) -> Iterator[tuple[str, FieldInfo]]: + yield from obj.model_fields.items() + + def _get_metadata(pydantic_field: FieldInfo) -> dict[str, Any]: + if pydantic_field.json_schema_extra: + metadata = pydantic_field.json_schema_extra + else: + metadata = {} + return metadata + + def model_config(**kwargs: Any) -> dict | type: + return kwargs + + def fields_set(obj: BaseModel) -> set[str]: + return obj.model_fields_set + +else: + from pydantic.fields import Field as _Field + from pydantic.fields import ModelField # type: ignore + from pydantic.fields import Undefined as Undefined # type: ignore + + def Field(*args: Any, **kwargs: Any) -> Any: # type: ignore + if "metadata" in kwargs: + kwargs["json_schema_extra"] = kwargs.pop("metadata") + return _Field(*args, **kwargs) # type: ignore + + def update_forward_refs(cls: type[M]) -> None: + cls.update_forward_refs() + + def iter_fields(obj: type[M]) -> Iterator[tuple[str, ModelField]]: # type: ignore + yield from obj.__fields__.items() # type: ignore + + def model_config(**kwargs: Any) -> dict | type: + return type("Config", (), kwargs) + + def _get_metadata(pydantic_field) -> dict: # type: ignore + extra = pydantic_field.field_info.extra + if "json_schema_extra" in extra: + return extra["json_schema_extra"] + return extra.get("metadata", {}) + + def fields_set(obj: BaseModel) -> set[str]: + return obj.__fields_set__ + + +def _get_defaults(pydantic_field: FieldInfo) -> tuple[Any, Any]: + if pydantic_field.default_factory is not None: + default_factory: Any = pydantic_field.default_factory + default = MISSING + else: + default_factory = MISSING + default = ( + MISSING + if pydantic_field.default in (Undefined, Ellipsis) + else pydantic_field.default + ) + return default_factory, default + + +def _pydantic_field_to_dataclass_field(name: str, pydantic_field: FieldInfo) -> Any: + default_factory, default = _get_defaults(pydantic_field) + + metadata = _get_metadata(pydantic_field) + + dataclass_field = field( # type: ignore + default=default, + default_factory=default_factory, + # init=True, + # hash=None, + # compare=True, + metadata=metadata, + # kw_only=MISSING, + ) + dataclass_field.name = name + # dataclass_field.type = pydantic_field.type_ + return dataclass_field + + +def dataclass_fields(obj: type[M]) -> Iterator[Any]: + for name, f in iter_fields(obj): + yield _pydantic_field_to_dataclass_field(name, f) diff --git a/tests/test_model.py b/tests/test_model.py index fe014bbc..8eb7f0bb 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -60,10 +60,10 @@ def test_no_id() -> None: def test_required_missing() -> None: """Test subclasses with non-default arguments still work.""" - with pytest.raises(ValidationError, match="value\n field required"): + with pytest.raises(ValidationError, match="required"): model.BooleanAnnotation() # type: ignore - with pytest.raises(ValidationError, match="x\n field required"): + with pytest.raises(ValidationError, match="required"): model.Label() # type: ignore diff --git a/tests/test_names.py b/tests/test_names.py index ede85f2d..31b09071 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -6,10 +6,10 @@ import pytest from pydantic import BaseModel -from pydantic.typing import display_as_type import ome_types from ome_types import model +from ome_types._pydantic_compat import PYDANTIC2 TESTS = Path(__file__).parent KNOWN_CHANGES: dict[str, list[tuple[str, str | None]]] = { @@ -109,6 +109,8 @@ def _assert_names_match( def _get_fields(cls: type[BaseModel]) -> dict[str, Any]: + from pydantic.typing import display_as_type + fields = {} for name, field in cls.__fields__.items(): if name.startswith("_"): @@ -120,6 +122,7 @@ def _get_fields(cls: type[BaseModel]) -> dict[str, Any]: return fields +@pytest.mark.skipif(PYDANTIC2, reason="no need to check pydantic 2") def test_names() -> None: with (TESTS / "data" / "old_model.json").open() as f: old_names = json.load(f) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index b0542516..6d519b44 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -70,7 +70,8 @@ def test_serialization(valid_xml: Path) -> None: def test_dict_roundtrip(valid_xml: Path) -> None: # Test round-trip through to_dict and from_dict ome1 = from_xml(valid_xml) - assert ome1 == OME(**to_dict(ome1)) + ome2 = OME(**to_dict(ome1)) + assert ome1 == ome2 @pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher") From 0d230fe7ee48adc9b46417a03e61591e85865877 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 14 Jul 2023 10:18:27 -0400 Subject: [PATCH 5/6] build: remove pydantic-extra-types dep (#206) --- pyproject.toml | 7 +- src/ome_types/_vendor/__init__.py | 15 + src/ome_types/_vendor/_pydantic_color_v1.py | 507 ++++++++++++++ src/ome_types/_vendor/_pydantic_color_v2.py | 652 ++++++++++++++++++ src/ome_types/model/_color.py | 4 +- .../pydantic_compat.py | 4 +- 6 files changed, 1181 insertions(+), 8 deletions(-) create mode 100644 src/ome_types/_vendor/__init__.py create mode 100644 src/ome_types/_vendor/_pydantic_color_v1.py create mode 100644 src/ome_types/_vendor/_pydantic_color_v2.py diff --git a/pyproject.toml b/pyproject.toml index 7b963c91..5f61cb3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,7 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "pydantic", - "pydantic-extra-types", + "pydantic >=1.9.0", "xsdata >=23.5", # 23.6 necessary for codegen, but not for runtime "importlib_metadata; python_version < '3.8'", ] @@ -126,7 +125,7 @@ ignore = [ "RUF009", # Do not perform function calls in default arguments "S3", # xml security ] -exclude = ['src/_ome_autogen.py'] +exclude = ['src/_ome_autogen.py', 'src/ome_types/_vendor'] [tool.ruff.flake8-tidy-imports] ban-relative-imports = "all" # Disallow all relative imports. @@ -206,7 +205,7 @@ exclude_lines = [ [tool.coverage.run] source = ["ome_types", "ome_autogen"] -omit = ["src/ome_types/_autogenerated/*", "/private/var/folders/*"] +omit = ["src/ome_types/_autogenerated/*", "/private/var/folders/*", "*/_vendor/*"] # Entry points -- REMOVE ONCE XSDATA-PYDANTIC-BASEMODEL IS SEPARATE [project.entry-points."xsdata.plugins.class_types"] diff --git a/src/ome_types/_vendor/__init__.py b/src/ome_types/_vendor/__init__.py new file mode 100644 index 00000000..2a094c2b --- /dev/null +++ b/src/ome_types/_vendor/__init__.py @@ -0,0 +1,15 @@ +"""Vendorized dependencies. + +Color has been moved from pydantic to pydantic-extra-types. However, we can't easily +require pydantic-extra-types because it pins pydantic to >2.0.0. So, in order to +retain compatibility with pydantic 1.x and 2.x without getting the color warning, and +without accidentally pinning pydantic, we vendor the two Color classes here. +""" +import pydantic.version + +if pydantic.version.VERSION.startswith("2"): + from ._pydantic_color_v2 import Color # noqa +else: + from ._pydantic_color_v1 import Color # type: ignore # noqa + +__all__ = ["Color"] diff --git a/src/ome_types/_vendor/_pydantic_color_v1.py b/src/ome_types/_vendor/_pydantic_color_v1.py new file mode 100644 index 00000000..34952e5d --- /dev/null +++ b/src/ome_types/_vendor/_pydantic_color_v1.py @@ -0,0 +1,507 @@ +""" +Color definitions are used as per CSS3 specification: +http://www.w3.org/TR/css3-color/#svg-color + +A few colors have multiple names referring to the sames colors, eg. `grey` and `gray` or `aqua` and `cyan`. + +In these cases the LAST color when sorted alphabetically takes preferences, +eg. Color((0, 255, 255)).as_named() == 'cyan' because "cyan" comes after "aqua". +""" +import math +import re +from colorsys import hls_to_rgb, rgb_to_hls +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union, cast + +from pydantic.errors import ColorError +from pydantic.utils import Representation, almost_equal_floats + +if TYPE_CHECKING: + from pydantic.typing import CallableGenerator, ReprArgs + +ColorTuple = Union[Tuple[int, int, int], Tuple[int, int, int, float]] +ColorType = Union[ColorTuple, str] +HslColorTuple = Union[Tuple[float, float, float], Tuple[float, float, float, float]] + + +class RGBA: + """ + Internal use only as a representation of a color. + """ + + __slots__ = "r", "g", "b", "alpha", "_tuple" + + def __init__(self, r: float, g: float, b: float, alpha: Optional[float]): + self.r = r + self.g = g + self.b = b + self.alpha = alpha + + self._tuple: Tuple[float, float, float, Optional[float]] = (r, g, b, alpha) + + def __getitem__(self, item: Any) -> Any: + return self._tuple[item] + + +# these are not compiled here to avoid import slowdown, they'll be compiled the first time they're used, then cached +r_hex_short = r"\s*(?:#|0x)?([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?\s*" +r_hex_long = r"\s*(?:#|0x)?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?\s*" +_r_255 = r"(\d{1,3}(?:\.\d+)?)" +_r_comma = r"\s*,\s*" +r_rgb = rf"\s*rgb\(\s*{_r_255}{_r_comma}{_r_255}{_r_comma}{_r_255}\)\s*" +_r_alpha = r"(\d(?:\.\d+)?|\.\d+|\d{1,2}%)" +r_rgba = rf"\s*rgba\(\s*{_r_255}{_r_comma}{_r_255}{_r_comma}{_r_255}{_r_comma}{_r_alpha}\s*\)\s*" +_r_h = r"(-?\d+(?:\.\d+)?|-?\.\d+)(deg|rad|turn)?" +_r_sl = r"(\d{1,3}(?:\.\d+)?)%" +r_hsl = rf"\s*hsl\(\s*{_r_h}{_r_comma}{_r_sl}{_r_comma}{_r_sl}\s*\)\s*" +r_hsla = ( + rf"\s*hsl\(\s*{_r_h}{_r_comma}{_r_sl}{_r_comma}{_r_sl}{_r_comma}{_r_alpha}\s*\)\s*" +) + +# colors where the two hex characters are the same, if all colors match this the short version of hex colors can be used +repeat_colors = {int(c * 2, 16) for c in "0123456789abcdef"} +rads = 2 * math.pi + + +class Color(Representation): + __slots__ = "_original", "_rgba" + + def __init__(self, value: ColorType) -> None: + self._rgba: RGBA + self._original: ColorType + if isinstance(value, (tuple, list)): + self._rgba = parse_tuple(value) + elif isinstance(value, str): + self._rgba = parse_str(value) + elif isinstance(value, Color): + self._rgba = value._rgba + value = value._original + else: + raise ColorError(reason="value must be a tuple, list or string") + + # if we've got here value must be a valid color + self._original = value + + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type="string", format="color") + + def original(self) -> ColorType: + """ + Original value passed to Color + """ + return self._original + + def as_named(self, *, fallback: bool = False) -> str: + if self._rgba.alpha is None: + rgb = cast(Tuple[int, int, int], self.as_rgb_tuple()) + try: + return COLORS_BY_VALUE[rgb] + except KeyError as e: + if fallback: + return self.as_hex() + else: + raise ValueError( + "no named color found, use fallback=True, as_hex() or as_rgb()" + ) from e + else: + return self.as_hex() + + def as_hex(self) -> str: + """ + Hex string representing the color can be 3, 4, 6 or 8 characters depending on whether the string + a "short" representation of the color is possible and whether there's an alpha channel. + """ + values = [float_to_255(c) for c in self._rgba[:3]] + if self._rgba.alpha is not None: + values.append(float_to_255(self._rgba.alpha)) + + as_hex = "".join(f"{v:02x}" for v in values) + if all(c in repeat_colors for c in values): + as_hex = "".join(as_hex[c] for c in range(0, len(as_hex), 2)) + return "#" + as_hex + + def as_rgb(self) -> str: + """ + Color as an rgb(, , ) or rgba(, , , ) string. + """ + if self._rgba.alpha is None: + return f"rgb({float_to_255(self._rgba.r)}, {float_to_255(self._rgba.g)}, {float_to_255(self._rgba.b)})" + else: + return ( + f"rgba({float_to_255(self._rgba.r)}, {float_to_255(self._rgba.g)}, {float_to_255(self._rgba.b)}, " + f"{round(self._alpha_float(), 2)})" + ) + + def as_rgb_tuple(self, *, alpha: Optional[bool] = None) -> ColorTuple: + """ + Color as an RGB or RGBA tuple; red, green and blue are in the range 0 to 255, alpha if included is + in the range 0 to 1. + + :param alpha: whether to include the alpha channel, options are + None - (default) include alpha only if it's set (e.g. not None) + True - always include alpha, + False - always omit alpha, + """ + r, g, b = (float_to_255(c) for c in self._rgba[:3]) + if alpha is None: + if self._rgba.alpha is None: + return r, g, b + else: + return r, g, b, self._alpha_float() + elif alpha: + return r, g, b, self._alpha_float() + else: + # alpha is False + return r, g, b + + def as_hsl(self) -> str: + """ + Color as an hsl(, , ) or hsl(, , , ) string. + """ + if self._rgba.alpha is None: + h, s, li = self.as_hsl_tuple(alpha=False) # type: ignore + return f"hsl({h * 360:0.0f}, {s:0.0%}, {li:0.0%})" + else: + h, s, li, a = self.as_hsl_tuple(alpha=True) # type: ignore + return f"hsl({h * 360:0.0f}, {s:0.0%}, {li:0.0%}, {round(a, 2)})" + + def as_hsl_tuple(self, *, alpha: Optional[bool] = None) -> HslColorTuple: + """ + Color as an HSL or HSLA tuple, e.g. hue, saturation, lightness and optionally alpha; all elements are in + the range 0 to 1. + + NOTE: this is HSL as used in HTML and most other places, not HLS as used in python's colorsys. + + :param alpha: whether to include the alpha channel, options are + None - (default) include alpha only if it's set (e.g. not None) + True - always include alpha, + False - always omit alpha, + """ + h, l, s = rgb_to_hls(self._rgba.r, self._rgba.g, self._rgba.b) + if alpha is None: + if self._rgba.alpha is None: + return h, s, l + else: + return h, s, l, self._alpha_float() + if alpha: + return h, s, l, self._alpha_float() + else: + # alpha is False + return h, s, l + + def _alpha_float(self) -> float: + return 1 if self._rgba.alpha is None else self._rgba.alpha + + @classmethod + def __get_validators__(cls) -> "CallableGenerator": + yield cls + + def __str__(self) -> str: + return self.as_named(fallback=True) + + def __repr_args__(self) -> "ReprArgs": + return [(None, self.as_named(fallback=True))] + [("rgb", self.as_rgb_tuple())] # type: ignore + + def __eq__(self, other: Any) -> bool: + return isinstance(other, Color) and self.as_rgb_tuple() == other.as_rgb_tuple() + + def __hash__(self) -> int: + return hash(self.as_rgb_tuple()) + + +def parse_tuple(value: Tuple[Any, ...]) -> RGBA: + """ + Parse a tuple or list as a color. + """ + if len(value) == 3: + r, g, b = (parse_color_value(v) for v in value) + return RGBA(r, g, b, None) + elif len(value) == 4: + r, g, b = (parse_color_value(v) for v in value[:3]) + return RGBA(r, g, b, parse_float_alpha(value[3])) + else: + raise ColorError(reason="tuples must have length 3 or 4") + + +def parse_str(value: str) -> RGBA: + """ + Parse a string to an RGBA tuple, trying the following formats (in this order): + * named color, see COLORS_BY_NAME below + * hex short eg. `fff` (prefix can be `#`, `0x` or nothing) + * hex long eg. `ffffff` (prefix can be `#`, `0x` or nothing) + * `rgb(, , ) ` + * `rgba(, , , )` + """ + value_lower = value.lower() + try: + r, g, b = COLORS_BY_NAME[value_lower] + except KeyError: + pass + else: + return ints_to_rgba(r, g, b, None) + + m = re.fullmatch(r_hex_short, value_lower) + if m: + *rgb, a = m.groups() + r, g, b = (int(v * 2, 16) for v in rgb) + if a: + alpha: Optional[float] = int(a * 2, 16) / 255 + else: + alpha = None + return ints_to_rgba(r, g, b, alpha) + + m = re.fullmatch(r_hex_long, value_lower) + if m: + *rgb, a = m.groups() + r, g, b = (int(v, 16) for v in rgb) + if a: + alpha = int(a, 16) / 255 + else: + alpha = None + return ints_to_rgba(r, g, b, alpha) + + m = re.fullmatch(r_rgb, value_lower) + if m: + return ints_to_rgba(*m.groups(), None) # type: ignore + + m = re.fullmatch(r_rgba, value_lower) + if m: + return ints_to_rgba(*m.groups()) # type: ignore + + m = re.fullmatch(r_hsl, value_lower) + if m: + h, h_units, s, l_ = m.groups() + return parse_hsl(h, h_units, s, l_) + + m = re.fullmatch(r_hsla, value_lower) + if m: + h, h_units, s, l_, a = m.groups() + return parse_hsl(h, h_units, s, l_, parse_float_alpha(a)) + + raise ColorError(reason="string not recognised as a valid color") + + +def ints_to_rgba( + r: Union[int, str], g: Union[int, str], b: Union[int, str], alpha: Optional[float] +) -> RGBA: + return RGBA( + parse_color_value(r), + parse_color_value(g), + parse_color_value(b), + parse_float_alpha(alpha), + ) + + +def parse_color_value(value: Union[int, str], max_val: int = 255) -> float: + """ + Parse a value checking it's a valid int in the range 0 to max_val and divide by max_val to give a number + in the range 0 to 1 + """ + try: + color = float(value) + except ValueError: + raise ColorError(reason="color values must be a valid number") + if 0 <= color <= max_val: + return color / max_val + else: + raise ColorError(reason=f"color values must be in the range 0 to {max_val}") + + +def parse_float_alpha(value: Union[None, str, float, int]) -> Optional[float]: + """ + Parse a value checking it's a valid float in the range 0 to 1 + """ + if value is None: + return None + try: + if isinstance(value, str) and value.endswith("%"): + alpha = float(value[:-1]) / 100 + else: + alpha = float(value) + except ValueError: + raise ColorError(reason="alpha values must be a valid float") + + if almost_equal_floats(alpha, 1): + return None + elif 0 <= alpha <= 1: + return alpha + else: + raise ColorError(reason="alpha values must be in the range 0 to 1") + + +def parse_hsl( + h: str, h_units: str, sat: str, light: str, alpha: Optional[float] = None +) -> RGBA: + """ + Parse raw hue, saturation, lightness and alpha values and convert to RGBA. + """ + s_value, l_value = parse_color_value(sat, 100), parse_color_value(light, 100) + + h_value = float(h) + if h_units in {None, "deg"}: + h_value = h_value % 360 / 360 + elif h_units == "rad": + h_value = h_value % rads / rads + else: + # turns + h_value = h_value % 1 + + r, g, b = hls_to_rgb(h_value, l_value, s_value) + return RGBA(r, g, b, alpha) + + +def float_to_255(c: float) -> int: + return int(round(c * 255)) + + +COLORS_BY_NAME = { + "aliceblue": (240, 248, 255), + "antiquewhite": (250, 235, 215), + "aqua": (0, 255, 255), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "black": (0, 0, 0), + "blanchedalmond": (255, 235, 205), + "blue": (0, 0, 255), + "blueviolet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadetblue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflowerblue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "darkblue": (0, 0, 139), + "darkcyan": (0, 139, 139), + "darkgoldenrod": (184, 134, 11), + "darkgray": (169, 169, 169), + "darkgreen": (0, 100, 0), + "darkgrey": (169, 169, 169), + "darkkhaki": (189, 183, 107), + "darkmagenta": (139, 0, 139), + "darkolivegreen": (85, 107, 47), + "darkorange": (255, 140, 0), + "darkorchid": (153, 50, 204), + "darkred": (139, 0, 0), + "darksalmon": (233, 150, 122), + "darkseagreen": (143, 188, 143), + "darkslateblue": (72, 61, 139), + "darkslategray": (47, 79, 79), + "darkslategrey": (47, 79, 79), + "darkturquoise": (0, 206, 209), + "darkviolet": (148, 0, 211), + "deeppink": (255, 20, 147), + "deepskyblue": (0, 191, 255), + "dimgray": (105, 105, 105), + "dimgrey": (105, 105, 105), + "dodgerblue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floralwhite": (255, 250, 240), + "forestgreen": (34, 139, 34), + "fuchsia": (255, 0, 255), + "gainsboro": (220, 220, 220), + "ghostwhite": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "gray": (128, 128, 128), + "green": (0, 128, 0), + "greenyellow": (173, 255, 47), + "grey": (128, 128, 128), + "honeydew": (240, 255, 240), + "hotpink": (255, 105, 180), + "indianred": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavenderblush": (255, 240, 245), + "lawngreen": (124, 252, 0), + "lemonchiffon": (255, 250, 205), + "lightblue": (173, 216, 230), + "lightcoral": (240, 128, 128), + "lightcyan": (224, 255, 255), + "lightgoldenrodyellow": (250, 250, 210), + "lightgray": (211, 211, 211), + "lightgreen": (144, 238, 144), + "lightgrey": (211, 211, 211), + "lightpink": (255, 182, 193), + "lightsalmon": (255, 160, 122), + "lightseagreen": (32, 178, 170), + "lightskyblue": (135, 206, 250), + "lightslategray": (119, 136, 153), + "lightslategrey": (119, 136, 153), + "lightsteelblue": (176, 196, 222), + "lightyellow": (255, 255, 224), + "lime": (0, 255, 0), + "limegreen": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "maroon": (128, 0, 0), + "mediumaquamarine": (102, 205, 170), + "mediumblue": (0, 0, 205), + "mediumorchid": (186, 85, 211), + "mediumpurple": (147, 112, 219), + "mediumseagreen": (60, 179, 113), + "mediumslateblue": (123, 104, 238), + "mediumspringgreen": (0, 250, 154), + "mediumturquoise": (72, 209, 204), + "mediumvioletred": (199, 21, 133), + "midnightblue": (25, 25, 112), + "mintcream": (245, 255, 250), + "mistyrose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajowhite": (255, 222, 173), + "navy": (0, 0, 128), + "oldlace": (253, 245, 230), + "olive": (128, 128, 0), + "olivedrab": (107, 142, 35), + "orange": (255, 165, 0), + "orangered": (255, 69, 0), + "orchid": (218, 112, 214), + "palegoldenrod": (238, 232, 170), + "palegreen": (152, 251, 152), + "paleturquoise": (175, 238, 238), + "palevioletred": (219, 112, 147), + "papayawhip": (255, 239, 213), + "peachpuff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powderblue": (176, 224, 230), + "purple": (128, 0, 128), + "red": (255, 0, 0), + "rosybrown": (188, 143, 143), + "royalblue": (65, 105, 225), + "saddlebrown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandybrown": (244, 164, 96), + "seagreen": (46, 139, 87), + "seashell": (255, 245, 238), + "sienna": (160, 82, 45), + "silver": (192, 192, 192), + "skyblue": (135, 206, 235), + "slateblue": (106, 90, 205), + "slategray": (112, 128, 144), + "slategrey": (112, 128, 144), + "snow": (255, 250, 250), + "springgreen": (0, 255, 127), + "steelblue": (70, 130, 180), + "tan": (210, 180, 140), + "teal": (0, 128, 128), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "white": (255, 255, 255), + "whitesmoke": (245, 245, 245), + "yellow": (255, 255, 0), + "yellowgreen": (154, 205, 50), +} + +COLORS_BY_VALUE = {v: k for k, v in COLORS_BY_NAME.items()} diff --git a/src/ome_types/_vendor/_pydantic_color_v2.py b/src/ome_types/_vendor/_pydantic_color_v2.py new file mode 100644 index 00000000..80fa567d --- /dev/null +++ b/src/ome_types/_vendor/_pydantic_color_v2.py @@ -0,0 +1,652 @@ +""" +Color definitions are used as per the CSS3 +[CSS Color Module Level 3](http://www.w3.org/TR/css3-color/#svg-color) specification. + +A few colors have multiple names referring to the sames colors, eg. `grey` and `gray` +or `aqua` and `cyan`. + +In these cases the _last_ color when sorted alphabetically takes preferences, +eg. `Color((0, 255, 255)).as_named() == 'cyan'` because "cyan" comes after "aqua". +""" +from __future__ import annotations + +import math +import re +from colorsys import hls_to_rgb, rgb_to_hls +from typing import TYPE_CHECKING, Any, Callable, Tuple, Union, cast + +from pydantic._internal import _repr, _utils +from pydantic_core import CoreSchema, PydanticCustomError, core_schema + +ColorTuple = Union[Tuple[int, int, int], Tuple[int, int, int, float]] +ColorType = Union[ColorTuple, str, "Color"] +HslColorTuple = Union[Tuple[float, float, float], Tuple[float, float, float, float]] + +if TYPE_CHECKING: + from pydantic import GetJsonSchemaHandler + from pydantic.json_schema import JsonSchemaValue + + +class RGBA: + """ + Internal use only as a representation of a color. + """ + + __slots__ = "r", "g", "b", "alpha", "_tuple" + + def __init__(self, r: float, g: float, b: float, alpha: float | None): + self.r = r + self.g = g + self.b = b + self.alpha = alpha + + self._tuple: tuple[float, float, float, float | None] = (r, g, b, alpha) + + def __getitem__(self, item: Any) -> Any: + return self._tuple[item] + + +# these are not compiled here to avoid import slowdown, they'll be compiled the first time they're used, then cached +_r_255 = r"(\d{1,3}(?:\.\d+)?)" +_r_comma = r"\s*,\s*" +_r_alpha = r"(\d(?:\.\d+)?|\.\d+|\d{1,2}%)" +_r_h = r"(-?\d+(?:\.\d+)?|-?\.\d+)(deg|rad|turn)?" +_r_sl = r"(\d{1,3}(?:\.\d+)?)%" +r_hex_short = r"\s*(?:#|0x)?([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?\s*" +r_hex_long = r"\s*(?:#|0x)?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?\s*" +# CSS3 RGB examples: rgb(0, 0, 0), rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 50%) +r_rgb = rf"\s*rgba?\(\s*{_r_255}{_r_comma}{_r_255}{_r_comma}{_r_255}(?:{_r_comma}{_r_alpha})?\s*\)\s*" +# CSS3 HSL examples: hsl(270, 60%, 50%), hsla(270, 60%, 50%, 0.5), hsla(270, 60%, 50%, 50%) +r_hsl = rf"\s*hsla?\(\s*{_r_h}{_r_comma}{_r_sl}{_r_comma}{_r_sl}(?:{_r_comma}{_r_alpha})?\s*\)\s*" +# CSS4 RGB examples: rgb(0 0 0), rgb(0 0 0 / 0.5), rgb(0 0 0 / 50%), rgba(0 0 0 / 50%) +r_rgb_v4_style = ( + rf"\s*rgba?\(\s*{_r_255}\s+{_r_255}\s+{_r_255}(?:\s*/\s*{_r_alpha})?\s*\)\s*" +) +# CSS4 HSL examples: hsl(270 60% 50%), hsl(270 60% 50% / 0.5), hsl(270 60% 50% / 50%), hsla(270 60% 50% / 50%) +r_hsl_v4_style = ( + rf"\s*hsla?\(\s*{_r_h}\s+{_r_sl}\s+{_r_sl}(?:\s*/\s*{_r_alpha})?\s*\)\s*" +) + +# colors where the two hex characters are the same, if all colors match this the short version of hex colors can be used +repeat_colors = {int(c * 2, 16) for c in "0123456789abcdef"} +rads = 2 * math.pi + + +class Color(_repr.Representation): + """ + Represents a color. + """ + + __slots__ = "_original", "_rgba" + + def __init__(self, value: ColorType) -> None: + self._rgba: RGBA + self._original: ColorType + if isinstance(value, (tuple, list)): + self._rgba = parse_tuple(value) + elif isinstance(value, str): + self._rgba = parse_str(value) + elif isinstance(value, Color): + self._rgba = value._rgba + value = value._original + else: + raise PydanticCustomError( + "color_error", + "value is not a valid color: value must be a tuple, list or string", + ) + + # if we've got here value must be a valid color + self._original = value + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + field_schema: dict[str, Any] = {} + field_schema.update(type="string", format="color") + return field_schema + + def original(self) -> ColorType: + """ + Original value passed to `Color`. + """ + return self._original + + def as_named(self, *, fallback: bool = False) -> str: + """ + Returns the name of the color if it can be found in `COLORS_BY_VALUE` dictionary, + otherwise returns the hexadecimal representation of the color or raises `ValueError`. + + Args: + fallback: If True, falls back to returning the hexadecimal representation of + the color instead of raising a ValueError when no named color is found. + + Returns: + The name of the color, or the hexadecimal representation of the color. + + Raises: + ValueError: When no named color is found and fallback is `False`. + """ + if self._rgba.alpha is None: + rgb = cast(Tuple[int, int, int], self.as_rgb_tuple()) + try: + return COLORS_BY_VALUE[rgb] + except KeyError as e: + if fallback: + return self.as_hex() + else: + raise ValueError( + "no named color found, use fallback=True, as_hex() or as_rgb()" + ) from e + else: + return self.as_hex() + + def as_hex(self) -> str: + """Returns the hexadecimal representation of the color. + + Hex string representing the color can be 3, 4, 6, or 8 characters depending on whether the string + a "short" representation of the color is possible and whether there's an alpha channel. + + Returns: + The hexadecimal representation of the color. + """ + values = [float_to_255(c) for c in self._rgba[:3]] + if self._rgba.alpha is not None: + values.append(float_to_255(self._rgba.alpha)) + + as_hex = "".join(f"{v:02x}" for v in values) + if all(c in repeat_colors for c in values): + as_hex = "".join(as_hex[c] for c in range(0, len(as_hex), 2)) + return "#" + as_hex + + def as_rgb(self) -> str: + """ + Color as an `rgb(, , )` or `rgba(, , , )` string. + """ + if self._rgba.alpha is None: + return f"rgb({float_to_255(self._rgba.r)}, {float_to_255(self._rgba.g)}, {float_to_255(self._rgba.b)})" + else: + return ( + f"rgba({float_to_255(self._rgba.r)}, {float_to_255(self._rgba.g)}, {float_to_255(self._rgba.b)}, " + f"{round(self._alpha_float(), 2)})" + ) + + def as_rgb_tuple(self, *, alpha: bool | None = None) -> ColorTuple: + """ + Returns the color as an RGB or RGBA tuple. + + Args: + alpha: Whether to include the alpha channel. There are three options for this input: + + - `None` (default): Include alpha only if it's set. (e.g. not `None`) + - `True`: Always include alpha. + - `False`: Always omit alpha. + + Returns: + A tuple that contains the values of the red, green, and blue channels in the range 0 to 255. + If alpha is included, it is in the range 0 to 1. + """ + r, g, b = (float_to_255(c) for c in self._rgba[:3]) + if alpha is None: + if self._rgba.alpha is None: + return r, g, b + else: + return r, g, b, self._alpha_float() + elif alpha: + return r, g, b, self._alpha_float() + else: + # alpha is False + return r, g, b + + def as_hsl(self) -> str: + """ + Color as an `hsl(, , )` or `hsl(, , , )` string. + """ + if self._rgba.alpha is None: + h, s, li = self.as_hsl_tuple(alpha=False) # type: ignore + return f"hsl({h * 360:0.0f}, {s:0.0%}, {li:0.0%})" + else: + h, s, li, a = self.as_hsl_tuple(alpha=True) # type: ignore + return f"hsl({h * 360:0.0f}, {s:0.0%}, {li:0.0%}, {round(a, 2)})" + + def as_hsl_tuple(self, *, alpha: bool | None = None) -> HslColorTuple: + """ + Returns the color as an HSL or HSLA tuple. + + Args: + alpha: Whether to include the alpha channel. + + - `None` (default): Include the alpha channel only if it's set (e.g. not `None`). + - `True`: Always include alpha. + - `False`: Always omit alpha. + + Returns: + The color as a tuple of hue, saturation, lightness, and alpha (if included). + All elements are in the range 0 to 1. + + Note: + This is HSL as used in HTML and most other places, not HLS as used in Python's `colorsys`. + """ + h, l, s = rgb_to_hls(self._rgba.r, self._rgba.g, self._rgba.b) # noqa: E741 + if alpha is None: + if self._rgba.alpha is None: + return h, s, l + else: + return h, s, l, self._alpha_float() + if alpha: + return h, s, l, self._alpha_float() + else: + # alpha is False + return h, s, l + + def _alpha_float(self) -> float: + return 1 if self._rgba.alpha is None else self._rgba.alpha + + @classmethod + def __get_pydantic_core_schema__( + cls, source: type[Any], handler: Callable[[Any], CoreSchema] + ) -> core_schema.CoreSchema: + return core_schema.general_plain_validator_function( + cls._validate, serialization=core_schema.to_string_ser_schema() + ) + + @classmethod + def _validate(cls, __input_value: Any, _: Any) -> Color: + return cls(__input_value) + + def __str__(self) -> str: + return self.as_named(fallback=True) + + def __repr_args__(self) -> _repr.ReprArgs: + return [(None, self.as_named(fallback=True))] + [("rgb", self.as_rgb_tuple())] + + def __eq__(self, other: Any) -> bool: + return isinstance(other, Color) and self.as_rgb_tuple() == other.as_rgb_tuple() + + def __hash__(self) -> int: + return hash(self.as_rgb_tuple()) + + +def parse_tuple(value: tuple[Any, ...]) -> RGBA: + """Parse a tuple or list to get RGBA values. + + Args: + value: A tuple or list. + + Returns: + An `RGBA` tuple parsed from the input tuple. + + Raises: + PydanticCustomError: If tuple is not valid. + """ + if len(value) == 3: + r, g, b = (parse_color_value(v) for v in value) + return RGBA(r, g, b, None) + elif len(value) == 4: + r, g, b = (parse_color_value(v) for v in value[:3]) + return RGBA(r, g, b, parse_float_alpha(value[3])) + else: + raise PydanticCustomError( + "color_error", "value is not a valid color: tuples must have length 3 or 4" + ) + + +def parse_str(value: str) -> RGBA: + """ + Parse a string representing a color to an RGBA tuple. + + Possible formats for the input string include: + + * named color, see `COLORS_BY_NAME` + * hex short eg. `fff` (prefix can be `#`, `0x` or nothing) + * hex long eg. `ffffff` (prefix can be `#`, `0x` or nothing) + * `rgb(, , )` + * `rgba(, , , )` + * `transparent` + + Args: + value: A string representing a color. + + Returns: + An `RGBA` tuple parsed from the input string. + + Raises: + ValueError: If the input string cannot be parsed to an RGBA tuple. + """ + value_lower = value.lower() + try: + r, g, b = COLORS_BY_NAME[value_lower] + except KeyError: + pass + else: + return ints_to_rgba(r, g, b, None) + + m = re.fullmatch(r_hex_short, value_lower) + if m: + *rgb, a = m.groups() + r, g, b = (int(v * 2, 16) for v in rgb) + if a: + alpha: float | None = int(a * 2, 16) / 255 + else: + alpha = None + return ints_to_rgba(r, g, b, alpha) + + m = re.fullmatch(r_hex_long, value_lower) + if m: + *rgb, a = m.groups() + r, g, b = (int(v, 16) for v in rgb) + if a: + alpha = int(a, 16) / 255 + else: + alpha = None + return ints_to_rgba(r, g, b, alpha) + + m = re.fullmatch(r_rgb, value_lower) or re.fullmatch(r_rgb_v4_style, value_lower) + if m: + return ints_to_rgba(*m.groups()) # type: ignore + + m = re.fullmatch(r_hsl, value_lower) or re.fullmatch(r_hsl_v4_style, value_lower) + if m: + return parse_hsl(*m.groups()) # type: ignore + + if value_lower == "transparent": + return RGBA(0, 0, 0, 0) + + raise PydanticCustomError( + "color_error", + "value is not a valid color: string not recognised as a valid color", + ) + + +def ints_to_rgba( + r: int | str, + g: int | str, + b: int | str, + alpha: float | None = None, +) -> RGBA: + """ + Converts integer or string values for RGB color and an optional alpha value to an `RGBA` object. + + Args: + r: An integer or string representing the red color value. + g: An integer or string representing the green color value. + b: An integer or string representing the blue color value. + alpha: A float representing the alpha value. Defaults to None. + + Returns: + An instance of the `RGBA` class with the corresponding color and alpha values. + """ + return RGBA( + parse_color_value(r), + parse_color_value(g), + parse_color_value(b), + parse_float_alpha(alpha), + ) + + +def parse_color_value(value: int | str, max_val: int = 255) -> float: + """ + Parse the color value provided and return a number between 0 and 1. + + Args: + value: An integer or string color value. + max_val: Maximum range value. Defaults to 255. + + Raises: + PydanticCustomError: If the value is not a valid color. + + Returns: + A number between 0 and 1. + """ + try: + color = float(value) + except ValueError: + raise PydanticCustomError( + "color_error", + "value is not a valid color: color values must be a valid number", + ) + if 0 <= color <= max_val: + return color / max_val + else: + raise PydanticCustomError( + "color_error", + "value is not a valid color: color values must be in the range 0 to {max_val}", + {"max_val": max_val}, + ) + + +def parse_float_alpha(value: None | str | float | int) -> float | None: + """ + Parse an alpha value checking it's a valid float in the range 0 to 1. + + Args: + value: The input value to parse. + + Returns: + The parsed value as a float, or `None` if the value was None or equal 1. + + Raises: + PydanticCustomError: If the input value cannot be successfully parsed as a float in the expected range. + """ + if value is None: + return None + try: + if isinstance(value, str) and value.endswith("%"): + alpha = float(value[:-1]) / 100 + else: + alpha = float(value) + except ValueError: + raise PydanticCustomError( + "color_error", + "value is not a valid color: alpha values must be a valid float", + ) + + if _utils.almost_equal_floats(alpha, 1): + return None + elif 0 <= alpha <= 1: + return alpha + else: + raise PydanticCustomError( + "color_error", + "value is not a valid color: alpha values must be in the range 0 to 1", + ) + + +def parse_hsl( + h: str, h_units: str, sat: str, light: str, alpha: float | None = None +) -> RGBA: + """ + Parse raw hue, saturation, lightness, and alpha values and convert to RGBA. + + Args: + h: The hue value. + h_units: The unit for hue value. + sat: The saturation value. + light: The lightness value. + alpha: Alpha value. + + Returns: + An instance of `RGBA`. + """ + s_value, l_value = parse_color_value(sat, 100), parse_color_value(light, 100) + + h_value = float(h) + if h_units in {None, "deg"}: + h_value = h_value % 360 / 360 + elif h_units == "rad": + h_value = h_value % rads / rads + else: + # turns + h_value = h_value % 1 + + r, g, b = hls_to_rgb(h_value, l_value, s_value) + return RGBA(r, g, b, parse_float_alpha(alpha)) + + +def float_to_255(c: float) -> int: + """ + Converts a float value between 0 and 1 (inclusive) to an integer between 0 and 255 (inclusive). + + Args: + c: The float value to be converted. Must be between 0 and 1 (inclusive). + + Returns: + The integer equivalent of the given float value rounded to the nearest whole number. + + Raises: + ValueError: If the given float value is outside the acceptable range of 0 to 1 (inclusive). + """ + return int(round(c * 255)) + + +COLORS_BY_NAME = { + "aliceblue": (240, 248, 255), + "antiquewhite": (250, 235, 215), + "aqua": (0, 255, 255), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "black": (0, 0, 0), + "blanchedalmond": (255, 235, 205), + "blue": (0, 0, 255), + "blueviolet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadetblue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflowerblue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "darkblue": (0, 0, 139), + "darkcyan": (0, 139, 139), + "darkgoldenrod": (184, 134, 11), + "darkgray": (169, 169, 169), + "darkgreen": (0, 100, 0), + "darkgrey": (169, 169, 169), + "darkkhaki": (189, 183, 107), + "darkmagenta": (139, 0, 139), + "darkolivegreen": (85, 107, 47), + "darkorange": (255, 140, 0), + "darkorchid": (153, 50, 204), + "darkred": (139, 0, 0), + "darksalmon": (233, 150, 122), + "darkseagreen": (143, 188, 143), + "darkslateblue": (72, 61, 139), + "darkslategray": (47, 79, 79), + "darkslategrey": (47, 79, 79), + "darkturquoise": (0, 206, 209), + "darkviolet": (148, 0, 211), + "deeppink": (255, 20, 147), + "deepskyblue": (0, 191, 255), + "dimgray": (105, 105, 105), + "dimgrey": (105, 105, 105), + "dodgerblue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floralwhite": (255, 250, 240), + "forestgreen": (34, 139, 34), + "fuchsia": (255, 0, 255), + "gainsboro": (220, 220, 220), + "ghostwhite": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "gray": (128, 128, 128), + "green": (0, 128, 0), + "greenyellow": (173, 255, 47), + "grey": (128, 128, 128), + "honeydew": (240, 255, 240), + "hotpink": (255, 105, 180), + "indianred": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavenderblush": (255, 240, 245), + "lawngreen": (124, 252, 0), + "lemonchiffon": (255, 250, 205), + "lightblue": (173, 216, 230), + "lightcoral": (240, 128, 128), + "lightcyan": (224, 255, 255), + "lightgoldenrodyellow": (250, 250, 210), + "lightgray": (211, 211, 211), + "lightgreen": (144, 238, 144), + "lightgrey": (211, 211, 211), + "lightpink": (255, 182, 193), + "lightsalmon": (255, 160, 122), + "lightseagreen": (32, 178, 170), + "lightskyblue": (135, 206, 250), + "lightslategray": (119, 136, 153), + "lightslategrey": (119, 136, 153), + "lightsteelblue": (176, 196, 222), + "lightyellow": (255, 255, 224), + "lime": (0, 255, 0), + "limegreen": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "maroon": (128, 0, 0), + "mediumaquamarine": (102, 205, 170), + "mediumblue": (0, 0, 205), + "mediumorchid": (186, 85, 211), + "mediumpurple": (147, 112, 219), + "mediumseagreen": (60, 179, 113), + "mediumslateblue": (123, 104, 238), + "mediumspringgreen": (0, 250, 154), + "mediumturquoise": (72, 209, 204), + "mediumvioletred": (199, 21, 133), + "midnightblue": (25, 25, 112), + "mintcream": (245, 255, 250), + "mistyrose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajowhite": (255, 222, 173), + "navy": (0, 0, 128), + "oldlace": (253, 245, 230), + "olive": (128, 128, 0), + "olivedrab": (107, 142, 35), + "orange": (255, 165, 0), + "orangered": (255, 69, 0), + "orchid": (218, 112, 214), + "palegoldenrod": (238, 232, 170), + "palegreen": (152, 251, 152), + "paleturquoise": (175, 238, 238), + "palevioletred": (219, 112, 147), + "papayawhip": (255, 239, 213), + "peachpuff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powderblue": (176, 224, 230), + "purple": (128, 0, 128), + "red": (255, 0, 0), + "rosybrown": (188, 143, 143), + "royalblue": (65, 105, 225), + "saddlebrown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandybrown": (244, 164, 96), + "seagreen": (46, 139, 87), + "seashell": (255, 245, 238), + "sienna": (160, 82, 45), + "silver": (192, 192, 192), + "skyblue": (135, 206, 235), + "slateblue": (106, 90, 205), + "slategray": (112, 128, 144), + "slategrey": (112, 128, 144), + "snow": (255, 250, 250), + "springgreen": (0, 255, 127), + "steelblue": (70, 130, 180), + "tan": (210, 180, 140), + "teal": (0, 128, 128), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "white": (255, 255, 255), + "whitesmoke": (245, 245, 245), + "yellow": (255, 255, 0), + "yellowgreen": (154, 205, 50), +} + +COLORS_BY_VALUE = {v: k for k, v in COLORS_BY_NAME.items()} diff --git a/src/ome_types/model/_color.py b/src/ome_types/model/_color.py index b7650e4d..3daf4ac8 100644 --- a/src/ome_types/model/_color.py +++ b/src/ome_types/model/_color.py @@ -1,7 +1,7 @@ from contextlib import suppress from typing import Tuple, Union -from ome_types import _pydantic_compat +from ome_types._vendor import Color as _Color __all__ = ["Color"] @@ -9,7 +9,7 @@ ColorType = Union[Tuple[int, int, int], RGBA, str, int] -class Color(_pydantic_compat.Color): +class Color(_Color): """A Pydantic Color subclass that converts to and from OME int32 types.""" def __init__(self, val: ColorType = -1) -> None: diff --git a/src/xsdata_pydantic_basemodel/pydantic_compat.py b/src/xsdata_pydantic_basemodel/pydantic_compat.py index 1f0d26ff..d569b104 100644 --- a/src/xsdata_pydantic_basemodel/pydantic_compat.py +++ b/src/xsdata_pydantic_basemodel/pydantic_compat.py @@ -3,7 +3,7 @@ from dataclasses import MISSING, field from typing import TYPE_CHECKING, Any, Callable, TypeVar -from pydantic import BaseModel, __version__ +from pydantic import BaseModel, version if TYPE_CHECKING: from typing import Iterator, Literal @@ -12,7 +12,7 @@ __all__ = ["Field"] -PYDANTIC2 = __version__.startswith("2") +PYDANTIC2 = version.VERSION.startswith("2") M = TypeVar("M", bound=BaseModel) C = TypeVar("C", bound=Callable[..., Any]) From dd869e343763b24e068460c145bddf5590863324 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 14 Jul 2023 10:30:35 -0400 Subject: [PATCH 6/6] feat: add back napari plugin (#207) * feat: add back napari plugin * typing * add headless --- .github/workflows/test.yml | 25 ++++- pyproject.toml | 7 +- src/ome_types/napari.yaml | 11 +++ src/ome_types/widget.py | 196 +++++++++++++++++++++++++++++++++++++ tests/test_widget.py | 15 ++- 5 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 src/ome_types/napari.yaml create mode 100644 src/ome_types/widget.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f49c75b1..a44b5879 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,29 @@ jobs: - uses: codecov/codecov-action@v3 if: matrix.python-version != '3.7' + test-widget: + name: test-widget + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + backend: ["PyQt5", "PySide2", "PySide6", "PyQt6"] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - uses: tlambert03/setup-qt-libs@v1 + - name: Install + run: | + python -m pip install -U pip + python -m pip install .[test,test-qt] + python -m pip install "${{ matrix.backend }}" + - name: Test + uses: aganders3/headless-gui@v1 + with: + run: pytest tests/test_widget.py + test-pydantic: name: Pydantic compat runs-on: ubuntu-latest @@ -170,7 +193,7 @@ jobs: python -m build . - name: Publish PyPI Package - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.TWINE_API_KEY }} diff --git a/pyproject.toml b/pyproject.toml index 5f61cb3a..9d53b5ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,8 @@ Source = "https://github.com/tlambert03/ome-types" Tracker = "https://github.com/tlambert03/ome-types/issues" Documentation = "https://ome-types.readthedocs.io/en/latest/" -[project.entry-points."napari.plugin"] -ome-types = "ome_types._napari_plugin" +[project.entry-points."napari.manifest"] +ome-types = "ome_types:napari.yaml" # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies @@ -66,6 +66,7 @@ test = [ "pytest", "xmlschema", ] +test-qt = ["qtpy", "pytest-qt"] # https://hatch.pypa.io/latest/plugins/build-hook/custom/ [tool.hatch.build.targets.wheel.hooks.custom] @@ -205,7 +206,7 @@ exclude_lines = [ [tool.coverage.run] source = ["ome_types", "ome_autogen"] -omit = ["src/ome_types/_autogenerated/*", "/private/var/folders/*", "*/_vendor/*"] +omit = ["src/ome_types/_autogenerated/*", "/private/var/folders/*", "*/_vendor/*", "src/ome_types/widget.py"] # Entry points -- REMOVE ONCE XSDATA-PYDANTIC-BASEMODEL IS SEPARATE [project.entry-points."xsdata.plugins.class_types"] diff --git a/src/ome_types/napari.yaml b/src/ome_types/napari.yaml new file mode 100644 index 00000000..38342e7d --- /dev/null +++ b/src/ome_types/napari.yaml @@ -0,0 +1,11 @@ +name: ome-types +display_name: OME Types +schema_version: 0.2.0 +contributions: + commands: + - id: ome-types.ome_tree + title: Open OME Tree widget + python_name: ome_types.widget:OMETree + widgets: + - command: ome-types.ome_tree + display_name: OME Tree Widget diff --git a/src/ome_types/widget.py b/src/ome_types/widget.py new file mode 100644 index 00000000..297c4a57 --- /dev/null +++ b/src/ome_types/widget.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import os +import warnings +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ome_types.model import OME + +try: + from qtpy.QtCore import QMimeData, Qt + from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem +except ImportError as e: + raise ImportError( + "qtpy and a Qt backend (pyside or pyqt) is required to use the OME widget:\n" + "pip install qtpy pyqt5" + ) from e + + +if TYPE_CHECKING: + import napari.layers + import napari.viewer + from qtpy.QtWidgets import QWidget + +METADATA_KEY = "ome_types" + + +class OMETree(QTreeWidget): + """A Widget that can show OME XML.""" + + def __init__( + self, + ome_meta: Path | OME | str | None | dict = None, + viewer: napari.viewer.Viewer = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + self._viewer = viewer + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setIndentation(15) + + item = self.headerItem() + font = item.font(0) + font.setBold(True) + item.setFont(0, font) + self.clear() + + self._current_path: str | None = None + if ome_meta: + if isinstance(ome_meta, Path): + ome_meta = str(ome_meta) + self.update(ome_meta) + + if viewer is not None: + viewer.layers.selection.events.active.connect( + lambda e: self._try_load_layer(e.value) + ) + self._try_load_layer(viewer.layers.selection.active) + + def clear(self) -> None: + """Clear the widget and reset the header text.""" + self.headerItem().setText(0, "drag/drop file...") + super().clear() + + def _try_load_layer(self, layer: napari.layers.Layer) -> None: + """Handle napari viewer behavior.""" + if layer is not None: + path = str(layer.source.path) + + # deprecated... don't do this ... it should be a dict + if callable(layer.metadata): + ome_meta = layer.metadata() + elif isinstance(layer.metadata, OME): + ome_meta = layer.metadata + else: + ome_meta = layer.metadata.get(METADATA_KEY) + if callable(ome_meta): + ome_meta = ome_meta() + + ome = None + if isinstance(ome_meta, OME): + ome = ome_meta + elif path.endswith((".tiff", ".tif")) and path != self._current_path: + try: + ome = OME.from_tiff(path) + except Exception: + return + elif path.endswith(".nd2"): + ome = self._try_from_nd2(path) + if ome is None: + return + if isinstance(ome, OME): + self._current_path = path + self.update(ome) + self.headerItem().setText(0, os.path.basename(path)) + else: + self._current_path = None + self.clear() + + def _try_from_nd2(self, path: str) -> OME | None: + try: + import nd2 + + with nd2.ND2File(path) as f: + return f.ome_metadata() + except Exception: + return None + + def update(self, ome: OME | str | None | dict) -> None: + """Update the widget with a new OME object or path to an OME XML file.""" + if not ome: + return + if isinstance(ome, OME): + _ome = ome + elif isinstance(ome, dict): + _ome = OME(**ome) + elif isinstance(ome, str): + if ome == self._current_path: + return + try: + if ome.endswith(".xml"): + _ome = OME.from_xml(ome) + elif ome.lower().endswith((".tif", ".tiff")): + _ome = OME.from_tiff(ome) + elif ome.lower().endswith(".nd2"): + _ome = self._try_from_nd2(ome) # type: ignore + if _ome is None: + raise Exception() + else: + warnings.warn(f"Unrecognized file type: {ome}", stacklevel=2) + return + except Exception as e: + warnings.warn( + f"Could not parse OME metadata from {ome}: {e}", stacklevel=2 + ) + return + self.headerItem().setText(0, os.path.basename(ome)) + self._current_path = ome + else: + raise TypeError("must be OME object or string") + if hasattr(_ome, "model_dump"): + data = _ome.model_dump(exclude_unset=True) + else: + data = _ome.dict(exclude_unset=True) + self._fill_item(data) + + def _fill_item(self, obj: Any, item: QTreeWidgetItem = None) -> None: + if item is None: + self.clear() + item = self.invisibleRootItem() + if isinstance(obj, dict): + for key, val in sorted(obj.items()): + child = QTreeWidgetItem([key]) + item.addChild(child) + self._fill_item(val, child) + elif isinstance(obj, (list, tuple)): + for n, val in enumerate(obj): + text = val.get("id", n) if hasattr(val, "get") else n + child = QTreeWidgetItem([str(text)]) + item.addChild(child) + self._fill_item(val, child) + else: + t = getattr(obj, "value", str(obj)) + item.setText(0, f"{item.text(0)}: {t}") + + def dropMimeData( + self, parent: QTreeWidgetItem, index: int, data: QMimeData, _: Any + ) -> bool: + """Handle drag/drop events to load OME XML files.""" + if data.hasUrls(): + for url in data.urls(): + lf = url.toLocalFile() + if lf.endswith((".xml", ".tiff", ".tif", ".nd2")): + self.update(lf) + return True + return False + + def mimeTypes(self) -> list[str]: + """Return the supported mime types for drag/drop events.""" + return ["text/uri-list"] + + def supportedDropActions(self) -> Qt.DropActions: + """Return the supported drop actions for drag/drop events.""" + return Qt.CopyAction + + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication + + app = QApplication([]) + + widget = OMETree() + widget.show() + + app.exec() diff --git a/tests/test_widget.py b/tests/test_widget.py index 843b8b27..eb77cba5 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -2,14 +2,11 @@ import pytest -nplg = pytest.importorskip("ome_types._napari_plugin") +try: + from ome_types import widget +except ImportError: + pytest.skip("ome_types not installed", allow_module_level=True) -TESTS = Path(__file__).parent -DATA = TESTS / "data" - -@pytest.mark.parametrize("fname", DATA.iterdir(), ids=lambda x: x.stem) -def test_widget(fname, qtbot): - if fname.stem in ("bad.ome", "timestampannotation.ome"): - pytest.xfail() - nplg.OMETree(str(fname)) +def test_widget(valid_xml: Path, qtbot): + widget.OMETree(str(valid_xml))