diff --git a/isic_metadata/fields.py b/isic_metadata/fields.py index 47a6473..4da0f85 100644 --- a/isic_metadata/fields.py +++ b/isic_metadata/fields.py @@ -191,6 +191,15 @@ class AnatomSiteGeneralEnum(str, Enum): oral_genital = "oral/genital" +class AnatomSiteSpecialEnum(str, Enum): + acral_nos = "acral NOS" + nail_nos = "nail NOS" + fingernail = "fingernail" + toenail = "toenail" + acral_palms_soles = "acral palms or soles" + oral_genital = "oral or genital" + + class ColorTintEnum(str, Enum): blue = "blue" pink = "pink" diff --git a/isic_metadata/metadata.py b/isic_metadata/metadata.py index 2e945cf..3c28f07 100644 --- a/isic_metadata/metadata.py +++ b/isic_metadata/metadata.py @@ -21,6 +21,7 @@ from isic_metadata.fields import ( Age, AnatomSiteGeneralEnum, + AnatomSiteSpecialEnum, BenignMalignantEnum, ClinSizeLongDiamMm, ColorTintEnum, @@ -206,6 +207,7 @@ class MetadataRow(BaseModel): ) = None sex: Literal["male", "female"] | None = None anatom_site_general: AnatomSiteGeneralEnum | None = None + anatom_site_special: AnatomSiteSpecialEnum | None = None benign_malignant: BenignMalignantEnum | None = None diagnosis: ( Annotated[ @@ -442,6 +444,57 @@ def validate_dermoscopic_fields(self) -> MetadataRow: return self + @model_validator(mode="after") + def validate_anatom_site_special(self) -> MetadataRow: + if not self.anatom_site_special: + return self + + if not self.anatom_site_general: + raise error_missing_field("anatom_site_special", "anatom_site_general") + + valid_combinations = { + AnatomSiteSpecialEnum.acral_nos: [ + AnatomSiteGeneralEnum.upper_extremity, + AnatomSiteGeneralEnum.lower_extremity, + AnatomSiteGeneralEnum.palms_soles, + ], + AnatomSiteSpecialEnum.nail_nos: [ + AnatomSiteGeneralEnum.upper_extremity, + AnatomSiteGeneralEnum.lower_extremity, + AnatomSiteGeneralEnum.palms_soles, + ], + AnatomSiteSpecialEnum.fingernail: [ + AnatomSiteGeneralEnum.upper_extremity, + AnatomSiteGeneralEnum.palms_soles, + ], + AnatomSiteSpecialEnum.toenail: [ + AnatomSiteGeneralEnum.lower_extremity, + AnatomSiteGeneralEnum.palms_soles, + ], + AnatomSiteSpecialEnum.acral_palms_soles: [ + AnatomSiteGeneralEnum.upper_extremity, + AnatomSiteGeneralEnum.lower_extremity, + AnatomSiteGeneralEnum.palms_soles, + ], + AnatomSiteSpecialEnum.oral_genital: [ + AnatomSiteGeneralEnum.head_neck, + AnatomSiteGeneralEnum.oral_genital, + ], + } + + if self.anatom_site_special.value not in valid_combinations: + return self + + if self.anatom_site_general.value not in valid_combinations[self.anatom_site_special.value]: + raise error_incompatible_fields( + "anatom_site_special", + "anatom_site_general", + self.anatom_site_special.value, + self.anatom_site_general.value, + ) + + return self + @model_validator(mode="after") def validate_tbp_tile_fields(self) -> MetadataRow: if not self.tbp_tile_type: diff --git a/tests/test_fields.py b/tests/test_fields.py index 7a19db7..71b1799 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -7,6 +7,7 @@ import pytest from isic_metadata.diagnosis_hierarchical import DiagnosisEnum +from isic_metadata.fields import AnatomSiteGeneralEnum from isic_metadata.metadata import MetadataRow, convert_errors @@ -124,3 +125,45 @@ def test_clin_size_long_diam_mm_invalid(): MetadataRow.model_validate({"clin_size_long_diam_mm": "foo"}) assert len(excinfo.value.errors()) == 1 assert "Unable to parse value as a number" in convert_errors(excinfo.value)[0]["msg"] + + +@pytest.mark.parametrize( + ("anatom_site_special", "anatom_site_general_values"), + [ + ("acral NOS", ["upper extremity", "lower extremity", "palms/soles"]), + ("nail NOS", ["upper extremity", "lower extremity", "palms/soles"]), + ("fingernail", ["upper extremity", "palms/soles"]), + ("toenail", ["lower extremity", "palms/soles"]), + ("acral palms or soles", ["upper extremity", "lower extremity", "palms/soles"]), + ("oral or genital", ["head/neck", "oral/genital"]), + ], +) +def test_anatom_site_special(anatom_site_special: str, anatom_site_general_values: list[str]): + for anatom_site_general_value in anatom_site_general_values: + metadata = MetadataRow.model_validate( + { + "anatom_site_special": anatom_site_special, + "anatom_site_general": anatom_site_general_value, + } + ) + assert metadata.anatom_site_special == anatom_site_special + assert metadata.anatom_site_general == anatom_site_general_value + + for invalid_anatom_site_general in AnatomSiteGeneralEnum: + if invalid_anatom_site_general.value not in anatom_site_general_values: + with pytest.raises(ValidationError) as excinfo: + MetadataRow.model_validate( + { + "anatom_site_special": anatom_site_special, + "anatom_site_general": invalid_anatom_site_general.value, + } + ) + assert len(excinfo.value.errors()) == 1 + assert "is incompatible with anatom_site_general" in excinfo.value.errors()[0]["msg"] + + +def test_anatom_site_special_requires_anatom_site_general(): + with pytest.raises(ValidationError) as excinfo: + MetadataRow.model_validate({"anatom_site_special": "acral NOS"}) + assert len(excinfo.value.errors()) == 1 + assert "requires setting anatom_site_general" in excinfo.value.errors()[0]["msg"]