PEP 692 – Using TypedDict for more precise **kwargs typing
- Autor:
- Franek Magiera <framagie at gmail.com>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 29-Mai-2022
- Python-Version:
- 3.12
- Post-History:
- 29-Mai-2022, 12-Jul-2022, 12-Jul-2022
- Resolution:
- Discourse-Nachricht
Inhaltsverzeichnis
- Zusammenfassung
- Motivation
- Begründung
- Spezifikation
- Beabsichtigte Verwendung
- Wie man das lehrt
- Referenzimplementierung
- Abgelehnte Ideen
- Urheberrecht
Zusammenfassung
Derzeit kann **kwargs als Typ-Hint gegeben werden, solange alle durch sie spezifizierten Schlüsselwortargumente vom selben Typ sind. Dieses Verhalten kann jedoch sehr einschränkend sein. Daher schlagen wir in diesem PEP einen neuen Weg vor, um eine präzisere **kwargs-Typisierung zu ermöglichen. Der neue Ansatz dreht sich um die Verwendung von TypedDict zur Typisierung von **kwargs, die Schlüsselwortargumente unterschiedlicher Typen umfassen.
Motivation
Derzeit bedeutet die Annotation von **kwargs mit einem Typ T, dass der Typ von kwargs tatsächlich dict[str, T] ist. Zum Beispiel
def foo(**kwargs: str) -> None: ...
bedeutet, dass alle Schlüsselwortargumente in foo Strings sind (d.h. kwargs hat den Typ dict[str, str]). Dieses Verhalten beschränkt die Möglichkeit, **kwargs nur in den Fällen zu typisieren, in denen alle vom selben Typ sind. Es ist jedoch oft der Fall, dass Schlüsselwortargumente, die durch **kwargs übermittelt werden, unterschiedliche Typen haben, die vom Namen des Schlüssels abhängen. In diesen Fällen ist die Typisierung von **kwargs nicht möglich. Dies ist insbesondere ein Problem für bereits bestehende Codebasen, bei denen die Notwendigkeit, den Code zu refaktorisieren, um ordnungsgemäße Typannotationen einzuführen, als nicht lohnenswert erachtet werden kann. Dies wiederum hindert das Projekt daran, alle Vorteile zu nutzen, die Typ-Hints bieten können.
Darüber hinaus kann **kwargs verwendet werden, um den benötigten Codeaufwand zu reduzieren, wenn eine Top-Level-Funktion Teil einer öffentlichen API ist und eine Reihe von Hilfsfunktionen aufruft, die alle dieselben Schlüsselwortargumente erwarten. Wenn diese Hilfsfunktionen jedoch **kwargs verwenden würden, gibt es keine Möglichkeit, sie ordnungsgemäß zu typisieren, wenn die von ihnen erwarteten Schlüsselwortargumente unterschiedliche Typen haben. Selbst wenn die Schlüsselwortargumente vom selben Typ sind, gibt es keine Möglichkeit zu überprüfen, ob die Funktion mit Schlüsselwortnamen aufgerufen wird, die sie tatsächlich erwartet.
Wie im Abschnitt Beabsichtigte Verwendung beschrieben, ist die Verwendung von **kwargs nicht immer das beste Werkzeug für die Aufgabe. Trotzdem ist es immer noch ein weit verbreitetes Muster. Infolgedessen gab es viele Diskussionen über die Unterstützung einer präziseren **kwargs-Typisierung, und es wurde zu einer Funktion, die für einen großen Teil der Python-Community wertvoll wäre. Dies wird am besten durch das mypy GitHub Issue 4441 veranschaulicht, das viele reale Anwendungsfälle enthält, die von diesem Vorschlag profitieren könnten.
Ein weiterer erwähnenswerter Anwendungsfall, für den **kwargs ebenfalls praktisch sind, ist, wenn eine Funktion optionale Schlüsselwort-Only-Argumente ermöglichen soll, die keine Standardwerte haben. Ein Bedarf für ein solches Muster kann entstehen, wenn Werte, die normalerweise als Standardwerte zur Anzeige keiner Benutzereingabe verwendet werden, wie z. B. None, von einem Benutzer übergeben werden können und zu einem gültigen, nicht standardmäßigen Verhalten führen sollen. Beispielsweise kam dieses Problem in der beliebten Bibliothek httpx auf.
Begründung
PEP 589 führte den TypedDict-Typkonstruktor ein, der Dictionary-Typen mit String-Schlüsseln und Werten potenziell unterschiedlicher Typen unterstützt. Die Schlüsselwortargumente einer Funktion, die durch einen formalen Parameter repräsentiert werden, der mit einem Doppelstern beginnt, wie z. B. **kwargs, werden als Dictionary empfangen. Zusätzlich werden solche Funktionen oft mit entpackten Dictionaries aufgerufen, um Schlüsselwortargumente bereitzustellen. Dies macht TypedDict zu einem perfekten Kandidaten für eine präzisere **kwargs-Typisierung. Darüber hinaus können mit TypedDict Schlüsselwortnamen bei der statischen Typanalyse berücksichtigt werden. Das Festlegen des **kwargs-Typs mit einem TypedDict bedeutet jedoch, wie bereits erwähnt, dass jedes durch **kwargs spezifizierte Schlüsselwortargument ein TypedDict selbst ist. Zum Beispiel
class Movie(TypedDict):
name: str
year: int
def foo(**kwargs: Movie) -> None: ...
bedeutet, dass jedes Schlüsselwortargument in foo selbst ein Movie-Dictionary ist, das einen name-Schlüssel mit einem String-Wert und einen year-Schlüssel mit einem Integer-Wert hat. Um daher die Typisierung von kwargs als TypedDict zu unterstützen, ohne das aktuelle Verhalten zu brechen, muss ein neues Konstrukt eingeführt werden.
Um diesen Anwendungsfall zu unterstützen, schlagen wir vor, Unpack wiederzuverwenden, das ursprünglich in PEP 646 eingeführt wurde. Dafür gibt es mehrere Gründe
- Sein Name ist für den
**kwargs-Typisierungsfall sehr passend und intuitiv, da unsere Absicht ist, die Schlüsselwortargumente aus dem bereitgestelltenTypedDictzu „entpacken“. - Die aktuelle Art der Typisierung von
*argswürde auf**kwargserweitert werden, und diese sollen sich ähnlich verhalten. - Es wäre nicht notwendig, neue Sonderformen einzuführen.
- Die Verwendung von
Unpackfür die in diesem PEP beschriebenen Zwecke stört nicht die in PEP 646 beschriebenen Anwendungsfälle.
Spezifikation
Mit Unpack führen wir eine neue Methode zur Annotation von **kwargs ein. Fortsetzung des vorherigen Beispiels
def foo(**kwargs: Unpack[Movie]) -> None: ...
würde bedeuten, dass **kwargs zwei von Movie spezifizierte Schlüsselwortargumente umfasst (d.h. einen name-Schlüssel vom Typ str und einen year-Schlüssel vom Typ int). Dies deutet darauf hin, dass die Funktion wie folgt aufgerufen werden sollte
kwargs: Movie = {"name": "Life of Brian", "year": 1979}
foo(**kwargs) # OK!
foo(name="The Meaning of Life", year=1983) # OK!
Wenn Unpack verwendet wird, behandeln Typ-Checker kwargs innerhalb des Funktionskörpers als ein TypedDict
def foo(**kwargs: Unpack[Movie]) -> None:
assert_type(kwargs, Movie) # OK!
Die Verwendung der neuen Annotation hat keine Laufzeitauswirkung – sie wird nur von Typ-Checkern berücksichtigt. Alle Fehlerhinweise in den folgenden Abschnitten beziehen sich auf Typ-Checker-Fehler.
Funktionsaufrufe mit Standard-Dictionaries
Das Übergeben eines Dictionaries vom Typ dict[str, object] als **kwargs-Argument an eine Funktion, die **kwargs mit Unpack annotiert hat, muss einen Typ-Checker-Fehler erzeugen. Auf der anderen Seite kann das Verhalten bei Funktionen, die Standard-Dictionaries ohne Typen verwenden, vom Typ-Checker abhängen. Zum Beispiel
def foo(**kwargs: Unpack[Movie]) -> None: ...
movie: dict[str, object] = {"name": "Life of Brian", "year": 1979}
foo(**movie) # WRONG! Movie is of type dict[str, object]
typed_movie: Movie = {"name": "The Meaning of Life", "year": 1983}
foo(**typed_movie) # OK!
another_movie = {"name": "Life of Brian", "year": 1979}
foo(**another_movie) # Depends on the type checker.
Schlüsselwortkollisionen
Ein TypedDict, das zur Typisierung von **kwargs verwendet wird, könnte potenziell Schlüssel enthalten, die bereits in der Signatur der Funktion definiert sind. Wenn der doppelte Name ein Standardparameter ist, sollte ein Fehler von Typ-Checkern gemeldet werden. Wenn der doppelte Name ein Positional-Only-Parameter ist, werden keine Fehler generiert. Zum Beispiel
def foo(name, **kwargs: Unpack[Movie]) -> None: ... # WRONG! "name" will
# always bind to the
# first parameter.
def foo(name, /, **kwargs: Unpack[Movie]) -> None: ... # OK! "name" is a
# positional-only parameter,
# so **kwargs can contain
# a "name" keyword.
Erforderliche und nicht erforderliche Schlüssel
Standardmäßig sind alle Schlüssel in einem TypedDict erforderlich. Dieses Verhalten kann durch Setzen des total-Parameters des Dictionaries auf False überschrieben werden. Darüber hinaus führte PEP 655 neue Typqualifizierer – typing.Required und typing.NotRequired – ein, die angeben, ob ein bestimmter Schlüssel erforderlich oder nicht erforderlich ist
class Movie(TypedDict):
title: str
year: NotRequired[int]
Bei der Verwendung eines TypedDict zur Typisierung von **kwargs sollten alle erforderlichen und nicht erforderlichen Schlüssel erforderlichen und nicht erforderlichen Funktionsschlüsselwortparametern entsprechen. Daher muss, wenn ein erforderlicher Schlüssel nicht vom Aufrufer unterstützt wird, ein Fehler von Typ-Checkern gemeldet werden.
Zuweisung
Zuweisungen einer mit **kwargs: Unpack[Movie] typisierten Funktion und eines anderen aufrufbaren Typs sollten nur dann einen Typ-Check bestehen, wenn sie kompatibel sind. Dies kann für die unten beschriebenen Szenarien passieren.
Quelle und Ziel enthalten **kwargs
Sowohl die Ziel- als auch die Quellfunktion haben einen **kwargs: Unpack[TypedDict]-Parameter, und das TypedDict der Zielfunktion ist dem TypedDict der Quellfunktion zuweisbar und die restlichen Parameter sind kompatibel.
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def accept_animal(**kwargs: Unpack[Animal]): ...
def accept_dog(**kwargs: Unpack[Dog]): ...
accept_dog = accept_animal # OK! Expression of type Dog can be
# assigned to a variable of type Animal.
accept_animal = accept_dog # WRONG! Expression of type Animal
# cannot be assigned to a variable of type Dog.
Quelle enthält **kwargs und Ziel nicht
Der Ziel-Aufruf hat kein **kwargs, der Quell-Aufruf hat **kwargs: Unpack[TypedDict] und die Schlüsselwortargumente der Zielfunktion sind den entsprechenden Schlüsseln im TypedDict der Quellfunktion zuweisbar. Darüber hinaus sollten nicht erforderliche Schlüssel optionalen Funktionsargumenten entsprechen, während erforderliche Schlüssel erforderlichen Funktionsargumenten entsprechen müssen. Wiederum müssen die restlichen Parameter kompatibel sein. Fortsetzung des vorherigen Beispiels
class Example(TypedDict):
animal: Animal
string: str
number: NotRequired[int]
def src(**kwargs: Unpack[Example]): ...
def dest(*, animal: Dog, string: str, number: int = ...): ...
dest = src # OK!
Es ist erwähnenswert, dass die Parameter der Zielfunktion, die mit den Schlüsseln und Werten aus dem TypedDict kompatibel sein sollen, schlüsselwort-only sein müssen.
def dest(dog: Dog, string: str, number: int = ...): ...
dog: Dog = {"name": "Daisy", "breed": "labrador"}
dest(dog, "some string") # OK!
dest = src # Type checker error!
dest(dog, "some string") # The same call fails at
# runtime now because 'src' expects
# keyword arguments.
Die umgekehrte Situation, in der der Ziel-Aufruf **kwargs: Unpack[TypedDict] hat und der Quell-Aufruf kein **kwargs hat, sollte nicht erlaubt sein. Dies liegt daran, dass wir nicht sicher sein können, dass zusätzliche Schlüsselwortargumente nicht übergeben werden, wenn eine Instanz einer Unterklasse einer Variablen mit einem Basisklassentyp zugewiesen und dann in der Ziel-Aufrufsinvokation entpackt wurde.
def dest(**kwargs: Unpack[Animal]): ...
def src(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
dest = src # WRONG!
dest(**animal) # Fails at runtime.
Eine ähnliche Situation kann auch ohne Vererbung auftreten, da die Kompatibilität zwischen TypedDicts auf struktureller Subtypisierung basiert.
Quelle enthält ungetyptes **kwargs
Der Ziel-Aufruf enthält **kwargs: Unpack[TypedDict] und der Quell-Aufruf enthält ungetyptes **kwargs
def src(**kwargs): ...
def dest(**kwargs: Unpack[Movie]): ...
dest = src # OK!
Quelle enthält traditionell getyptes **kwargs: T
Der Ziel-Aufruf enthält **kwargs: Unpack[TypedDict], der Quell-Aufruf enthält traditionell getyptes **kwargs: T und jedes Feld des TypedDict der Zielfunktion ist einer Variablen vom Typ T zuweisbar.
class Vehicle:
...
class Car(Vehicle):
...
class Motorcycle(Vehicle):
...
class Vehicles(TypedDict):
car: Car
moto: Motorcycle
def dest(**kwargs: Unpack[Vehicles]): ...
def src(**kwargs: Vehicle): ...
dest = src # OK!
Andererseits, wenn der Ziel-Aufruf entweder ungetyptes oder traditionell getyptes **kwargs: T enthält und der Quell-Aufruf mit **kwargs: Unpack[TypedDict] typisiert ist, sollte ein Fehler generiert werden, da traditionell getypte **kwargs nicht auf Schlüsselwortnamen überprüft werden.
Zusammenfassend lässt sich sagen, dass Funktionsparameter sich kontravariant und Rückgabetypen sich kovariant verhalten sollten.
Weitergabe von kwargs innerhalb einer Funktion an eine andere Funktion
Ein vorheriger Punkt erwähnt das Problem der möglichen Übergabe zusätzlicher Schlüsselwortargumente durch Zuweisung einer Unterklasseninstanz an eine Variable mit einem Basisklassentyp. Betrachten wir das folgende Beispiel
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def takes_name(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
def foo(**kwargs: Unpack[Animal]):
print(kwargs["name"].capitalize())
def bar(**kwargs: Unpack[Animal]):
takes_name(**kwargs)
def baz(animal: Animal):
takes_name(**animal)
def spam(**kwargs: Unpack[Animal]):
baz(kwargs)
foo(**animal) # OK! foo only expects and uses keywords of 'Animal'.
bar(**animal) # WRONG! This will fail at runtime because 'breed' keyword
# will be passed to 'takes_name' as well.
spam(**animal) # WRONG! Again, 'breed' keyword will be eventually passed
# to 'takes_name'.
Im obigen Beispiel wird der Aufruf von foo zur Laufzeit keine Probleme verursachen. Auch wenn foo kwargs vom Typ Animal erwartet, spielt es keine Rolle, ob es zusätzliche Argumente erhält, da es nur liest und verwendet, was es benötigt, und alle zusätzlichen Werte vollständig ignoriert.
Die Aufrufe von bar und spam werden fehlschlagen, da der Funktion takes_name ein unerwartetes Schlüsselwortargument übergeben wird.
Daher können mit einem entpackten TypedDict getypte kwargs nur dann an eine andere Funktion übergeben werden, wenn die Funktion, an die die entpackten kwargs übergeben werden, ebenfalls **kwargs in ihrer Signatur hat, da zusätzliche Schlüssel dann bei der Funktionsaufrufung keine Laufzeitfehler verursachen würden. Andernfalls sollte der Typ-Checker einen Fehler generieren.
In Fällen, die der Funktion bar ähneln, könnte das Problem durch explizites Dereferenzieren gewünschter Felder und deren Verwendung als Argumente für den Funktionsaufruf umgangen werden.
def bar(**kwargs: Unpack[Animal]):
name = kwargs["name"]
takes_name(name)
Verwendung von Unpack mit anderen Typen als TypedDict
Wie im Abschnitt Begründung beschrieben, ist TypedDict der natürlichste Kandidat für die Typisierung von **kwargs. Daher sollte im Kontext der Typisierung von **kwargs die Verwendung von Unpack mit anderen Typen als TypedDict nicht erlaubt sein und Typ-Checker sollten in solchen Fällen Fehler generieren.
Änderungen an Unpack
Derzeit ist die Verwendung von Unpack im Kontext der Typisierung austauschbar mit der Verwendung der Sternchen-Syntax.
>>> Unpack[Movie]
*<class '__main__.Movie'>
Daher sollte für die Kompatibilität mit dem neuen Anwendungsfall der repr von Unpack auf einfach Unpack[T] geändert werden.
Beabsichtigte Verwendung
Die beabsichtigten Anwendungsfälle für diesen Vorschlag sind im Abschnitt Motivation beschrieben. Zusammenfassend lässt sich sagen, dass eine präzisere **kwargs-Typisierung Vorteile für bereits bestehende Codebasen bringen kann, die sich ursprünglich für die Verwendung von **kwargs entschieden haben, aber nun reif genug sind, um einen strengeren Vertrag über Typ-Hints zu nutzen. Die Verwendung von **kwargs kann auch dazu beitragen, Code-Duplizierung und die Menge an Copy-Paste-Aufwand zu reduzieren, wenn viele Funktionen dieselbe Menge an Schlüsselwortargumenten benötigen. Schließlich sind **kwargs nützlich für Fälle, in denen eine Funktion optionale Schlüsselwortargumente ermöglichen muss, für die es keine offensichtlichen Standardwerte gibt.
Es ist jedoch anzumerken, dass in einigen Fällen bessere Werkzeuge für die Aufgabe vorhanden sind als die Verwendung von TypedDict zur Typisierung von **kwargs, wie in diesem PEP vorgeschlagen. Zum Beispiel ist es beim Schreiben von neuem Code, wenn alle Schlüsselwortargumente erforderlich sind oder Standardwerte haben, besser, alles explizit zu schreiben, anstatt **kwargs und ein TypedDict zu verwenden.
def foo(name: str, year: int): ... # Preferred way.
def foo(**kwargs: Unpack[Movie]): ...
Ähnlich verhält es sich beim Typ-Hinting von Drittanbieterbibliotheken über Stubs: Es ist wieder besser, die Funktionssignatur explizit anzugeben – dies ist die einzige Möglichkeit, eine solche Funktion zu typisieren, wenn sie Standardargumente hat. Ein weiteres Problem, das in diesem Fall auftreten kann, wenn versucht wird, die Funktion mit einem TypedDict zu typisieren, ist, dass einige Standard-Funktionsparameter als schlüsselwort-only behandelt werden könnten.
def foo(name, year): ... # Function in a third party library.
def foo(Unpack[Movie]): ... # Function signature in a stub file.
foo("Life of Brian", 1979) # This would be now failing type
# checking but is fine.
foo(name="Life of Brian", year=1979) # This would be the only way to call
# the function now that passes type
# checking.
Daher wird in diesem Fall wieder bevorzugt, eine solche Funktion explizit zu typisieren als
def foo(name: str, year: int): ...
Außerdem sollten Funktionen, die Teil der öffentlichen API sind, zugunsten von IDEs und Dokumentationsseiten, wo immer möglich, explizite Schlüsselwortparameter bevorzugen.
Wie man das lehrt
Dieses PEP könnte in der Dokumentation des Moduls typing verlinkt werden. Darüber hinaus könnte ein neuer Abschnitt über die Verwendung von Unpack zu den oben genannten Dokumenten hinzugefügt werden. Ähnliche Abschnitte könnten auch in der mypy-Dokumentation und der Typing-Dokumentation hinzugefügt werden.
Referenzimplementierung
Der mypy-Typ-Checker unterstützt bereits präzisere **kwargs-Typisierung unter Verwendung von Unpack.
Der Pyright-Typ-Checker bietet ebenfalls eine vorläufige Unterstützung für diese Funktion.
Abgelehnte Ideen
TypedDict-Vereinigungen
Es ist möglich, Vereinigungen von getypten Dictionaries zu erstellen. Die Unterstützung der Typisierung von **kwargs mit einer Vereinigung von Typed Dictionaries würde jedoch die Komplexität der Implementierung dieses PEP erheblich erhöhen, und es scheint keinen zwingenden Anwendungsfall zu geben, der die Unterstützung dafür rechtfertigt. Daher kann die Verwendung von Vereinigungen von Typed Dictionaries zur Typisierung von **kwargs im Kontext dieses PEP zu einem Fehler führen.
class Book(TypedDict):
genre: str
pages: int
TypedDictUnion = Movie | Book
def foo(**kwargs: Unpack[TypedDictUnion]) -> None: ... # WRONG! Unsupported use
# of a union of
# TypedDicts to type
# **kwargs
Stattdessen kann eine Funktion, die eine Vereinigung von TypedDicts erwartet, überladen werden.
@overload
def foo(**kwargs: Unpack[Movie]): ...
@overload
def foo(**kwargs: Unpack[Book]): ...
Änderung der Bedeutung von **kwargs-Annotationen
Eine Möglichkeit, den Zweck dieses PEP zu erreichen, wäre, die Bedeutung von **kwargs-Annotationen zu ändern, sodass sich die Annotationen auf das gesamte **kwargs-Dictionary beziehen, nicht auf einzelne Elemente. Zur Konsistenz müssten wir eine analoge Änderung an *args-Annotationen vornehmen.
Diese Idee wurde in einer Besprechung der Typisierungs-Community diskutiert, und der Konsens war, dass die Änderung den Aufwand nicht wert sei. Es gibt keinen klaren Migrationspfad, die aktuelle Bedeutung von *args und **kwargs-Annotationen ist im Ökosystem gut etabliert, und Typ-Checker müssten neue Fehler für Code einführen, der derzeit legal ist.
Einführung einer neuen Syntax
In früheren Versionen dieses PEP wurde eine Doppelsternchen-Syntax vorgeschlagen, um eine präzisere **kwargs-Typisierung zu unterstützen. Mit dieser Syntax könnten Funktionen wie folgt annotiert werden:
def foo(**kwargs: **Movie): ...
was dieselbe Bedeutung hätte wie
def foo(**kwargs: Unpack[Movie]): ...
Dies erhöhte den Umfang des PEP erheblich, da es eine Grammatikänderung und die Einführung eines neuen Dunder für die Sonderform Unpack erfordert hätte. Gleichzeitig war die Begründung für die Einführung einer neuen Syntax nicht stark genug und wurde zu einem Blocker für das gesamte PEP. Daher haben wir beschlossen, die Idee der Einführung einer neuen Syntax als Teil dieses PEP aufzugeben und sie möglicherweise in einem separaten PEP erneut vorzuschlagen.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0692.rst
Zuletzt geändert: 2025-03-05 16:28:34 GMT