diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6bfb331..7493af8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: - id: debug-statements - id: detect-private-key - repo: https://github.com/asottile/pyupgrade - rev: v2.26.0 + rev: v2.27.0 hooks: - id: pyupgrade args: @@ -61,7 +61,7 @@ repos: - phonenumbers>=8.12.33 - pydantic - repo: https://github.com/mgedmin/check-manifest - rev: "0.46" + rev: "0.47" hooks: - id: check-manifest diff --git a/docs/index.rst b/docs/index.rst index 820c424..e0ec61a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,7 +14,7 @@ Documentation sections pages/getting-started.rst pages/types.rst pages/predicates.rst - pages/bounds.rst + pages/composing-types.rst pages/functional-composition.rst pages/external-wrappers.rst pages/pydantic-support.rst diff --git a/docs/pages/bounds.rst b/docs/pages/bounds.rst deleted file mode 100644 index 65a93e8..0000000 --- a/docs/pages/bounds.rst +++ /dev/null @@ -1,72 +0,0 @@ -Bounds -====== - -The bound of a phantom type is the type that its values will have at runtime, so when -checking if a value is an instance of a phantom type, it's first checked to be within -its bounds, so that the value can be safely passed as argument to the predicate -function of the type. - -When subclassing, the bound of the new type must be a subtype of the bound of the super -class. - -The bound of a phantom type is exposed as :attr:`phantom.Phantom.__bound__` for -introspection. - -Resolution order -~~~~~~~~~~~~~~~~ - -The bound of a phantom type is resolved in the order: explicitly by class argument, -implicitly by base classes, or implicitly inheritance, e.g.:: - - # Resolved by an explicit class arg: - class A(Phantom, bound=str, predicate=...): - ... - - # Resolved implicitly as any base classes before Phantom: - class B(str, Phantom, predicate=...): - ... - - # Resolves to str by inheritance from B: - class C(B): - ... - -Abstract bounds -^^^^^^^^^^^^^^^ - -It's sometimes useful to create base classes without specifying a bound type. To do so -the class can be made abstract by passing ``abstract=True`` as a class argument:: - - class Base(Phantom, abstract=True): - ... - - class Concrete(str, Base): - ... - -This is for instance used by the shipped -:ref:`numeric interval types `. - -Bound erasure -^^^^^^^^^^^^^ - -If a phantom type doesn't properly specify its bounds, in addition to risking passing -invalid arguments to its predicate function, it is also likely that a static type -checker might inadvertently erase the runtime type when type guarding. - -As an example, this code will error on the access to ``dt.year`` because -``UTCDateTime.parse()`` has made the type checker erase the knowledge that dt is a -``datetime``. - -:: - - class UTCDateTime(Phantom, predicate=is_utc): - ... - - dt = UTCDateTime.parse(now()) - dt.year # Error! - -In this example we could remedy this by adding ``datetime`` as a base class and bound. - -:: - - class UTCDateTime(datetime.datetime, Phantom, predicate=is_utc): - ... diff --git a/docs/pages/composing-types.rst b/docs/pages/composing-types.rst new file mode 100644 index 0000000..9123894 --- /dev/null +++ b/docs/pages/composing-types.rst @@ -0,0 +1,134 @@ +Composing types +*************** + +Bounds +====== + +The bound of a phantom type is the type that its values will have at runtime, so when +checking if a value is an instance of a phantom type, it's first checked to be within +its bounds, so that the value can be safely passed as argument to the predicate +function of the type. + +When subclassing, the bound of the new type must be a subtype of the bound of the super +class. + +The bound of a phantom type is exposed as :attr:`phantom.Phantom.__bound__` for +introspection. + +Resolution order +~~~~~~~~~~~~~~~~ + +The bound of a phantom type is resolved in the order: explicitly by class argument, +implicitly by base classes, or implicitly inheritance, e.g.: + +.. code-block:: python + + # Resolved by an explicit class arg: + class A(Phantom, bound=str, predicate=...): + ... + + + # Resolved implicitly as any base classes before Phantom: + class B(str, Phantom, predicate=...): + ... + + + # Resolves to str by inheritance from B: + class C(B): + ... + +Abstract bounds +~~~~~~~~~~~~~~~ + +It's sometimes useful to create base classes without specifying a bound type. To do so +the class can be made abstract by passing ``abstract=True`` as a class argument: + +.. code-block:: python + + class Base(Phantom, abstract=True): + ... + + + class Concrete(str, Base): + ... + +This is for instance used by the shipped +:ref:`numeric interval types `. + +Bound erasure +~~~~~~~~~~~~~ + +If a phantom type doesn't properly specify its bounds, in addition to risking passing +invalid arguments to its predicate function, it is also likely that a static type +checker might inadvertently erase the runtime type when type guarding. + +As an example, this code will error on the access to ``dt.year`` because +``UTCDateTime.parse()`` has made the type checker erase the knowledge that dt is a +``datetime``. + +.. code-block:: python + + class UTCDateTime(Phantom, predicate=is_utc): + ... + + + dt = UTCDateTime.parse(now()) + dt.year # Error! + +In this example we could remedy this by adding ``datetime`` as a base class and bound. + +.. code-block:: python + + class UTCDateTime(datetime.datetime, Phantom, predicate=is_utc): + ... + +Mutability +========== + +Phantom types are completely incompatible with mutable data and should never be used to +narrow a mutable type. The reason is that there is no way for a type checker to detect +that a mutation changes an object to no longer satisfy the predicate of a phantom +type. For example: + +.. code-block:: python + + # A phantom type that checks that a list has more than 2 items. + class HasMany(list, Phantom, predicate=count(greater(2))): + ... + + + # The check will pass because the list *currently* has 3 items in it. + instance = HasMany.parse([1, 2, 3]) + + # But! Lists are mutable, so nothing is stopping us from removing an item. At this + # point the list will only have 2 items and won't satisfy the predicate of the + # HasMany type anymore. + del instance[-1] + + # There is no way for a type checker to now that the predicate isn't fulfilled + # anymore, so the revealed type here will still be HasMany. + reveal_type(instance) # Revealed type is HasMany + +In some cases phantom-types tries to be smart and disallow using mutable types as +bounds, but in the general case this isn't possible to detect and so it's up to you as a +developer to make sure to not mix mutable data with phantom types. + +Metaclass conflicts +=================== + +Phantom types are implemented using a metaclass. When creating a phantom type that +narrows on a type that also uses a metaclass it's common to stumble into a metaclass +conflict. The usual solution to such situation is to create a new metaclass that +inherits both existing metaclasses and base the new type on it. + +.. code-block:: python + + from phantom import PhantomMeta + + + class NewMeta(PhantomMeta, OldMeta): + ... + + + class New(Old, Phantom, metaclass=NewMeta): + ... diff --git a/docs/pages/pydantic-support.rst b/docs/pages/pydantic-support.rst index d93d171..a3f3d63 100644 --- a/docs/pages/pydantic-support.rst +++ b/docs/pages/pydantic-support.rst @@ -9,11 +9,14 @@ implements full JSON Schema and OpenAPI support. .. _pydantic: https://pydantic-docs.helpmanual.io/ To make a phantom type compatible with pydantic, all you need to do is override -:func:`Phantom.__schema__() `:: +:func:`Phantom.__schema__() `: + +.. code-block:: python from phantom import Phantom from phantom.schema import Schema + class Name(str, Phantom, predicate=...): @classmethod def __schema__(cls) -> Schema: @@ -33,7 +36,9 @@ Sized containers are currently only partially supported. Their validation is acc but their schemas aren't propagating their inner type. This likely won't be possible to support until pydantic exposes its ``ModelField`` to ``__modify_schema__``. To work around this subclasses of :class:`PhantomSized ` can specify -``"items"`` like so:: +``"items"`` like so: + +.. code-block:: python class LimitedSize(PhantomSized[int], len=numeric.greater(10)): @classmethod diff --git a/src/phantom/__init__.py b/src/phantom/__init__.py index e40ac03..81557dd 100644 --- a/src/phantom/__init__.py +++ b/src/phantom/__init__.py @@ -1,7 +1,7 @@ """ Use ``Phantom`` to create arbitrary phantom types using boolean predicates. -:: +.. code-block:: python import phantom diff --git a/src/phantom/interval.py b/src/phantom/interval.py index a15c28f..7d1adca 100644 --- a/src/phantom/interval.py +++ b/src/phantom/interval.py @@ -2,7 +2,9 @@ Types for describing narrower sets of numbers than builtin numeric types like ``int`` and ``float``. Use the provided base classes to build custom intervals. For example, to represent number in the open range ``(0, 100)`` for a volume control you would define a -type like this:: +type like this: + +.. code-block:: python class VolumeLevel(int, Open, low=0, high=100): ... @@ -10,7 +12,7 @@ class VolumeLevel(int, Open, low=0, high=100): There is also a set of concrete ready-to-use interval types provided, that use predicate functions from :py:mod:`phantom.predicates.interval`. -:: +.. code-block:: python def take_portion(portion: Portion, whole: Natural) -> float: return portion * whole diff --git a/src/phantom/iso3166.py b/src/phantom/iso3166.py index b7f56d2..4722d6a 100644 --- a/src/phantom/iso3166.py +++ b/src/phantom/iso3166.py @@ -2,7 +2,9 @@ Exposes a :py:class:`CountryCode` type that is a union of a :py:class:`Literal` containing all ISO3166 alpha-2 codes, and a phantom type that parses alpha-2 codes at runtime. This allows mixing statically known values with runtime-parsed values, like -so:: +so: + +.. code-block:: python countries: tuple[CountryCode] = ("SE", "DK", ParsedAlpha2.parse("FR")) """ diff --git a/src/phantom/re.py b/src/phantom/re.py index cc83896..21f0b2a 100644 --- a/src/phantom/re.py +++ b/src/phantom/re.py @@ -1,11 +1,12 @@ """ Types for representing strings that match a pattern. -:: +.. code-block:: python class Greeting(Match, pattern=r"^(Hi|Hello)"): ... + assert isinstance("Hello Jane!", Greeting) """ from __future__ import annotations diff --git a/src/phantom/schema.py b/src/phantom/schema.py index 7daa788..6429077 100644 --- a/src/phantom/schema.py +++ b/src/phantom/schema.py @@ -41,7 +41,9 @@ def __schema__(cls) -> Schema: for more information. This hook differs to pydantic's ``__modify_schema__()`` and expects subclasses to instantiate new dicts instead of mutating a given one. - Example:: + Example: + + .. code-block:: python class Name(str, Phantom, predicate=...): @classmethod diff --git a/src/phantom/sized.py b/src/phantom/sized.py index 0debd28..c79c1bd 100644 --- a/src/phantom/sized.py +++ b/src/phantom/sized.py @@ -8,7 +8,9 @@ will be called with the size of the tested collection. For instance, ``NonEmpty`` is implemented using ``len=numeric.greater(0)``. -This made-up type would describe sized collections with between 5 and 10 ints:: +This made-up type would describe sized collections with between 5 and 10 ints: + +.. code-block:: python class SpecificSize(PhantomSized[int], len=interval.open(5, 10)): ...