Skip to content

Commit 87b0984

Browse files
authored
PYTHON-3494 Improve Documentation Surrounding Type-Checking "_id" (#1104)
1 parent 0d301f1 commit 87b0984

File tree

2 files changed

+120
-7
lines changed

2 files changed

+120
-7
lines changed

doc/examples/type_hints.rst

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ You can use :py:class:`~typing.TypedDict` (Python 3.8+) when using a well-define
9797
These methods automatically add an "_id" field.
9898

9999
.. doctest::
100+
:pyversion: >= 3.8
100101

101102
>>> from typing import TypedDict
102103
>>> from pymongo import MongoClient
@@ -111,14 +112,73 @@ These methods automatically add an "_id" field.
111112
>>> result = collection.find_one({"name": "Jurassic Park"})
112113
>>> assert result is not None
113114
>>> assert result["year"] == 1993
114-
>>> # This will not be type checked, despite being present, because it is added by PyMongo.
115-
>>> assert type(result["_id"]) == ObjectId
115+
>>> # This will raise a type-checking error, despite being present, because it is added by PyMongo.
116+
>>> assert result["_id"] # type:ignore[typeddict-item]
117+
118+
Modeling Document Types with TypedDict
119+
--------------------------------------
120+
121+
You can use :py:class:`~typing.TypedDict` (Python 3.8+) to model structured data.
122+
As noted above, PyMongo will automatically add an `_id` field if it is not present. This also applies to TypedDict.
123+
There are three approaches to this:
124+
125+
1. Do not specify `_id` at all. It will be inserted automatically, and can be retrieved at run-time, but will yield a type-checking error unless explicitly ignored.
126+
127+
2. Specify `_id` explicitly. This will mean that every instance of your custom TypedDict class will have to pass a value for `_id`.
128+
129+
3. Make use of :py:class:`~typing.NotRequired`. This has the flexibility of option 1, but with the ability to access the `_id` field without causing a type-checking error.
130+
131+
Note: to use :py:class:`~typing.TypedDict` and :py:class:`~typing.NotRequired` in earlier versions of Python (<3.8, <3.11), use the `typing_extensions` package.
132+
133+
.. doctest:: typed-dict-example
134+
:pyversion: >= 3.11
135+
136+
>>> from typing import TypedDict, NotRequired
137+
>>> from pymongo import MongoClient
138+
>>> from pymongo.collection import Collection
139+
>>> from bson import ObjectId
140+
>>> class Movie(TypedDict):
141+
... name: str
142+
... year: int
143+
...
144+
>>> class ExplicitMovie(TypedDict):
145+
... _id: ObjectId
146+
... name: str
147+
... year: int
148+
...
149+
>>> class NotRequiredMovie(TypedDict):
150+
... _id: NotRequired[ObjectId]
151+
... name: str
152+
... year: int
153+
...
154+
>>> client: MongoClient = MongoClient()
155+
>>> collection: Collection[Movie] = client.test.test
156+
>>> inserted = collection.insert_one(Movie(name="Jurassic Park", year=1993))
157+
>>> result = collection.find_one({"name": "Jurassic Park"})
158+
>>> assert result is not None
159+
>>> # This will yield a type-checking error, despite being present, because it is added by PyMongo.
160+
>>> assert result["_id"] # type:ignore[typeddict-item]
161+
>>> collection: Collection[ExplicitMovie] = client.test.test
162+
>>> # Note that the _id keyword argument must be supplied
163+
>>> inserted = collection.insert_one(ExplicitMovie(_id=ObjectId(), name="Jurassic Park", year=1993))
164+
>>> result = collection.find_one({"name": "Jurassic Park"})
165+
>>> assert result is not None
166+
>>> # This will not raise a type-checking error.
167+
>>> assert result["_id"]
168+
>>> collection: Collection[NotRequiredMovie] = client.test.test
169+
>>> # Note the lack of _id, similar to the first example
170+
>>> inserted = collection.insert_one(NotRequiredMovie(name="Jurassic Park", year=1993))
171+
>>> result = collection.find_one({"name": "Jurassic Park"})
172+
>>> assert result is not None
173+
>>> # This will not raise a type-checking error, despite not being provided explicitly.
174+
>>> assert result["_id"]
175+
116176

117177
Typed Database
118178
--------------
119179

120180
While less common, you could specify that the documents in an entire database
121-
match a well-defined shema using :py:class:`~typing.TypedDict` (Python 3.8+).
181+
match a well-defined schema using :py:class:`~typing.TypedDict` (Python 3.8+).
122182

123183

124184
.. doctest::

test/test_mypy.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,30 @@
2020
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List
2121

2222
try:
23-
from typing_extensions import TypedDict
23+
from typing_extensions import NotRequired, TypedDict
2424

25-
class Movie(TypedDict): # type: ignore[misc]
25+
from bson import ObjectId
26+
27+
class Movie(TypedDict):
2628
name: str
2729
year: int
2830

29-
except ImportError:
30-
TypedDict = None
31+
class MovieWithId(TypedDict):
32+
_id: ObjectId
33+
name: str
34+
year: int
35+
36+
class ImplicitMovie(TypedDict):
37+
_id: NotRequired[ObjectId]
38+
name: str
39+
year: int
40+
41+
except ImportError as exc:
42+
Movie = dict # type:ignore[misc,assignment]
43+
ImplicitMovie = dict # type: ignore[assignment,misc]
44+
MovieWithId = dict # type: ignore[assignment,misc]
45+
TypedDict = None # type: ignore[assignment]
46+
NotRequired = None # type: ignore[assignment]
3147

3248

3349
try:
@@ -324,6 +340,43 @@ def test_typeddict_document_type_insertion(self) -> None:
324340
)
325341
coll.insert_many([bad_movie])
326342

343+
@only_type_check
344+
def test_typeddict_explicit_document_type(self) -> None:
345+
out = MovieWithId(_id=ObjectId(), name="THX-1138", year=1971)
346+
assert out is not None
347+
# This should fail because the output is a Movie.
348+
assert out["foo"] # type:ignore[typeddict-item]
349+
assert out["_id"]
350+
351+
# This should work the same as the test above, but this time using NotRequired to allow
352+
# automatic insertion of the _id field by insert_one.
353+
@only_type_check
354+
def test_typeddict_not_required_document_type(self) -> None:
355+
out = ImplicitMovie(name="THX-1138", year=1971)
356+
assert out is not None
357+
# This should fail because the output is a Movie.
358+
assert out["foo"] # type:ignore[typeddict-item]
359+
assert out["_id"]
360+
361+
@only_type_check
362+
def test_typeddict_empty_document_type(self) -> None:
363+
out = Movie(name="THX-1138", year=1971)
364+
assert out is not None
365+
# This should fail because the output is a Movie.
366+
assert out["foo"] # type:ignore[typeddict-item]
367+
# This should fail because _id is not included in our TypedDict definition.
368+
assert out["_id"] # type:ignore[typeddict-item]
369+
370+
def test_typeddict_find_notrequired(self):
371+
if NotRequired is None or ImplicitMovie is None:
372+
raise unittest.SkipTest("Python 3.11+ is required to use NotRequired.")
373+
client: MongoClient[ImplicitMovie] = rs_or_single_client()
374+
coll = client.test.test
375+
coll.insert_one(ImplicitMovie(name="THX-1138", year=1971))
376+
out = coll.find_one({})
377+
assert out is not None
378+
assert out["_id"]
379+
327380
@only_type_check
328381
def test_raw_bson_document_type(self) -> None:
329382
client = MongoClient(document_class=RawBSONDocument)

0 commit comments

Comments
 (0)