diff --git a/doc/changelog.rst b/doc/changelog.rst index d263d4534..ab895fad5 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -8,6 +8,7 @@ Changes in Version 4.1 PyMongo 4.1 brings a number of improvements including: +- Type Hinting support (formerly provided by ``pymongo-stubs``). See :doc:`examples/type_hints` for more information. - Added support for the ``let`` parameter to :meth:`~pymongo.collection.Collection.update_one`, :meth:`~pymongo.collection.Collection.update_many`, diff --git a/doc/examples/index.rst b/doc/examples/index.rst index f8828cdfd..6cdeafc20 100644 --- a/doc/examples/index.rst +++ b/doc/examples/index.rst @@ -31,5 +31,6 @@ MongoDB, you can start it like so: server_selection tailable tls + type_hints encryption uuid diff --git a/doc/examples/type_hints.rst b/doc/examples/type_hints.rst new file mode 100644 index 000000000..029761bc7 --- /dev/null +++ b/doc/examples/type_hints.rst @@ -0,0 +1,243 @@ + +.. _type_hints-example: + +Type Hints +=========== + +As of version 4.1, PyMongo ships with `type hints`_. With type hints, Python +type checkers can easily find bugs before they reveal themselves in your code. + +If your IDE is configured to use type hints, +it can suggest more appropriate completions and highlight errors in your code. +Some examples include `PyCharm`_, `Sublime Text`_, and `Visual Studio Code`_. + +You can also use the `mypy`_ tool from your command line or in Continuous Integration tests. + +All of the public APIs in PyMongo are fully type hinted, and +several of them support generic parameters for the +type of document object returned when decoding BSON documents. + +Due to `limitations in mypy`_, the default +values for generic document types are not yet provided (they will eventually be ``Dict[str, any]``). + +For a larger set of examples that use types, see the PyMongo `test_mypy module`_. + +If you would like to opt out of using the provided types, add the following to +your `mypy config`_: :: + + [mypy-pymongo] + follow_imports = False + + +Basic Usage +----------- + +Note that a type for :class:`~pymongo.mongo_client.MongoClient` must be specified. Here we use the +default, unspecified document type: + +.. doctest:: + + >>> from pymongo import MongoClient + >>> client: MongoClient = MongoClient() + >>> collection = client.test.test + >>> inserted = collection.insert_one({"x": 1, "tags": ["dog", "cat"]}) + >>> retrieved = collection.find_one({"x": 1}) + >>> assert isinstance(retrieved, dict) + +For a more accurate typing for document type you can use: + +.. doctest:: + + >>> from typing import Any, Dict + >>> from pymongo import MongoClient + >>> client: MongoClient[Dict[str, Any]] = MongoClient() + >>> collection = client.test.test + >>> inserted = collection.insert_one({"x": 1, "tags": ["dog", "cat"]}) + >>> retrieved = collection.find_one({"x": 1}) + >>> assert isinstance(retrieved, dict) + +Typed Client +------------ + +:class:`~pymongo.mongo_client.MongoClient` is generic on the document type used to decode BSON documents. + +You can specify a :class:`~bson.raw_bson.RawBSONDocument` document type: + +.. doctest:: + + >>> from pymongo import MongoClient + >>> from bson.raw_bson import RawBSONDocument + >>> client = MongoClient(document_class=RawBSONDocument) + >>> collection = client.test.test + >>> inserted = collection.insert_one({"x": 1, "tags": ["dog", "cat"]}) + >>> result = collection.find_one({"x": 1}) + >>> assert isinstance(result, RawBSONDocument) + +Subclasses of :py:class:`collections.abc.Mapping` can also be used, such as :class:`~bson.son.SON`: + +.. doctest:: + + >>> from bson import SON + >>> from pymongo import MongoClient + >>> client = MongoClient(document_class=SON[str, int]) + >>> collection = client.test.test + >>> inserted = collection.insert_one({"x": 1, "y": 2 }) + >>> result = collection.find_one({"x": 1}) + >>> assert result is not None + >>> assert result["x"] == 1 + +Note that when using :class:`~bson.son.SON`, the key and value types must be given, e.g. ``SON[str, Any]``. + + +Typed Collection +---------------- + +You can use :py:class:`~typing.TypedDict` when using a well-defined schema for the data in a :class:`~pymongo.collection.Collection`: + +.. doctest:: + + >>> from typing import TypedDict + >>> from pymongo import MongoClient, Collection + >>> class Movie(TypedDict): + ... name: str + ... year: int + ... + >>> client: MongoClient = MongoClient() + >>> collection: Collection[Movie] = client.test.test + >>> inserted = collection.insert_one({"name": "Jurassic Park", "year": 1993 }) + >>> result = collection.find_one({"name": "Jurassic Park"}) + >>> assert result is not None + >>> assert result["year"] == 1993 + +Typed Database +-------------- + +While less common, you could specify that the documents in an entire database +match a well-defined shema using :py:class:`~typing.TypedDict`. + + +.. doctest:: + + >>> from typing import TypedDict + >>> from pymongo import MongoClient, Database + >>> class Movie(TypedDict): + ... name: str + ... year: int + ... + >>> client: MongoClient = MongoClient() + >>> db: Database[Movie] = client.test + >>> collection = db.test + >>> inserted = collection.insert_one({"name": "Jurassic Park", "year": 1993 }) + >>> result = collection.find_one({"name": "Jurassic Park"}) + >>> assert result is not None + >>> assert result["year"] == 1993 + +Typed Command +------------- +When using the :meth:`~pymongo.database.Database.command`, you can specify the document type by providing a custom :class:`~bson.codec_options.CodecOptions`: + +.. doctest:: + + >>> from pymongo import MongoClient + >>> from bson.raw_bson import RawBSONDocument + >>> from bson import CodecOptions + >>> client: MongoClient = MongoClient() + >>> options = CodecOptions(RawBSONDocument) + >>> result = client.admin.command("ping", codec_options=options) + >>> assert isinstance(result, RawBSONDocument) + +Custom :py:class:`collections.abc.Mapping` subclasses and :py:class:`~typing.TypedDict` are also supported. +For :py:class:`~typing.TypedDict`, use the form: ``options: CodecOptions[MyTypedDict] = CodecOptions(...)``. + +Typed BSON Decoding +------------------- +You can specify the document type returned by :mod:`bson` decoding functions by providing :class:`~bson.codec_options.CodecOptions`: + +.. doctest:: + + >>> from typing import Any, Dict + >>> from bson import CodecOptions, encode, decode + >>> class MyDict(Dict[str, Any]): + ... def foo(self): + ... return "bar" + ... + >>> options = CodecOptions(document_class=MyDict) + >>> doc = {"x": 1, "y": 2 } + >>> bsonbytes = encode(doc, codec_options=options) + >>> rt_document = decode(bsonbytes, codec_options=options) + >>> assert rt_document.foo() == "bar" + +:class:`~bson.raw_bson.RawBSONDocument` and :py:class:`~typing.TypedDict` are also supported. +For :py:class:`~typing.TypedDict`, use the form: ``options: CodecOptions[MyTypedDict] = CodecOptions(...)``. + + +Troubleshooting +--------------- + +Client Type Annotation +~~~~~~~~~~~~~~~~~~~~~~ +If you forget to add a type annotation for a :class:`~pymongo.mongo_client.MongoClient` object you may get the followig ``mypy`` error:: + + from pymongo import MongoClient + client = MongoClient() # error: Need type annotation for "client" + +The solution is to annotate the type as ``client: MongoClient`` or ``client: MongoClient[Dict[str, Any]]``. See `Basic Usage`_. + +Incompatible Types +~~~~~~~~~~~~~~~~~~ +If you use the generic form of :class:`~pymongo.mongo_client.MongoClient` you +may encounter a ``mypy`` error like:: + + from pymongo import MongoClient + + client: MongoClient = MongoClient() + client.test.test.insert_many( + {"a": 1} + ) # error: Dict entry 0 has incompatible type "str": "int"; + # expected "Mapping[str, Any]": "int" + + +The solution is to use ``client: MongoClient[Dict[str, Any]]`` as used in +`Basic Usage`_ . + +Actual Type Errors +~~~~~~~~~~~~~~~~~~ + +Other times ``mypy`` will catch an actual error, like the following code:: + + from pymongo import MongoClient + from typing import Mapping + client: MongoClient = MongoClient() + client.test.test.insert_one( + [{}] + ) # error: Argument 1 to "insert_one" of "Collection" has + # incompatible type "List[Dict[, ]]"; + # expected "Mapping[str, Any]" + +In this case the solution is to use ``insert_one({})``, passing a document instead of a list. + +Another example is trying to set a value on a :class:`~bson.raw_bson.RawBSONDocument`, which is read-only.:: + + from bson.raw_bson import RawBSONDocument + from pymongo import MongoClient + + client = MongoClient(document_class=RawBSONDocument) + coll = client.test.test + doc = {"my": "doc"} + coll.insert_one(doc) + retreived = coll.find_one({"_id": doc["_id"]}) + assert retreived is not None + assert len(retreived.raw) > 0 + retreived[ + "foo" + ] = "bar" # error: Unsupported target for indexed assignment + # ("RawBSONDocument") [index] + +.. _PyCharm: https://www.jetbrains.com/help/pycharm/type-hinting-in-product.html +.. _Visual Studio Code: https://code.visualstudio.com/docs/languages/python +.. _Sublime Text: https://github.com/sublimelsp/LSP-pyright +.. _type hints: https://docs.python.org/3/library/typing.html +.. _mypy: https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html +.. _limitations in mypy: https://github.com/python/mypy/issues/3737 +.. _mypy config: https://mypy.readthedocs.io/en/stable/config_file.html +.. _test_mypy module: https://github.com/mongodb/mongo-python-driver/blob/master/test/test_mypy.py diff --git a/doc/index.rst b/doc/index.rst index 8fd357b4c..b6e510ad3 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -28,6 +28,9 @@ everything you need to know to use **PyMongo**. :doc:`examples/encryption` Using PyMongo with client side encryption. +:doc:`examples/type_hints` + Using PyMongo with type hints. + :doc:`faq` Some questions that come up often. diff --git a/pymongo/common.py b/pymongo/common.py index 9007bbdfd..5a6ffbd36 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -448,7 +448,15 @@ def validate_document_class( option: str, value: Any ) -> Union[Type[MutableMapping], Type[RawBSONDocument]]: """Validate the document_class option.""" - if not issubclass(value, (abc.MutableMapping, RawBSONDocument)): + # issubclass can raise TypeError for generic aliases like SON[str, Any]. + # In that case we can use the base class for the comparison. + is_mapping = False + try: + is_mapping = issubclass(value, abc.MutableMapping) + except TypeError: + if hasattr(value, "__origin__"): + is_mapping = issubclass(value.__origin__, abc.MutableMapping) + if not is_mapping and not issubclass(value, RawBSONDocument): raise TypeError( "%s must be dict, bson.son.SON, " "bson.raw_bson.RawBSONDocument, or a " diff --git a/test/mypy_fails/insert_many_dict.py b/test/mypy_fails/insert_many_dict.py index 6e8acb67b..7cbabc28f 100644 --- a/test/mypy_fails/insert_many_dict.py +++ b/test/mypy_fails/insert_many_dict.py @@ -1,6 +1,6 @@ from pymongo import MongoClient -client = MongoClient() +client: MongoClient = MongoClient() client.test.test.insert_many( {"a": 1} ) # error: Dict entry 0 has incompatible type "str": "int"; expected "Mapping[str, Any]": "int" diff --git a/test/mypy_fails/insert_one_list.py b/test/mypy_fails/insert_one_list.py index 7a26a3ff7..12079ffc6 100644 --- a/test/mypy_fails/insert_one_list.py +++ b/test/mypy_fails/insert_one_list.py @@ -1,6 +1,6 @@ from pymongo import MongoClient -client = MongoClient() +client: MongoClient = MongoClient() client.test.test.insert_one( [{}] ) # error: Argument 1 to "insert_one" of "Collection" has incompatible type "List[Dict[, ]]"; expected "Mapping[str, Any]" diff --git a/test/test_mypy.py b/test/test_mypy.py index 6cf3eb2c8..12a6cffbe 100644 --- a/test/test_mypy.py +++ b/test/test_mypy.py @@ -309,6 +309,9 @@ class TestDocumentType(unittest.TestCase): assert retreived is not None retreived["a"] = 1 + def test_son_document_type_runtime(self) -> None: + client = MongoClient(document_class=SON[str, Any], connect=False) + class TestCommandDocumentType(unittest.TestCase): @only_type_check