Skip to content

PEP xxx: Inner Fields Annotations #3326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ pep-0723.rst @AA-Turner
pep-0725.rst @pradyunsg
pep-0726.rst @AA-Turner
pep-0727.rst @JelleZijlstra
pep-0728.rst @JelleZijlstra
# ...
# pep-0754.txt
# ...
Expand Down
322 changes: 322 additions & 0 deletions pep-0728.rst
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please wrap to 80 columns per PEP 1

Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
PEP: 728
Title: Inner Fields Annotations
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you be a bit more specific with this title? Before reading the PEP I had no idea what "Inner Fields Annotations" meant.

Author: Nir Schulman <[email protected]>
Sponsor: Jelle Zijlstra <[email protected]>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ping @JelleZijlstra for confirmation of sponsorship

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but I am not sponsoring this PEP at the moment. I already have four open PEPs (696, 702, 724, 727), that feels like too much. I can think about sponsoring more when a few of the others are done.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. In that case I'll wait and in the meantime address the rest of the comments (and in general try to improve the idea).

@AA-Turner Does this mean this PR should be closed for now or should it stay open with an appropriate label?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UltimateLobster I would suggest that you revisit the Discourse thread and see if another core developer interested in typing would sponsor this proposal. I'll close this PR for now so that we're aware that the PEP number isn't reserved, though still happy to discuss any of the comments I made in review.

A

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to set expectations, I don't know whether I'd be willing to sponsor the PEP if I had fewer open PEPs; I'd have to think more about whether I think this is a good direction to go into. I'd also prefer to see more evidence of the feasibility of the idea (e.g., buy-in from type checker maintainers, ideally a prototype implementation).

Discussions-To: https://discuss.python.org/t/26527
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This link should go into Post-History; a new thread should be created for PEP 728 just before this PR is merged

Status: Draft
Type: Standards Track
Topic: Typing
Content-Type: text/x-rst
Created: 01-Sep-2023
Python-Version: 3.13
Post-History:


Abstract
========

This PEP specifies a way for annotations to reference the field types of other complex objects. This include the field
names and types objects with static layout such as dataclasses (or dataclass-like objects), TypedDicts and NamedTuples.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
names and types objects with static layout such as dataclasses (or dataclass-like objects), TypedDicts and NamedTuples.
names and types objects with static layout such as dataclasses (or dataclass-like objects), :py:class:`~typing.TypedDict`s and :py:class:`~typing.NamedTuple`s.



Motivation
==========
Consider the following example:

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


@dataclass
class Cat:
fur_color: str
age: int

def get_cats(field_name: Literal["fur_color", "age"], value: str | int) -> list[Cat]:
pass

# will return cats that are 3 years old
get_cats("age", 3)


The current annotation of ``get_cats`` has some problems:

1. We need to remember to update this function whenever the field types or names of ``Cat`` are changing.
2. We lose the connection between the field name and the field type. ``get_cats("age", "black")`` is invalid usage would
because we supplied a non-integer value as the filter for the age field, but the type checker allows this.

The latter problem can be solved using ``typing.overload``:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The latter problem can be solved using ``typing.overload``:
The latter problem can be solved using :py:func:`typing.overload`:


.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import overload, Literal

@overload
def get_cats(field_name: Literal['fur_color'], value: str) -> list[Cat]:
pass

@overload
def get_cats(field_name: Literal['age'], value: int) -> list[Cat]:
pass

def get_cats(field_name, value) -> list[Cat]:
pass

However this solution only worsens the former problem. We need to recreate the definition of the ``get_cats`` function
for each field, and remember to update the relevant overload whenever the class ``Cat`` changes. This is cumbersome and
introduces a lot of code duplication (which is prone to errors).

The current proposals solves both of the mentioned problems by introducing 2 new types into the ``typing`` module.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The current proposals solves both of the mentioned problems by introducing 2 new types into the ``typing`` module.
The current proposals solves both of the mentioned problems by introducing 2 new types into the :py:mod:`typing` module.

``Field`` and ``FieldName`` which allows you to refer to the type of an object's fields as well as their respective names.

.. code-block:: py

from typing import FieldName

@dataclass
class Cat:
fur_color: str
age: int

def get_cats[T: FieldName[Cat]](field_name: T, value: Field[Cat, T]) -> list[Cat]:
pass

get_cats("some_field", 12) # Type checker error: Cat did not define field named "some_field"
get_cats("age", "black") # Type checker error: Cat defines "age" as a field of type int, but received str.
get_cats("age", 3) # Passes type check

A similar benefit could be achieved for other functions that expose certain CRUD operations against a database.
Clients like pymongo can then define stricter typings for their queries:

.. code-block:: py

# This definition is obviously simplified for the sake of the example.
type Filter[T: dict, TName: FieldName[T]] = dict[TName, NotRequired[Field[T, TName]]]

class Collection[T]:
def find(self, filter: Filter[T]) -> Iterable[T]:
...

cat_collection: Collection[Cat]

# Passes type checking because filters do not require all of Cat's fields to be present within the query.
cat_collection.find({"age": 3})

# Fails type checking because fur_color must be a string
cat_collection.find({"fur_color": 1})

# Fails type checking because "foo" is not declared in the collection's scheme
cat_collection.find({"foo": "bar"})


Specification
=============

The current proposals introduces adds ``Field`` and ``FieldName`` to the ``typing`` module.

Field
'''''''''
Comment on lines +115 to +116
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Field
'''''''''
Field
'''''

``Field`` can be used to refer to another type's fields. It receives 2 arguments, a type and a name of a field within
this type. The first argument may be a dataclasses, (or dataclass-transformed objects), classes implementing
``__slots__``, ``TypedDict`` or any other type with a typed layout.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field

@dataclass
class Foo:
bar: int
baz: str

# Function returns int
def func() -> Field[Foo, 'bar']:
pass

While dataclass-like objects will consider the attributes of the instance, dictionaries will instead refer to the items
of the instance.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field, TypedDict

class Foo(TypedDict):
bar: int
baz: str

# Function returns int
def func() -> Field[Foo, 'bar']:
pass


Multiple field names may be specified using ``Literal``. In these cases, the annotation will be the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Multiple field names may be specified using ``Literal``. In these cases, the annotation will be the
Multiple field names may be specified using :py:class:`~typing.Literal`. In these cases, the annotation will be the

equivalent of a union of the types of the given fields.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field, Literal

@dataclass
class Foo:
bar: int
baz: str

# Function returns int | str
def func() -> Field[Foo, Literal['bar', 'baz']]:
pass

Using ``Field`` with an explicit ``Literal`` of a single field is allowed, but not needed because you can use the bare
field name.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field, Literal

@dataclass
class Foo:
bar: int
baz: str

# Function returns int
def func() -> Field[Foo, Literal["bar"]]:
pass


String field names that are provided to ``Field`` will always be assumed to be the literal strings (instead of forward
references) unless the entire annotation is wrapped within quotes. When the entire annotation is wrapped within quotes,
you may still explicitly use ``Literal`` in order to prevent this behavior.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field, Literal

@dataclass
class Foo:
bar: int
baz: str

bar: TypeAlias = Literal['baz']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use the type parameter syntax since TypeAlias is deprecated(-ish)? They technically don't do the same thing at runtime but I'm guessing both are supposed to work the same within the context of this PEP's new typing constructs.


# Will refer to the literal "bar". Function returns int
def func() -> Field[Foo, 'bar']:
pass

# Will refer to the type alias bar. Function returns str
def func() -> 'Field[Foo, bar]':
pass

# Will refer to a union of the literal "bar" and the type alias bar. Function returns int | str
def func() -> 'Field[Foo, Literal[bar] | bar]':
pass

If no field names are given to ``Field``, the annotation will be an equivalent of a union of all values of the fields
within the type

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field

@dataclass
class Foo:
bar: int
baz: str

# Function returns int | str
def func() -> Field[Foo]:
pass

Type checkers should raise an error when using an invalid field name as a parameter to ``Field``

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import Field

@dataclass
class Foo:
bar: int
baz: str

# Type checker error, "Foo" does not expose a field named "bla"
def func() -> Field[Foo, "bla"]:
pass


FieldName
'''''''''
Comment on lines +242 to +243
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
FieldName
'''''''''
FieldName
'''''''''

In addition to ``Field``, ``FieldName`` will be added to the ``typing`` module. ``FieldName`` can be used in order to
refer to the name of a type's fields. It receives a single argument and when used, is equivalent to a union
of literals of its field names.

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


from typing import FieldName

@dataclass
class Foo:
bar: int
baz: str

# FieldName[ComplexType] is equivalent to Literal["bar", "baz"]
def func(field_name: FieldName[Foo]):
pass

func("bar")
func("baz")

# Type checker error.
func("something else")



Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Open Issues
===========

Having separate types for attributes, and items
'''''''''''''''''''''''''''''''''''''''''''''''
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'''''''''''''''''''''''''''''''''''''''''''''''
'''''''''''''''''''''''''''''''''''''''''''''''

``FieldName`` and ``Field`` can refer to either objects attributes (``obj.x``) or items ``(obj['x'])`` depending on the
context, this behavior may cause some confusion. One could suggest that instead of ``FieldName`` and ``Field`` being
added to typing, we could add ``Attribute``, ``AttributeName``, ``Item``, ``ItemName``.

In general this approach has the benefit of being consistent and predictable from the user side of things.
``Attribute[ObjType, 'x']`` would be treated the type of ``obj.x`` whereas ``Item[ObjType, 'x']`` would be treated as
the type of ``obj['x']``. These types would have the same meaning for all python objects (Whether working with TypedDicts or
dataclasses). It would also allow referring to fields of objects containing both attributes and items:

.. code-block:: py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: py
.. code-block:: python


class Foo(TypedDict):
get: int
set: str

# Refers to the type of Foo['get']
Item[Foo, 'get]

# Refers to the dictionary method 'dict.get'
Attribute[Foo, 'get']

However, this approach will clutter the typing namespace with more concepts that would have to be maintained. It is
debatable whether or not it would make this concept easier or harder to learn. While requiring to understand 4
additional utilities instead of only 2, one could argue it makes more intuitive sense and is easier to remember than the
behaviour of TypedDicts, dataclasses, and dataclass-like types separately.


Should __annotations__ be used as the reference table for fields?
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Comment on lines +301 to +302
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Should __annotations__ be used as the reference table for fields?
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Should __annotations__ be used as the reference table for fields?
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''

Related to the previous question, we could define ``Field`` and ``FieldName`` by the type's __annotations__ definition.
This approach will allow for type-checkers to have the same treatment for dataclasses and TypedDict, making it easier to
support this feature.

However, this approach will not allow users to refer to dictionaries' methods, or dataclass-like
types' items (if they support ``__getitem__``).
It might also prevent us from expanding these types in the future to refer to inner types of typed collections (like
``dict[int, str]``, ``tuple[int, ...]``, etc.) or any other type that does not define ``__annotations__``.


Naming
''''''
Comment on lines +313 to +314
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Naming
''''''
Naming
''''''

The names ``Field`` and ``FieldName`` are temporary and would probably need to change as this PEP evolves and the rest
of the questions in this section are answered.


Copyright
=========

This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.