Skip to content

Commit 42a08c4

Browse files
authored
PYTHON-4260 Lazily load optional imports (#1550)
1 parent 5e49363 commit 42a08c4

16 files changed

+201
-92
lines changed

Diff for: .evergreen/config.yml

+21
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,21 @@ tasks:
20712071
bash $SCRIPT -p $CONFIG -h ${github_commit} -o "mongodb" -n "mongo-python-driver"
20722072
echo '{"results": [{ "status": "PASS", "test_file": "Build", "log_raw": "Test completed" } ]}' > ${PROJECT_DIRECTORY}/test-results.json
20732073
2074+
- name: "check-import-time"
2075+
tags: ["pr"]
2076+
commands:
2077+
- command: shell.exec
2078+
type: test
2079+
params:
2080+
shell: "bash"
2081+
working_dir: src
2082+
script: |
2083+
${PREPARE_SHELL}
2084+
set -x
2085+
export BASE_SHA=${revision}
2086+
export HEAD_SHA=${github_commit}
2087+
bash .evergreen/run-import-time-test.sh
2088+
20742089
axes:
20752090
# Choice of distro
20762091
- id: platform
@@ -3046,6 +3061,12 @@ buildvariants:
30463061
tasks:
30473062
- name: "assign-pr-reviewer"
30483063

3064+
- name: rhel8-import-time
3065+
display_name: Import Time Check
3066+
run_on: rhel87-small
3067+
tasks:
3068+
- name: "check-import-time"
3069+
30493070
- name: Release
30503071
display_name: Release
30513072
batchtime: 20160 # 14 days

Diff for: .evergreen/run-import-time-test.sh

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/bin/bash -ex
2+
3+
set -o errexit # Exit the script with error if any of the commands fail
4+
set -x
5+
6+
. .evergreen/utils.sh
7+
8+
if [ -z "$PYTHON_BINARY" ]; then
9+
PYTHON_BINARY=$(find_python3)
10+
fi
11+
12+
# Use the previous commit if this was not a PR run.
13+
if [ "$BASE_SHA" == "$HEAD_SHA" ]; then
14+
BASE_SHA=$(git rev-parse HEAD~1)
15+
fi
16+
17+
function get_import_time() {
18+
local log_file
19+
createvirtualenv "$PYTHON_BINARY" import-venv
20+
python -m pip install -q ".[aws,encryption,gssapi,ocsp,snappy,zstd]"
21+
# Import once to cache modules
22+
python -c "import pymongo"
23+
log_file="pymongo-$1.log"
24+
python -X importtime -c "import pymongo" 2> $log_file
25+
}
26+
27+
get_import_time $HEAD_SHA
28+
git checkout $BASE_SHA
29+
get_import_time $BASE_SHA
30+
git checkout $HEAD_SHA
31+
python tools/compare_import_time.py $HEAD_SHA $BASE_SHA

Diff for: .evergreen/run-tests.sh

+3-1
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ if [ -n "$COVERAGE" ] && [ "$PYTHON_IMPL" = "CPython" ]; then
248248
fi
249249

250250
if [ -n "$GREEN_FRAMEWORK" ]; then
251-
python -m pip install $GREEN_FRAMEWORK
251+
# Install all optional deps to ensure lazy imports are getting patched.
252+
python -m pip install -q ".[aws,encryption,gssapi,ocsp,snappy,zstd]"
253+
python -m pip install $GREEN_FRAMEWORK
252254
fi
253255

254256
# Show the installed packages

Diff for: doc/changelog.rst

+6
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ Unavoidable breaking changes
7474
>>> dict_to_SON(data_as_dict)
7575
SON([('driver', SON([('name', 'PyMongo'), ('version', '4.7.0.dev0')])), ('os', SON([('type', 'Darwin'), ('name', 'Darwin'), ('architecture', 'arm64'), ('version', '14.3')])), ('platform', 'CPython 3.11.6.final.0')])
7676

77+
- PyMongo now uses `lazy imports <https://docs.python.org/3/library/importlib.html#implementing-lazy-imports>`_ for external dependencies.
78+
If you are relying on any kind of monkey-patching of the standard library, you may need to explicitly import those external libraries in addition
79+
to ``pymongo`` before applying the patch. Note that we test with ``gevent`` and ``eventlet`` patching, and those continue to work.
80+
81+
- The "aws" extra now requires minimum version of ``1.1.0`` for ``pymongo_auth_aws``.
82+
7783
Changes in Version 4.6.2
7884
------------------------
7985

Diff for: pymongo/_azure_helpers.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717

1818
import json
1919
from typing import Any, Optional
20-
from urllib.request import Request, urlopen
2120

2221

2322
def _get_azure_response(
2423
resource: str, client_id: Optional[str] = None, timeout: float = 5
2524
) -> dict[str, Any]:
25+
# Deferred import to save overall import time.
26+
from urllib.request import Request, urlopen
27+
2628
url = "http://169.254.169.254/metadata/identity/oauth2/token"
2729
url += "?api-version=2018-02-01"
2830
url += f"&resource={resource}"

Diff for: pymongo/_csot.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
from collections import deque
2222
from contextlib import AbstractContextManager
2323
from contextvars import ContextVar, Token
24-
from typing import Any, Callable, Deque, MutableMapping, Optional, TypeVar, cast
24+
from typing import TYPE_CHECKING, Any, Callable, Deque, MutableMapping, Optional, TypeVar, cast
2525

26-
from pymongo.write_concern import WriteConcern
26+
if TYPE_CHECKING:
27+
from pymongo.write_concern import WriteConcern
2728

2829
TIMEOUT: ContextVar[Optional[float]] = ContextVar("TIMEOUT", default=None)
2930
RTT: ContextVar[float] = ContextVar("RTT", default=0.0)

Diff for: pymongo/_lazy_import.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2024-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you
4+
# may not use this file except in compliance with the License. You
5+
# may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12+
# implied. See the License for the specific language governing
13+
# permissions and limitations under the License.
14+
from __future__ import annotations
15+
16+
import importlib.util
17+
import sys
18+
from types import ModuleType
19+
20+
21+
def lazy_import(name: str) -> ModuleType:
22+
"""Lazily import a module by name
23+
24+
From https://docs.python.org/3/library/importlib.html#implementing-lazy-imports
25+
"""
26+
try:
27+
spec = importlib.util.find_spec(name)
28+
except ValueError:
29+
raise ModuleNotFoundError(name=name) from None
30+
if spec is None:
31+
raise ModuleNotFoundError(name=name)
32+
assert spec is not None
33+
loader = importlib.util.LazyLoader(spec.loader) # type:ignore[arg-type]
34+
spec.loader = loader
35+
module = importlib.util.module_from_spec(spec)
36+
sys.modules[name] = module
37+
loader.exec_module(module)
38+
return module

Diff for: pymongo/auth_aws.py

+23-46
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,16 @@
1515
"""MONGODB-AWS Authentication helpers."""
1616
from __future__ import annotations
1717

18-
try:
19-
import pymongo_auth_aws # type:ignore[import]
20-
from pymongo_auth_aws import (
21-
AwsCredential,
22-
AwsSaslContext,
23-
PyMongoAuthAwsError,
24-
)
18+
from pymongo._lazy_import import lazy_import
2519

20+
try:
21+
pymongo_auth_aws = lazy_import("pymongo_auth_aws")
2622
_HAVE_MONGODB_AWS = True
2723
except ImportError:
28-
29-
class AwsSaslContext: # type: ignore
30-
def __init__(self, credentials: MongoCredential):
31-
pass
32-
3324
_HAVE_MONGODB_AWS = False
3425

35-
try:
36-
from pymongo_auth_aws.auth import ( # type:ignore[import]
37-
set_cached_credentials,
38-
set_use_cached_credentials,
39-
)
40-
41-
# Enable credential caching.
42-
set_use_cached_credentials(True)
43-
except ImportError:
44-
45-
def set_cached_credentials(_creds: Optional[AwsCredential]) -> None:
46-
pass
4726

48-
49-
from typing import TYPE_CHECKING, Any, Mapping, Optional, Type
27+
from typing import TYPE_CHECKING, Any, Mapping, Type
5028

5129
import bson
5230
from bson.binary import Binary
@@ -58,21 +36,6 @@ def set_cached_credentials(_creds: Optional[AwsCredential]) -> None:
5836
from pymongo.pool import Connection
5937

6038

61-
class _AwsSaslContext(AwsSaslContext): # type: ignore
62-
# Dependency injection:
63-
def binary_type(self) -> Type[Binary]:
64-
"""Return the bson.binary.Binary type."""
65-
return Binary
66-
67-
def bson_encode(self, doc: Mapping[str, Any]) -> bytes:
68-
"""Encode a dictionary to BSON."""
69-
return bson.encode(doc)
70-
71-
def bson_decode(self, data: _ReadableBuffer) -> Mapping[str, Any]:
72-
"""Decode BSON to a dictionary."""
73-
return bson.decode(data)
74-
75-
7639
def _authenticate_aws(credentials: MongoCredential, conn: Connection) -> None:
7740
"""Authenticate using MONGODB-AWS."""
7841
if not _HAVE_MONGODB_AWS:
@@ -84,9 +47,23 @@ def _authenticate_aws(credentials: MongoCredential, conn: Connection) -> None:
8447
if conn.max_wire_version < 9:
8548
raise ConfigurationError("MONGODB-AWS authentication requires MongoDB version 4.4 or later")
8649

50+
class AwsSaslContext(pymongo_auth_aws.AwsSaslContext): # type: ignore
51+
# Dependency injection:
52+
def binary_type(self) -> Type[Binary]:
53+
"""Return the bson.binary.Binary type."""
54+
return Binary
55+
56+
def bson_encode(self, doc: Mapping[str, Any]) -> bytes:
57+
"""Encode a dictionary to BSON."""
58+
return bson.encode(doc)
59+
60+
def bson_decode(self, data: _ReadableBuffer) -> Mapping[str, Any]:
61+
"""Decode BSON to a dictionary."""
62+
return bson.decode(data)
63+
8764
try:
88-
ctx = _AwsSaslContext(
89-
AwsCredential(
65+
ctx = AwsSaslContext(
66+
pymongo_auth_aws.AwsCredential(
9067
credentials.username,
9168
credentials.password,
9269
credentials.mechanism_properties.aws_session_token,
@@ -108,14 +85,14 @@ def _authenticate_aws(credentials: MongoCredential, conn: Connection) -> None:
10885
if res["done"]:
10986
# SASL complete.
11087
break
111-
except PyMongoAuthAwsError as exc:
88+
except pymongo_auth_aws.PyMongoAuthAwsError as exc:
11289
# Clear the cached credentials if we hit a failure in auth.
113-
set_cached_credentials(None)
90+
pymongo_auth_aws.set_cached_credentials(None)
11491
# Convert to OperationFailure and include pymongo-auth-aws version.
11592
raise OperationFailure(
11693
f"{exc} (pymongo-auth-aws version {pymongo_auth_aws.__version__})"
11794
) from None
11895
except Exception:
11996
# Clear the cached credentials if we hit a failure in auth.
120-
set_cached_credentials(None)
97+
pymongo_auth_aws.set_cached_credentials(None)
12198
raise

Diff for: pymongo/compression_support.py

+9-10
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,31 @@
1616
import warnings
1717
from typing import Any, Iterable, Optional, Union
1818

19-
try:
20-
import snappy # type:ignore[import]
19+
from pymongo._lazy_import import lazy_import
20+
from pymongo.hello import HelloCompat
21+
from pymongo.monitoring import _SENSITIVE_COMMANDS
2122

23+
try:
24+
snappy = lazy_import("snappy")
2225
_HAVE_SNAPPY = True
2326
except ImportError:
2427
# python-snappy isn't available.
2528
_HAVE_SNAPPY = False
2629

2730
try:
28-
import zlib
31+
zlib = lazy_import("zlib")
2932

3033
_HAVE_ZLIB = True
3134
except ImportError:
3235
# Python built without zlib support.
3336
_HAVE_ZLIB = False
3437

3538
try:
36-
from zstandard import ZstdCompressor, ZstdDecompressor
37-
39+
zstandard = lazy_import("zstandard")
3840
_HAVE_ZSTD = True
3941
except ImportError:
4042
_HAVE_ZSTD = False
4143

42-
from pymongo.hello import HelloCompat
43-
from pymongo.monitoring import _SENSITIVE_COMMANDS
44-
4544
_SUPPORTED_COMPRESSORS = {"snappy", "zlib", "zstd"}
4645
_NO_COMPRESSION = {HelloCompat.CMD, HelloCompat.LEGACY_CMD}
4746
_NO_COMPRESSION.update(_SENSITIVE_COMMANDS)
@@ -138,7 +137,7 @@ class ZstdContext:
138137
def compress(data: bytes) -> bytes:
139138
# ZstdCompressor is not thread safe.
140139
# TODO: Use a pool?
141-
return ZstdCompressor().compress(data)
140+
return zstandard.ZstdCompressor().compress(data)
142141

143142

144143
def decompress(data: bytes, compressor_id: int) -> bytes:
@@ -153,6 +152,6 @@ def decompress(data: bytes, compressor_id: int) -> bytes:
153152
elif compressor_id == ZstdContext.compressor_id:
154153
# ZstdDecompressor is not thread safe.
155154
# TODO: Use a pool?
156-
return ZstdDecompressor().decompress(data)
155+
return zstandard.ZstdDecompressor().decompress(data)
157156
else:
158157
raise ValueError("Unknown compressorId %d" % (compressor_id,))

Diff for: pymongo/errors.py

+1-11
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,14 @@
1515
"""Exceptions raised by PyMongo."""
1616
from __future__ import annotations
1717

18+
from ssl import SSLCertVerificationError as _CertificateError # noqa: F401
1819
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional, Sequence, Union
1920

2021
from bson.errors import InvalidDocument
2122

2223
if TYPE_CHECKING:
2324
from pymongo.typings import _DocumentOut
2425

25-
try:
26-
# CPython 3.7+
27-
from ssl import SSLCertVerificationError as _CertificateError
28-
except ImportError:
29-
try:
30-
from ssl import CertificateError as _CertificateError
31-
except ImportError:
32-
33-
class _CertificateError(ValueError): # type: ignore
34-
pass
35-
3626

3727
class PyMongoError(Exception):
3828
"""Base class for all PyMongo exceptions."""

0 commit comments

Comments
 (0)