From 43c9ff5d35dd5144e16827d0d2fdaac31cbe4cd1 Mon Sep 17 00:00:00 2001 From: Anton Agestam Date: Sat, 24 Sep 2022 21:04:03 +0300 Subject: [PATCH] Give parsing abilities to datetime types (#234) Closes #86. --- .github/workflows/ci.yaml | 22 ++- .pre-commit-config.yaml | 16 +- Makefile | 9 - README.md | 23 ++- pyproject.toml | 4 + setup.cfg | 6 + src/phantom/__init__.py | 4 +- src/phantom/_base.py | 43 +---- src/phantom/bounds.py | 54 ++++++ src/phantom/datetime.py | 50 ++++++ src/phantom/errors.py | 6 + src/phantom/ext/phonenumbers.py | 3 +- src/phantom/iso3166.py | 3 +- tests/ext/test_phonenumbers.py | 4 +- tests/pydantic/test_datetime.py | 44 +++++ ...st_pydantic_schemas.py => test_schemas.py} | 2 + tests/test_base.py | 4 +- tests/test_datetime.py | 164 ++++++++++++++++-- 18 files changed, 381 insertions(+), 80 deletions(-) create mode 100644 src/phantom/bounds.py create mode 100644 src/phantom/errors.py create mode 100644 tests/pydantic/test_datetime.py rename tests/pydantic/{test_pydantic_schemas.py => test_schemas.py} (99%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30cab1c..80c569e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,9 +48,25 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: setup.cfg - - run: pip install -e '.[phonenumbers,pydantic,test]' - - run: make coverage - - run: make coverage-report + - name: Install minimum requirements + run: pip install --upgrade -e '.[test]' + - name: Run all tests that don't require extra dependencies + run: >- + coverage run --append -m pytest + -m "no_external or not external" + --ignore=src/phantom/ext + --ignore=tests/pydantic + --ignore=tests/ext + - name: Install extra requirements + run: pip install --upgrade -e '.[all,test]' + - name: Run all tests that require extra dependencies + run: >- + coverage run --append -m pytest + -m "external" + - name: Collect coverage + run: | + coverage report + coverage xml - name: Report coverage uses: codecov/codecov-action@v3 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07c05ed..d540856 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,14 +60,16 @@ repos: hooks: - id: mypy exclude: ^examples/ + pass_filenames: false additional_dependencies: - - typing-extensions - - pytest - - typeguard - - phonenumbers>=8.12.41 - - pydantic - - types-setuptools - - numerary + - typing-extensions==4.3.0 + - pytest==7.1.3 + - typeguard==2.13.3 + - phonenumbers==8.12.56 + - pydantic==1.10.2 + - types-setuptools==65.3.0 + - numerary==0.4.2 + - types-python-dateutil==2.8.19 - repo: https://github.com/pre-commit/mirrors-prettier rev: "v2.7.1" hooks: diff --git a/Makefile b/Makefile index 22ee000..462e78c 100644 --- a/Makefile +++ b/Makefile @@ -43,15 +43,6 @@ test-typeguard: test-typing: pytest $(test) -k.yaml -.PHONY: coverage -coverage: - @coverage run -m pytest $(test) - -.PHONY: coverage-report -coverage-report: - @coverage report - @coverage xml - .PHONY: clean clean: rm -rf {**/,}*.egg-info **{/**,}/__pycache__ build dist .coverage coverage.xml diff --git a/README.md b/README.md index d99e4b3..7822d40 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,27 @@ avoid shotgun parsing by enabling you to practice ["Parse, don't validate"][pars $ python3 -m pip install phantom-types ``` +#### Extras + +- `phantom-types[dateutil]` installs [python-dateutil]. Required to use [`TZAware` and + `TZNaive`][phantom-datetime] for parsing strings. +- `phantom-types[phonenumbers]` installs [phonenumbers]. Required to use + [`phantom.ext.phonenumbers`][phantom-phonenumbers]. +- `phantom-types[pydantic]` installs [pydantic]. +- `phantom-types[all]` installs all of the above. + +[python-dateutil]: https://pypi.org/project/python-dateutil/ +[phonenumbers]: https://pypi.org/project/phonenumbers/ +[pydantic]: https://pypi.org/project/pydantic/ +[phantom-datetime]: + https://phantom-types.readthedocs.io/en/main/pages/types.html#module-phantom.datetime +[phantom-phonenumbers]: + https://phantom-types.readthedocs.io/en/main/pages/external-wrappers.html#module-phantom.ext.phonenumbers + +```bash +$ python3 -m pip install phantom-types[all] +``` + ## Examples By introducing a phantom type we can define a pre-condition for a function argument. @@ -133,7 +154,7 @@ The code above outputs the following JSONSchema. Install development requirements, preferably in a virtualenv: ```bash -$ python3 -m pip install .[test,pydantic,phonenumbers] +$ python3 -m pip install .[all,test] ``` Run tests: diff --git a/pyproject.toml b/pyproject.toml index 83d4da0..c4165e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,7 @@ target-version = ["py38"] norecursedirs = ["examples"] testpaths = ["tests", "src", "docs"] addopts = "--mypy-ini-file=setup.cfg --mypy-only-local-stub --doctest-modules" +markers = [ + "external: mark tests that require extra dependencies", + "no_external: mark tests that will fail if run with extra dependencies", +] diff --git a/setup.cfg b/setup.cfg index 5f71dac..d72e92d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,12 @@ phonenumbers = phonenumbers>=8.12.41 pydantic = pydantic>=1.9.0 +dateutil = + python-dateutil>=2.8.2 +all = + phantom-types[phonenumbers] + phantom-types[pydantic] + phantom-types[dateutil] test = mypy>=0.930 pytest diff --git a/src/phantom/__init__.py b/src/phantom/__init__.py index 9848199..fec91bb 100644 --- a/src/phantom/__init__.py +++ b/src/phantom/__init__.py @@ -16,11 +16,11 @@ class Big(int, Phantom, predicate=is_big): assert isinstance(10, Big) # this passes """ -from ._base import BoundError from ._base import Phantom from ._base import PhantomBase from ._base import PhantomMeta -from ._base import get_bound_parser +from .bounds import get_bound_parser +from .errors import BoundError from .predicates import Predicate __version__ = "0.18.0" diff --git a/src/phantom/_base.py b/src/phantom/_base.py index 646a94c..4451b41 100644 --- a/src/phantom/_base.py +++ b/src/phantom/_base.py @@ -7,12 +7,9 @@ from typing import Generic from typing import Iterable from typing import Iterator -from typing import Sequence from typing import TypeVar -from typing import cast from typing_extensions import Protocol -from typing_extensions import get_args from typing_extensions import runtime_checkable from ._utils.misc import BoundType @@ -21,12 +18,10 @@ from ._utils.misc import fully_qualified_name from ._utils.misc import is_not_known_mutable_type from ._utils.misc import is_subtype -from ._utils.misc import is_union from ._utils.misc import resolve_class_attr +from .bounds import get_bound_parser +from .errors import BoundError from .predicates import Predicate -from .predicates.boolean import all_of -from .predicates.generic import of_complex_type -from .predicates.generic import of_type from .schema import SchemaField @@ -53,43 +48,9 @@ def __call__(cls, instance): # type: ignore[no-untyped-def] return cls.parse(instance) # type: ignore[attr-defined] -class BoundError(TypeError): - ... - - T = TypeVar("T", covariant=True) -def display_bound(bound: Any) -> str: - if isinstance(bound, Iterable): - return f"Intersection[{', '.join(display_bound(part) for part in bound)}]" - if is_union(bound): - return ( - f"typing.Union[" - f"{', '.join(display_bound(part) for part in get_args(bound))}" - f"]" - ) - return str(getattr(bound, "__name__", bound)) - - -def get_bound_parser(bound: Any) -> Callable[[object], T]: - within_bound = ( - # Interpret sequence as intersection - all_of(of_type(t) for t in bound) - if isinstance(bound, Sequence) - else of_complex_type(bound) - ) - - def parser(instance: object) -> T: - if not within_bound(instance): - raise BoundError( - f"Value is not within bound of {display_bound(bound)!r}: {instance!r}" - ) - return cast(T, instance) - - return parser - - Derived = TypeVar("Derived", bound="PhantomBase") diff --git a/src/phantom/bounds.py b/src/phantom/bounds.py new file mode 100644 index 0000000..23b5b9a --- /dev/null +++ b/src/phantom/bounds.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Sequence +from typing import TypeVar +from typing import cast + +from typing_extensions import Final +from typing_extensions import get_args + +from ._utils.misc import is_union +from .errors import BoundError +from .predicates.boolean import all_of +from .predicates.generic import of_complex_type +from .predicates.generic import of_type + +__all__ = ("get_bound_parser", "parse_str") + +T = TypeVar("T", covariant=True) + + +def display_bound(bound: Any) -> str: + if isinstance(bound, Iterable): + return f"Intersection[{', '.join(display_bound(part) for part in bound)}]" + if is_union(bound): + return ( + f"typing.Union[" + f"{', '.join(display_bound(part) for part in get_args(bound))}" + f"]" + ) + return str(getattr(bound, "__name__", bound)) + + +def get_bound_parser(bound: Any) -> Callable[[object], T]: + within_bound = ( + # Interpret sequence as intersection + all_of(of_type(t) for t in bound) + if isinstance(bound, Sequence) + else of_complex_type(bound) + ) + + def parser(instance: object) -> T: + if not within_bound(instance): + raise BoundError( + f"Value is not within bound of {display_bound(bound)!r}: {instance!r}" + ) + return cast(T, instance) + + return parser + + +parse_str: Final[Callable[[object], str]] = get_bound_parser(str) diff --git a/src/phantom/datetime.py b/src/phantom/datetime.py index 150dbea..a6d1f88 100644 --- a/src/phantom/datetime.py +++ b/src/phantom/datetime.py @@ -1,14 +1,56 @@ """ Types for narrowing on the builtin datetime types. + +These types can be used without installing any extra dependencies, however, to parse +strings, python-dateutil must be installed or a +:py:class:`phantom.errors.MissingDependency` error will be raised when calling parse. + +You can install python-dateutil by using the ``[dateutil]`` or ``[all]`` extras. """ +from __future__ import annotations import datetime from . import Phantom +from .bounds import parse_str +from .errors import MissingDependency from .predicates.datetime import is_tz_aware from .predicates.datetime import is_tz_naive from .schema import Schema +try: + import dateutil.parser + + parse_datetime_str = dateutil.parser.parse + DateutilParseError = dateutil.parser.ParserError +except ImportError as e: + exception = e + + def parse_datetime_str( + *_: object, + **__: object, + ) -> datetime.datetime: + raise MissingDependency( + "python-dateutil needs to be installed to use this type for parsing. It " + "can be installed with the phantom-types[dateutil] extra." + ) from exception + + class DateutilParseError(Exception): # type: ignore[no-redef] + ... + + +__all__ = ("TZAware", "TZNaive") + + +def parse_datetime(value: object) -> datetime.datetime: + if isinstance(value, datetime.datetime): + return value + str_value = parse_str(value) + try: + return parse_datetime_str(str_value) + except DateutilParseError as exc: + raise TypeError("Could not parse datetime from given string") from exc + class TZAware(datetime.datetime, Phantom, predicate=is_tz_aware): """ @@ -24,6 +66,10 @@ class TZAware(datetime.datetime, Phantom, predicate=is_tz_aware): # attribute to not include None. tzinfo: datetime.tzinfo + @classmethod + def parse(cls, instance: object) -> TZAware: + return super().parse(parse_datetime(instance)) + @classmethod def __schema__(cls) -> Schema: return { @@ -40,6 +86,10 @@ class TZNaive(datetime.datetime, Phantom, predicate=is_tz_naive): False """ + @classmethod + def parse(cls, instance: object) -> TZNaive: + return super().parse(parse_datetime(instance)) + @classmethod def __schema__(cls) -> Schema: return { diff --git a/src/phantom/errors.py b/src/phantom/errors.py new file mode 100644 index 0000000..0e64e5a --- /dev/null +++ b/src/phantom/errors.py @@ -0,0 +1,6 @@ +class BoundError(TypeError): + ... + + +class MissingDependency(Exception): + ... diff --git a/src/phantom/ext/phonenumbers.py b/src/phantom/ext/phonenumbers.py index be8bf5a..48bb99c 100644 --- a/src/phantom/ext/phonenumbers.py +++ b/src/phantom/ext/phonenumbers.py @@ -15,7 +15,7 @@ from typing_extensions import Final from phantom import Phantom -from phantom import get_bound_parser +from phantom.bounds import parse_str from phantom.fn import excepts from phantom.schema import Schema @@ -64,7 +64,6 @@ def normalize_phone_number( is_phone_number = excepts(InvalidPhoneNumber)(_deconstruct_phone_number) -parse_str = get_bound_parser(str) def is_formatted_phone_number(number: str) -> bool: diff --git a/src/phantom/iso3166.py b/src/phantom/iso3166.py index 4722d6a..03ce4e0 100644 --- a/src/phantom/iso3166.py +++ b/src/phantom/iso3166.py @@ -18,7 +18,7 @@ from typing_extensions import get_args from phantom import Phantom -from phantom import get_bound_parser +from phantom.bounds import parse_str from phantom.predicates.collection import contained from phantom.schema import Schema @@ -288,7 +288,6 @@ ALPHA2: Final = frozenset(get_args(LiteralAlpha2)) is_alpha2_country_code = contained(ALPHA2) -parse_str = get_bound_parser(str) class InvalidCountryCode(TypeError): diff --git a/tests/ext/test_phonenumbers.py b/tests/ext/test_phonenumbers.py index 17da0f7..9602559 100644 --- a/tests/ext/test_phonenumbers.py +++ b/tests/ext/test_phonenumbers.py @@ -1,6 +1,6 @@ import pytest -from phantom import BoundError +from phantom.errors import BoundError from phantom.ext.phonenumbers import FormattedPhoneNumber from phantom.ext.phonenumbers import InvalidPhoneNumber from phantom.ext.phonenumbers import PhoneNumber @@ -9,6 +9,8 @@ from phantom.ext.phonenumbers import is_phone_number from phantom.ext.phonenumbers import normalize_phone_number +pytestmark = [pytest.mark.external] + class TestPhoneNumber: def test_unparsable_number_is_not_instance(self): diff --git a/tests/pydantic/test_datetime.py b/tests/pydantic/test_datetime.py new file mode 100644 index 0000000..c85179c --- /dev/null +++ b/tests/pydantic/test_datetime.py @@ -0,0 +1,44 @@ +import datetime + +import pytest + +import pydantic +from phantom.datetime import TZAware +from phantom.datetime import TZNaive +from pydantic import ValidationError +from tests.test_datetime import parametrize_aware_str +from tests.test_datetime import parametrize_naive_str + +pytestmark = [pytest.mark.external] + + +class HasTZAware(pydantic.BaseModel): + created_at: TZAware + + +class TestPydanticTZAware: + @parametrize_aware_str + def test_can_parse_tz_aware(self, value: str, expected: datetime.datetime): + object = HasTZAware.parse_obj({"created_at": value}) + assert type(object.created_at) is datetime.datetime + assert object.created_at == expected + + def test_tz_aware_rejects_naive_datetime(self): + with pytest.raises(ValidationError): + HasTZAware.parse_obj({"created_at": "2022-09-24T10:40:20"}) + + +class HasTZNaive(pydantic.BaseModel): + time_of_day: TZNaive + + +class TestPydanticTZNaive: + @parametrize_naive_str + def test_can_parse_tz_naive(self, value: str, expected: datetime.datetime): + object = HasTZNaive.parse_obj({"time_of_day": value}) + assert type(object.time_of_day) is datetime.datetime + assert object.time_of_day == expected + + def test_tz_naive_rejects_aware_datetime(self): + with pytest.raises(ValidationError): + HasTZNaive.parse_obj({"time_of_day": "2022-09-24T10:40:20+00:00"}) diff --git a/tests/pydantic/test_pydantic_schemas.py b/tests/pydantic/test_schemas.py similarity index 99% rename from tests/pydantic/test_pydantic_schemas.py rename to tests/pydantic/test_schemas.py index 8eba1b7..ae66aab 100644 --- a/tests/pydantic/test_pydantic_schemas.py +++ b/tests/pydantic/test_schemas.py @@ -24,6 +24,8 @@ from phantom.sized import NonEmptyStr from phantom.sized import PhantomSized +pytestmark = [pytest.mark.external] + class OpenType(int, Open, low=0, high=100): ... diff --git a/tests/test_base.py b/tests/test_base.py index 57a78e0..a339a76 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -5,13 +5,13 @@ import pytest -from phantom import BoundError from phantom import Phantom from phantom import PhantomMeta -from phantom import get_bound_parser from phantom._base import AbstractInstanceCheck from phantom._base import MutableType from phantom._utils.misc import UnresolvedClassAttribute +from phantom.bounds import get_bound_parser +from phantom.errors import BoundError from phantom.predicates import boolean from phantom.predicates.numeric import positive diff --git a/tests/test_datetime.py b/tests/test_datetime.py index 48a7076..944aae3 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -2,50 +2,194 @@ import pytest +from phantom import BoundError from phantom.datetime import TZAware from phantom.datetime import TZNaive +from phantom.errors import MissingDependency parametrize_aware = pytest.mark.parametrize( - "dt", (datetime.datetime.now(tz=datetime.timezone.utc),) + "dt", + ( + datetime.datetime.now(tz=datetime.timezone.utc), + datetime.datetime(1969, 12, 23, tzinfo=datetime.timezone.utc), + datetime.datetime.min.replace(tzinfo=datetime.timezone.utc), + datetime.datetime.max.replace(tzinfo=datetime.timezone.utc), + ), ) parametrize_naive = pytest.mark.parametrize( - "dt", (datetime.datetime.now(), datetime.datetime(1969, 12, 23)) + "dt", + ( + datetime.datetime.now(), + datetime.datetime(1969, 12, 23), + datetime.datetime.min, + datetime.datetime.max, + ), +) +parametrize_invalid_type = pytest.mark.parametrize( + "value", + ( + object(), + datetime.time(), + datetime.timedelta(), + 1, + 1.1, + (), + # https://github.com/pydantic/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh + float("inf"), + float("-inf"), + ), +) +parametrize_invalid_str = pytest.mark.parametrize( + "value", + ( + # https://github.com/pydantic/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh + "infinity", + "inf", + "-infinity", + "-inf", + "2022-13-24", + "20222-12-24", + "2022-12-32", + "foo", + ), +) +min_utc = datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) +max_utc = datetime.datetime.max.replace(tzinfo=datetime.timezone.utc) +parametrize_aware_str = pytest.mark.parametrize( + "value, expected", + [ + (min_utc.isoformat(), min_utc), + (max_utc.isoformat(), max_utc), + ( + "2022-09-24T10:40:20+00:00", + datetime.datetime(2022, 9, 24, 10, 40, 20, 0, tzinfo=datetime.timezone.utc), + ), + ( + "2022-09-24T10:40:20.779388+00:00", + datetime.datetime( + 2022, 9, 24, 10, 40, 20, 779388, tzinfo=datetime.timezone.utc + ), + ), + ], +) +parametrize_naive_str = pytest.mark.parametrize( + "value, expected", + [ + (datetime.datetime.min.isoformat(), datetime.datetime.min), + (datetime.datetime.max.isoformat(), datetime.datetime.max), + ( + "2022-09-24T10:40:20", + datetime.datetime(2022, 9, 24, 10, 40, 20, 0), + ), + ( + "2022-09-24T10:40:20.779388", + datetime.datetime(2022, 9, 24, 10, 40, 20, 779388), + ), + ], ) class TestTZAware: @parametrize_aware - def test_aware_datetime_is_instance(self, dt): + def test_aware_datetime_is_instance(self, dt: datetime.datetime): assert isinstance(dt, TZAware) @parametrize_naive - def test_naive_datetime_is_not_instance(self, dt): + def test_naive_datetime_is_not_instance(self, dt: datetime.datetime): assert not isinstance(dt, TZAware) @parametrize_naive - def test_instantiation_raises_for_naive_datetime(self, dt): + def test_instantiation_raises_for_naive_datetime_instance( + self, dt: datetime.datetime + ): with pytest.raises(TypeError): TZAware.parse(dt) @parametrize_aware - def test_instantiation_returns_instance(self, dt): + def test_instantiation_returns_instance(self, dt: datetime.datetime): assert dt is TZAware.parse(dt) + @parametrize_invalid_type + def test_parse_rejects_non_str_object(self, value: object): + with pytest.raises(BoundError): + TZAware.parse(value) + + @pytest.mark.external + @parametrize_invalid_str + def test_parse_rejects_invalid_str(self, value: object): + with pytest.raises(TypeError): + TZAware.parse(value) + + @pytest.mark.external + @parametrize_naive_str + def test_parse_rejects_naive_str(self, value: str, expected: datetime.datetime): + with pytest.raises(TypeError): + TZAware.parse(value) + + @pytest.mark.external + @parametrize_aware_str + def test_can_parse_valid_str(self, value: str, expected: datetime.datetime): + assert TZAware.parse(value) == expected + + @pytest.mark.no_external + @parametrize_aware_str + def test_parse_str_without_dateutil_raises_missing_dependency( + self, + value: str, + expected: datetime.datetime, + ): + with pytest.raises(MissingDependency): + TZAware.parse(value) + class TestTZNaive: @parametrize_naive - def test_naive_datetime_is_instance(self, dt): + def test_naive_datetime_is_instance(self, dt: datetime.datetime): assert isinstance(dt, TZNaive) @parametrize_aware - def test_aware_datetime_is_not_instance(self, dt): + def test_aware_datetime_is_not_instance(self, dt: datetime.datetime): assert not isinstance(dt, TZNaive) @parametrize_aware - def test_instantiation_raises_for_aware_datetime(self, dt): + def test_instantiation_raises_for_aware_datetime_instance( + self, dt: datetime.datetime + ): with pytest.raises(TypeError): TZNaive.parse(dt) @parametrize_naive - def test_instantiation_returns_instance(self, dt): + def test_instantiation_returns_instance(self, dt: datetime.datetime): assert dt is TZNaive.parse(dt) + + @parametrize_invalid_type + def test_parse_rejects_non_str_object(self, value: object): + with pytest.raises(BoundError): + TZNaive.parse(value) + + @pytest.mark.external + @parametrize_invalid_str + def test_parse_rejects_invalid_str(self, value: object): + with pytest.raises(TypeError): + TZNaive.parse(value) + + @pytest.mark.external + @parametrize_aware_str + def test_parse_rejects_aware_str(self, value: str, expected: datetime.datetime): + with pytest.raises(TypeError): + TZNaive.parse(value) + + @pytest.mark.external + @parametrize_naive_str + def test_can_parse_valid_str(self, value: str, expected: datetime.datetime): + assert TZNaive.parse(value) == expected + + @pytest.mark.no_external + @parametrize_naive_str + def test_parse_str_without_dateutil_raises_missing_dependency( + self, + value: str, + expected: datetime.datetime, + ): + with pytest.raises(MissingDependency): + TZNaive.parse(value)