From 1cddee1c6558d8ee78028975e7e15ef8a71c139b Mon Sep 17 00:00:00 2001 From: Nik Stuckenbrock <35262568+nikstuckenbrock@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:30:21 +0200 Subject: [PATCH] Add Semantic version type (#195) * Add Semver as a dependency * Add SemanticVersion type including tests * Increase test coverage --------- Co-authored-by: Yasser Tahiri --- pydantic_extra_types/semantic_version.py | 55 ++++++++++++++++++++++++ pyproject.toml | 2 + requirements/pyproject.txt | 2 + tests/test_json_schema.py | 10 +++++ tests/test_semantic_version.py | 23 ++++++++++ 5 files changed, 92 insertions(+) create mode 100644 pydantic_extra_types/semantic_version.py create mode 100644 tests/test_semantic_version.py diff --git a/pydantic_extra_types/semantic_version.py b/pydantic_extra_types/semantic_version.py new file mode 100644 index 00000000..f0945fd2 --- /dev/null +++ b/pydantic_extra_types/semantic_version.py @@ -0,0 +1,55 @@ +""" +SemanticVersion definition that is based on the Semantiv Versioning Specification [semver](https://semver.org/). +""" + +from typing import Any, Callable + +from pydantic import GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + +try: + import semver +except ModuleNotFoundError as e: # pragma: no cover + raise RuntimeError( + 'The `semantic_version` module requires "semver" to be installed. You can install it with "pip install semver".' + ) from e + + +class SemanticVersion: + """ + Semantic version based on the official [semver thread](https://python-semver.readthedocs.io/en/latest/advanced/combine-pydantic-and-semver.html). + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: Any, + _handler: Callable[[Any], core_schema.CoreSchema], + ) -> core_schema.CoreSchema: + def validate_from_str(value: str) -> semver.Version: + return semver.Version.parse(value) + + from_str_schema = core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(validate_from_str), + ] + ) + + return core_schema.json_or_python_schema( + json_schema=from_str_schema, + python_schema=core_schema.union_schema( + [ + core_schema.is_instance_schema(semver.Version), + from_str_schema, + ] + ), + serialization=core_schema.to_string_ser_schema(), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + return handler(core_schema.str_schema()) diff --git a/pyproject.toml b/pyproject.toml index 26f15d0e..eadf5103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,14 @@ dynamic = ['version'] all = [ 'phonenumbers>=8,<9', 'pycountry>=23', + 'semver>=3.0.2', 'python-ulid>=1,<2; python_version<"3.9"', 'python-ulid>=1,<3; python_version>="3.9"', 'pendulum>=3.0.0,<4.0.0' ] phonenumbers = ['phonenumbers>=8,<9'] pycountry = ['pycountry>=23'] +semver = ['semver>=3.0.2'] python_ulid = [ 'python-ulid>=1,<2; python_version<"3.9"', 'python-ulid>=1,<3; python_version>="3.9"', diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 1f30461b..aab552b6 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -22,6 +22,8 @@ python-dateutil==2.8.2 # time-machine python-ulid==1.1.0 # via pydantic-extra-types (pyproject.toml) +semver==3.0.2 + # via pydantic-extra-types (pyproject.toml) six==1.16.0 # via python-dateutil time-machine==2.13.0 diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 43ad9326..303098c1 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -18,6 +18,7 @@ from pydantic_extra_types.payment import PaymentCardNumber from pydantic_extra_types.pendulum_dt import DateTime from pydantic_extra_types.script_code import ISO_15924 +from pydantic_extra_types.semantic_version import SemanticVersion from pydantic_extra_types.ulid import ULID languages = [lang.alpha_3 for lang in pycountry.languages] @@ -325,6 +326,15 @@ 'type': 'object', }, ), + ( + SemanticVersion, + { + 'properties': {'x': {'title': 'X', 'type': 'string'}}, + 'required': ['x'], + 'title': 'Model', + 'type': 'object', + }, + ), ], ) def test_json_schema(cls, expected): diff --git a/tests/test_semantic_version.py b/tests/test_semantic_version.py new file mode 100644 index 00000000..dc79bef1 --- /dev/null +++ b/tests/test_semantic_version.py @@ -0,0 +1,23 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from pydantic_extra_types.semantic_version import SemanticVersion + + +@pytest.fixture(scope='module', name='SemanticVersionObject') +def application_object_fixture(): + class Application(BaseModel): + version: SemanticVersion + + return Application + + +def test_valid_semantic_version(SemanticVersionObject): + application = SemanticVersionObject(version='1.0.0') + assert application.version + assert application.model_dump() == {'version': '1.0.0'} + + +def test_invalid_semantic_version(SemanticVersionObject): + with pytest.raises(ValidationError): + SemanticVersionObject(version='Peter Maffay')