diff --git a/src/dve/metadata_parser/domain_types.py b/src/dve/metadata_parser/domain_types.py index 3d7bc3c..6c864e8 100644 --- a/src/dve/metadata_parser/domain_types.py +++ b/src/dve/metadata_parser/domain_types.py @@ -11,11 +11,12 @@ from functools import lru_cache from typing import Any, ClassVar, Optional, TypeVar, Union -from pydantic import GetCoreSchemaHandler, types, validate_call +from pydantic import GetCoreSchemaHandler, validate_call from pydantic_core import CoreSchema, core_schema from typing_extensions import Literal from dve.metadata_parser import exc +from dve.metadata_parser.utilities import generate_alphanumeric_type_name T = TypeVar("T") @@ -674,60 +675,65 @@ def reportingperiod( return type("ReportingPeriod", (ReportingPeriod, *ReportingPeriod.__bases__), dict_) -# TODO - refactor this as it won't work in Pyndatic V2 +class Alphanumeric(str): + """ + Alphanumeric type + """ + + ID_GROUP_STR: Optional[str] = r"[A-Za-z0-9]" + MIN_DIGITS: Optional[int] = None + MAX_DIGITS: Optional[int] = 1 + + @classmethod + def generate_pattern_and_type(cls) -> re.Pattern: + """ + Generates an alphanumeric regex pattern based on user defined min/max digits. + """ + if (cls.MAX_DIGITS == cls.MIN_DIGITS) or ( + cls.MAX_DIGITS is not None and cls.MIN_DIGITS is None + ): + pattern_str = f"{cls.ID_GROUP_STR}{{{cls.MAX_DIGITS}}}" + else: + pattern_str = f"{cls.ID_GROUP_STR}{{{cls.MIN_DIGITS},{cls.MAX_DIGITS}}}" + return re.compile(f"^{pattern_str}$") + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + """Gets all validators""" + return core_schema.str_schema(pattern=cls.generate_pattern_and_type()) + + @lru_cache() @validate_call -def alphanumeric( - min_digits: types.NonNegativeInt = 1, # pylint: disable=E1101 - max_digits: types.PositiveInt = 1, # pylint: disable=E1101 -) -> type[_SimpleRegexValidator]: - """Return a regex-validated class which will ensure that +def alphanumeric(max_digits: int = 1, min_digits: Optional[int] = None) -> type[Alphanumeric]: + """ + Return a regex-validated class which will ensure that passed numbers are alphanumeric. - """ - an_group_str = r"[A-Za-z0-9]" - if max_digits == min_digits: - type_name = f"AN{max_digits}" - pattern_str = f"{an_group_str}{{{max_digits}}}" - else: - type_name = f"AN{min_digits}_{max_digits}" - pattern_str = f"{an_group_str}{{{min_digits},{max_digits}}}" - - dict_ = _SimpleRegexValidator.__dict__.copy() - dict_["pattern"] = re.compile(f"^{pattern_str}$") - - return type( - type_name, - (_SimpleRegexValidator, *_SimpleRegexValidator.__bases__), - dict_, - ) + dict_ = Alphanumeric.__dict__.copy() + dict_["MAX_DIGITS"] = max_digits + dict_["MIN_DIGITS"] = min_digits + + _type_name = generate_alphanumeric_type_name(max_digits, min_digits) + + return type(_type_name, (Alphanumeric, *Alphanumeric.__bases__), dict_) -# TODO - refactor this as it won't work in Pyndatic V2 @lru_cache() @validate_call -def identifier( - min_digits: types.NonNegativeInt = 1, # pylint: disable=E1101 - max_digits: types.PositiveInt = 1, # pylint: disable=E1101 -) -> type[_SimpleRegexValidator]: +def identifier(max_digits: int = 1, min_digits: Optional[int] = None) -> type[Alphanumeric]: """ Return a regex-validated class which will ensure that passed strings are alphanumeric or in a fixed set of special characters for identifiers. """ - id_group_str = r"[A-Za-z0-9_\-=\/\\#:; ().`*!,|+'\^\[\]]" - if max_digits == min_digits: - type_name = f"AN{max_digits}" - pattern_str = rf"{id_group_str}{{{max_digits}}}" - else: - type_name = f"AN{min_digits}_{max_digits}" - pattern_str = rf"{id_group_str}{{{min_digits},{max_digits}}}" - - dict_ = _SimpleRegexValidator.__dict__.copy() - dict_["pattern"] = re.compile(f"^{pattern_str}$") - - return type( - type_name, - (_SimpleRegexValidator, *_SimpleRegexValidator.__bases__), - dict_, - ) + dict_ = Alphanumeric.__dict__.copy() + dict_["MAX_DIGITS"] = max_digits + dict_["MIN_DIGITS"] = min_digits + dict_["ID_GROUP_STR"] = r"[A-Za-z0-9_\-=\/\\#:; ().`*!,|+'\^\[\]]" + + _type_name = generate_alphanumeric_type_name(max_digits, min_digits) + + return type(_type_name, (Alphanumeric, *Alphanumeric.__bases__), dict_) diff --git a/src/dve/metadata_parser/utilities.py b/src/dve/metadata_parser/utilities.py index 0efa078..aaa5e43 100644 --- a/src/dve/metadata_parser/utilities.py +++ b/src/dve/metadata_parser/utilities.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from types import ModuleType -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Optional, Union from typing_extensions import Protocol @@ -52,3 +52,14 @@ def chain_get( return result raise exc.TypeNotFoundError(f"Callable or type ({item!r}) not found") + + +def generate_alphanumeric_type_name(max_digits: int, min_digits: Optional[int]) -> str: + """Generates a dynamic alphanumeric type name based on the max digits and min digits provided""" + if max_digits is None: + raise ValueError("Alphanumeric type must have a max digits value defined.") + + if (max_digits == min_digits) or min_digits is None: + return f"AN{max_digits}" + + return f"AN{min_digits}_{max_digits}" diff --git a/tests/test_model_generation/test_domain_types.py b/tests/test_model_generation/test_domain_types.py index 8917bac..9708548 100644 --- a/tests/test_model_generation/test_domain_types.py +++ b/tests/test_model_generation/test_domain_types.py @@ -19,6 +19,8 @@ class ATestModel(BaseModel): postcode: Optional[hct.Postcode] = None org_id: Optional[hct.OrgID] = None nhsnumber2: Optional[hct.permissive_nhs_number()] = None + an_value: Optional[hct.alphanumeric(max_digits=6)] = None + id_value: Optional[hct.identifier(max_digits=6)] = None class DatetimeModel(BaseModel): @@ -400,3 +402,80 @@ def test_formattedtime_against_model(time_to_validate: str, expected_to_error: b StrictTimeModel(time_val=time_to_validate) else: StrictTimeModel(time_val=time_to_validate) + + +@pytest.mark.parametrize( + ("val", "expected"), + [ + ("abcdef", "abcdef"), + ("abcDEF", "abcDEF"), + ("123456", "123456"), + ("abc123", "abc123"), + ], +) +def test_valid_alphanumeric(val: str, expected: str): + model = ATestModel(an_value=val) + assert model.an_value == expected + +@pytest.mark.parametrize( + "val", + [ + "ab@cd", + "ab cd", + "ab.cd", + "ab$cd", + ], +) +def test_invalid_alphanumeric(val: str): + with pytest.raises(ValidationError): + ATestModel(an_value=val) + + +@pytest.mark.parametrize( + ("val", "expected"), + [ + ("ab-123", "ab-123"), + ("ab 123", "ab 123"), + ("ab.123", "ab.123"), + ("ab_123", "ab_123"), + ("ab-cd.", "ab-cd."), + ("ab=123", "ab=123"), + ("ab/cd.", "ab/cd."), + ("ab\\cd.", "ab\\cd."), + ("ab#123", "ab#123"), + ("ab:123", "ab:123"), + ("ab;cd.", "ab;cd."), + ("ab(cd)", "ab(cd)"), + ("ab`cd.", "ab`cd."), + ("ab*cd.", "ab*cd."), + ("ab!cd.", "ab!cd."), + ("ab,cd.", "ab,cd."), + ("ab|cd.", "ab|cd."), + ("ab+cd.", "ab+cd."), + ("ab'cd.", "ab'cd."), + ("ab^cd.", "ab^cd."), + ("a[bc]d", "a[bc]d"), + ("a/b-c_", "a/b-c_"), + ("a#b:c!", "a#b:c!"), + ("a(b,c)", "a(b,c)"), + ("a|b'c.", "a|b'c."), + ("[INF]!", "[INF]!"), + ] +) +def test_valid_identifier(val: str, expected: str): + model = ATestModel(id_value=val) + assert model.id_value == expected + + +@pytest.mark.parametrize( + "val", + [ + "ab@cd", + "ab$cd", + "ab&cd", + "ab%cd", + ], +) +def test_invalid_identifier(val: str): + with pytest.raises(ValidationError): + ATestModel(id_value=val) diff --git a/tests/test_model_generation/test_utilities.py b/tests/test_model_generation/test_utilities.py new file mode 100644 index 0000000..28a09ef --- /dev/null +++ b/tests/test_model_generation/test_utilities.py @@ -0,0 +1,15 @@ +import pytest + +from dve.metadata_parser.utilities import generate_alphanumeric_type_name + +@pytest.mark.parametrize( + "mind, maxd, etype", + [ + (None, 10, "AN10"), + (10, 10, "AN10"), + (1, 10, "AN1_10") + ] +) +def test_generate_alphanumeric_type_name(mind, maxd, etype): + _type = generate_alphanumeric_type_name(maxd, mind) + assert _type == etype