diff --git a/docs/usage.rst b/docs/usage.rst index eb4cc25b..08dc534f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -787,3 +787,18 @@ the original class: Traceback (most recent call last): ... ValueError: '1.2.4': not a valid semantic version tag. Must start with 'v' or 'V' + +Integrating with Pydantic +------------------------------------- + +If you are building a Pydantic model, you can use :class:`~semver.version.Version` directly. +An appropriate :class:`pydantic.ValidationError` will be raised for invalid version numbers. + +.. code-block:: python + + >>> from semver.version import Version + >>> from pydantic import create_model + >>> Model = create_model('Model', version=(Version, ...)) + >>> model = Model(version="3.4.5-pre.2+build.4") + >>> model.version + Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') diff --git a/src/semver/version.py b/src/semver/version.py index 9e02544f..2377eda6 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -1,28 +1,22 @@ """Version handling.""" -import collections import re -from functools import wraps +import collections from typing import ( Any, Dict, - Iterable, - Optional, - SupportsInt, Tuple, Union, - cast, Callable, + Iterable, + Optional, Collection, + SupportsInt, + cast, ) +from functools import wraps -from ._types import ( - VersionTuple, - VersionDict, - VersionIterator, - String, - VersionPart, -) +from ._types import String, VersionDict, VersionPart, VersionTuple, VersionIterator # These types are required here because of circular imports Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], str] @@ -552,6 +546,18 @@ def match(self, match_expr: str) -> bool: return cmp_res in possibilities + @classmethod + def __get_validators__(cls): + """Return a list of validator methods for pydantic models.""" + + yield cls.parse + + @classmethod + def __modify_schema__(cls, field_schema): + """Inject/mutate the pydantic field schema in-place.""" + + field_schema.update(examples=["1.0.2", "2.15.3-alpha", "21.3.15-beta+12345"]) + @classmethod def parse(cls, version: String) -> "Version": """ diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py new file mode 100644 index 00000000..941d8099 --- /dev/null +++ b/tests/test_pydantic.py @@ -0,0 +1,109 @@ +import pytest +import pydantic + +from semver import Version + + +class Schema(pydantic.BaseModel): + """An example schema which contains a semver Version object""" + + name: str + """ Other data which isn't important """ + version: Version + """ Version number auto-parsed by Pydantic """ + + +@pytest.mark.parametrize( + "version,expected", + [ + # no. 1 + ( + "1.2.3-alpha.1.2+build.11.e0f985a", + { + "major": 1, + "minor": 2, + "patch": 3, + "prerelease": "alpha.1.2", + "build": "build.11.e0f985a", + }, + ), + # no. 2 + ( + "1.2.3-alpha-1+build.11.e0f985a", + { + "major": 1, + "minor": 2, + "patch": 3, + "prerelease": "alpha-1", + "build": "build.11.e0f985a", + }, + ), + ( + "0.1.0-0f", + {"major": 0, "minor": 1, "patch": 0, "prerelease": "0f", "build": None}, + ), + ( + "0.0.0-0foo.1", + {"major": 0, "minor": 0, "patch": 0, "prerelease": "0foo.1", "build": None}, + ), + ( + "0.0.0-0foo.1+build.1", + { + "major": 0, + "minor": 0, + "patch": 0, + "prerelease": "0foo.1", + "build": "build.1", + }, + ), + ], +) +def test_should_parse_version(version, expected): + result = Schema(name="test", version=version) + assert result.version == expected + + +@pytest.mark.parametrize( + "version,expected", + [ + # no. 1 + ( + "1.2.3-rc.0+build.0", + { + "major": 1, + "minor": 2, + "patch": 3, + "prerelease": "rc.0", + "build": "build.0", + }, + ), + # no. 2 + ( + "1.2.3-rc.0.0+build.0", + { + "major": 1, + "minor": 2, + "patch": 3, + "prerelease": "rc.0.0", + "build": "build.0", + }, + ), + ], +) +def test_should_parse_zero_prerelease(version, expected): + result = Schema(name="test", version=version) + assert result.version == expected + + +@pytest.mark.parametrize("version", ["01.2.3", "1.02.3", "1.2.03"]) +def test_should_raise_value_error_for_zero_prefixed_versions(version): + with pytest.raises(pydantic.ValidationError): + Schema(name="test", version=version) + + +def test_should_have_schema_examples(): + assert Schema.schema()["properties"]["version"]["examples"] == [ + "1.0.2", + "2.15.3-alpha", + "21.3.15-beta+12345", + ] diff --git a/tox.ini b/tox.ini index ce566562..5479fd44 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,7 @@ commands = pytest {posargs:} deps = pytest pytest-cov + pydantic setenv = PIP_DISABLE_PIP_VERSION_CHECK = 1