Skip to content

Commit

Permalink
Document mutability and metaclass interactions (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
antonagestam authored Sep 25, 2021
1 parent f84b3be commit 9e169b9
Show file tree
Hide file tree
Showing 11 changed files with 160 additions and 84 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 0 additions & 72 deletions docs/pages/bounds.rst

This file was deleted.

134 changes: 134 additions & 0 deletions docs/pages/composing-types.rst
Original file line number Diff line number Diff line change
@@ -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 <numeric-intervals>`.

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):
...
9 changes: 7 additions & 2 deletions docs/pages/pydantic-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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__() <phantom.Phantom.__schema__>`::
:func:`Phantom.__schema__() <phantom.Phantom.__schema__>`:

.. code-block:: python
from phantom import Phantom
from phantom.schema import Schema
class Name(str, Phantom, predicate=...):
@classmethod
def __schema__(cls) -> Schema:
Expand All @@ -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 <phantom.sized.PhantomSized>` can specify
``"items"`` like so::
``"items"`` like so:

.. code-block:: python
class LimitedSize(PhantomSized[int], len=numeric.greater(10)):
@classmethod
Expand Down
2 changes: 1 addition & 1 deletion src/phantom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Use ``Phantom`` to create arbitrary phantom types using boolean predicates.
::
.. code-block:: python
import phantom
Expand Down
6 changes: 4 additions & 2 deletions src/phantom/interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
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):
...
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
Expand Down
4 changes: 3 additions & 1 deletion src/phantom/iso3166.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
"""
Expand Down
3 changes: 2 additions & 1 deletion src/phantom/re.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/phantom/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/phantom/sized.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
...
Expand Down

0 comments on commit 9e169b9

Please sign in to comment.