Skip to content

Commit 3ea52a9

Browse files
fix: alphanumeric and identifier type (#136)
* fix: fix alphanumeric and identifier type
1 parent 4fdf21b commit 3ea52a9

4 files changed

Lines changed: 157 additions & 46 deletions

File tree

src/dve/metadata_parser/domain_types.py

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
from functools import lru_cache
1212
from typing import Any, ClassVar, Optional, TypeVar, Union
1313

14-
from pydantic import GetCoreSchemaHandler, types, validate_call
14+
from pydantic import GetCoreSchemaHandler, validate_call
1515
from pydantic_core import CoreSchema, core_schema
1616
from typing_extensions import Literal
1717

1818
from dve.metadata_parser import exc
19+
from dve.metadata_parser.utilities import generate_alphanumeric_type_name
1920

2021
T = TypeVar("T")
2122

@@ -674,60 +675,65 @@ def reportingperiod(
674675
return type("ReportingPeriod", (ReportingPeriod, *ReportingPeriod.__bases__), dict_)
675676

676677

677-
# TODO - refactor this as it won't work in Pyndatic V2
678+
class Alphanumeric(str):
679+
"""
680+
Alphanumeric type
681+
"""
682+
683+
ID_GROUP_STR: Optional[str] = r"[A-Za-z0-9]"
684+
MIN_DIGITS: Optional[int] = None
685+
MAX_DIGITS: Optional[int] = 1
686+
687+
@classmethod
688+
def generate_pattern_and_type(cls) -> re.Pattern:
689+
"""
690+
Generates an alphanumeric regex pattern based on user defined min/max digits.
691+
"""
692+
if (cls.MAX_DIGITS == cls.MIN_DIGITS) or (
693+
cls.MAX_DIGITS is not None and cls.MIN_DIGITS is None
694+
):
695+
pattern_str = f"{cls.ID_GROUP_STR}{{{cls.MAX_DIGITS}}}"
696+
else:
697+
pattern_str = f"{cls.ID_GROUP_STR}{{{cls.MIN_DIGITS},{cls.MAX_DIGITS}}}"
698+
return re.compile(f"^{pattern_str}$")
699+
700+
@classmethod
701+
def __get_pydantic_core_schema__(
702+
cls, source_type: Any, handler: GetCoreSchemaHandler
703+
) -> CoreSchema:
704+
"""Gets all validators"""
705+
return core_schema.str_schema(pattern=cls.generate_pattern_and_type())
706+
707+
678708
@lru_cache()
679709
@validate_call
680-
def alphanumeric(
681-
min_digits: types.NonNegativeInt = 1, # pylint: disable=E1101
682-
max_digits: types.PositiveInt = 1, # pylint: disable=E1101
683-
) -> type[_SimpleRegexValidator]:
684-
"""Return a regex-validated class which will ensure that
710+
def alphanumeric(max_digits: int = 1, min_digits: Optional[int] = None) -> type[Alphanumeric]:
711+
"""
712+
Return a regex-validated class which will ensure that
685713
passed numbers are alphanumeric.
686-
687714
"""
688-
an_group_str = r"[A-Za-z0-9]"
689-
if max_digits == min_digits:
690-
type_name = f"AN{max_digits}"
691-
pattern_str = f"{an_group_str}{{{max_digits}}}"
692-
else:
693-
type_name = f"AN{min_digits}_{max_digits}"
694-
pattern_str = f"{an_group_str}{{{min_digits},{max_digits}}}"
695-
696-
dict_ = _SimpleRegexValidator.__dict__.copy()
697-
dict_["pattern"] = re.compile(f"^{pattern_str}$")
698-
699-
return type(
700-
type_name,
701-
(_SimpleRegexValidator, *_SimpleRegexValidator.__bases__),
702-
dict_,
703-
)
715+
dict_ = Alphanumeric.__dict__.copy()
716+
dict_["MAX_DIGITS"] = max_digits
717+
dict_["MIN_DIGITS"] = min_digits
718+
719+
_type_name = generate_alphanumeric_type_name(max_digits, min_digits)
720+
721+
return type(_type_name, (Alphanumeric, *Alphanumeric.__bases__), dict_)
704722

705723

706-
# TODO - refactor this as it won't work in Pyndatic V2
707724
@lru_cache()
708725
@validate_call
709-
def identifier(
710-
min_digits: types.NonNegativeInt = 1, # pylint: disable=E1101
711-
max_digits: types.PositiveInt = 1, # pylint: disable=E1101
712-
) -> type[_SimpleRegexValidator]:
726+
def identifier(max_digits: int = 1, min_digits: Optional[int] = None) -> type[Alphanumeric]:
713727
"""
714728
Return a regex-validated class which will ensure that
715729
passed strings are alphanumeric or in a fixed set of
716730
special characters for identifiers.
717731
"""
718-
id_group_str = r"[A-Za-z0-9_\-=\/\\#:; ().`*!,|+'\^\[\]]"
719-
if max_digits == min_digits:
720-
type_name = f"AN{max_digits}"
721-
pattern_str = rf"{id_group_str}{{{max_digits}}}"
722-
else:
723-
type_name = f"AN{min_digits}_{max_digits}"
724-
pattern_str = rf"{id_group_str}{{{min_digits},{max_digits}}}"
725-
726-
dict_ = _SimpleRegexValidator.__dict__.copy()
727-
dict_["pattern"] = re.compile(f"^{pattern_str}$")
728-
729-
return type(
730-
type_name,
731-
(_SimpleRegexValidator, *_SimpleRegexValidator.__bases__),
732-
dict_,
733-
)
732+
dict_ = Alphanumeric.__dict__.copy()
733+
dict_["MAX_DIGITS"] = max_digits
734+
dict_["MIN_DIGITS"] = min_digits
735+
dict_["ID_GROUP_STR"] = r"[A-Za-z0-9_\-=\/\\#:; ().`*!,|+'\^\[\]]"
736+
737+
_type_name = generate_alphanumeric_type_name(max_digits, min_digits)
738+
739+
return type(_type_name, (Alphanumeric, *Alphanumeric.__bases__), dict_)

src/dve/metadata_parser/utilities.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Mapping
44
from types import ModuleType
5-
from typing import TYPE_CHECKING, Any, Union
5+
from typing import TYPE_CHECKING, Any, Optional, Union
66

77
from typing_extensions import Protocol
88

@@ -52,3 +52,14 @@ def chain_get(
5252
return result
5353

5454
raise exc.TypeNotFoundError(f"Callable or type ({item!r}) not found")
55+
56+
57+
def generate_alphanumeric_type_name(max_digits: int, min_digits: Optional[int]) -> str:
58+
"""Generates a dynamic alphanumeric type name based on the max digits and min digits provided"""
59+
if max_digits is None:
60+
raise ValueError("Alphanumeric type must have a max digits value defined.")
61+
62+
if (max_digits == min_digits) or min_digits is None:
63+
return f"AN{max_digits}"
64+
65+
return f"AN{min_digits}_{max_digits}"

tests/test_model_generation/test_domain_types.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class ATestModel(BaseModel):
1919
postcode: Optional[hct.Postcode] = None
2020
org_id: Optional[hct.OrgID] = None
2121
nhsnumber2: Optional[hct.permissive_nhs_number()] = None
22+
an_value: Optional[hct.alphanumeric(max_digits=6)] = None
23+
id_value: Optional[hct.identifier(max_digits=6)] = None
2224

2325

2426
class DatetimeModel(BaseModel):
@@ -400,3 +402,80 @@ def test_formattedtime_against_model(time_to_validate: str, expected_to_error: b
400402
StrictTimeModel(time_val=time_to_validate)
401403
else:
402404
StrictTimeModel(time_val=time_to_validate)
405+
406+
407+
@pytest.mark.parametrize(
408+
("val", "expected"),
409+
[
410+
("abcdef", "abcdef"),
411+
("abcDEF", "abcDEF"),
412+
("123456", "123456"),
413+
("abc123", "abc123"),
414+
],
415+
)
416+
def test_valid_alphanumeric(val: str, expected: str):
417+
model = ATestModel(an_value=val)
418+
assert model.an_value == expected
419+
420+
@pytest.mark.parametrize(
421+
"val",
422+
[
423+
"ab@cd",
424+
"ab cd",
425+
"ab.cd",
426+
"ab$cd",
427+
],
428+
)
429+
def test_invalid_alphanumeric(val: str):
430+
with pytest.raises(ValidationError):
431+
ATestModel(an_value=val)
432+
433+
434+
@pytest.mark.parametrize(
435+
("val", "expected"),
436+
[
437+
("ab-123", "ab-123"),
438+
("ab 123", "ab 123"),
439+
("ab.123", "ab.123"),
440+
("ab_123", "ab_123"),
441+
("ab-cd.", "ab-cd."),
442+
("ab=123", "ab=123"),
443+
("ab/cd.", "ab/cd."),
444+
("ab\\cd.", "ab\\cd."),
445+
("ab#123", "ab#123"),
446+
("ab:123", "ab:123"),
447+
("ab;cd.", "ab;cd."),
448+
("ab(cd)", "ab(cd)"),
449+
("ab`cd.", "ab`cd."),
450+
("ab*cd.", "ab*cd."),
451+
("ab!cd.", "ab!cd."),
452+
("ab,cd.", "ab,cd."),
453+
("ab|cd.", "ab|cd."),
454+
("ab+cd.", "ab+cd."),
455+
("ab'cd.", "ab'cd."),
456+
("ab^cd.", "ab^cd."),
457+
("a[bc]d", "a[bc]d"),
458+
("a/b-c_", "a/b-c_"),
459+
("a#b:c!", "a#b:c!"),
460+
("a(b,c)", "a(b,c)"),
461+
("a|b'c.", "a|b'c."),
462+
("[INF]!", "[INF]!"),
463+
]
464+
)
465+
def test_valid_identifier(val: str, expected: str):
466+
model = ATestModel(id_value=val)
467+
assert model.id_value == expected
468+
469+
470+
@pytest.mark.parametrize(
471+
"val",
472+
[
473+
"ab@cd",
474+
"ab$cd",
475+
"ab&cd",
476+
"ab%cd",
477+
],
478+
)
479+
def test_invalid_identifier(val: str):
480+
with pytest.raises(ValidationError):
481+
ATestModel(id_value=val)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pytest
2+
3+
from dve.metadata_parser.utilities import generate_alphanumeric_type_name
4+
5+
@pytest.mark.parametrize(
6+
"mind, maxd, etype",
7+
[
8+
(None, 10, "AN10"),
9+
(10, 10, "AN10"),
10+
(1, 10, "AN1_10")
11+
]
12+
)
13+
def test_generate_alphanumeric_type_name(mind, maxd, etype):
14+
_type = generate_alphanumeric_type_name(maxd, mind)
15+
assert _type == etype

0 commit comments

Comments
 (0)