Skip to content

Commit

Permalink
Give parsing abilities to datetime types (#234)
Browse files Browse the repository at this point in the history
Closes #86.
  • Loading branch information
antonagestam committed Sep 24, 2022
1 parent 1eee5f3 commit 43c9ff5
Show file tree
Hide file tree
Showing 18 changed files with 381 additions and 80 deletions.
22 changes: 19 additions & 3 deletions .github/workflows/ci.yaml
Expand Up @@ -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:
Expand Down
16 changes: 9 additions & 7 deletions .pre-commit-config.yaml
Expand Up @@ -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:
Expand Down
9 changes: 0 additions & 9 deletions Makefile
Expand Up @@ -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
23 changes: 22 additions & 1 deletion README.md
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Expand Up @@ -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",
]
6 changes: 6 additions & 0 deletions setup.cfg
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/phantom/__init__.py
Expand Up @@ -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"
Expand Down
43 changes: 2 additions & 41 deletions src/phantom/_base.py
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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")


Expand Down
54 changes: 54 additions & 0 deletions 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)
50 changes: 50 additions & 0 deletions 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):
"""
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/phantom/errors.py
@@ -0,0 +1,6 @@
class BoundError(TypeError):
...


class MissingDependency(Exception):
...
3 changes: 1 addition & 2 deletions src/phantom/ext/phonenumbers.py
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions src/phantom/iso3166.py
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 43c9ff5

Please sign in to comment.