Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 51 additions & 45 deletions src/dve/metadata_parser/domain_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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_)
13 changes: 12 additions & 1 deletion src/dve/metadata_parser/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}"
79 changes: 79 additions & 0 deletions tests/test_model_generation/test_domain_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
15 changes: 15 additions & 0 deletions tests/test_model_generation/test_utilities.py
Original file line number Diff line number Diff line change
@@ -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
Loading