Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 42a08c4

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

16 files changed

+201
-92
lines changed
 

‎.evergreen/config.yml

Lines changed: 21 additions & 0 deletions
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

‎.evergreen/run-import-time-test.sh

Lines changed: 31 additions & 0 deletions
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

‎.evergreen/run-tests.sh

Lines changed: 3 additions & 1 deletion
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

‎doc/changelog.rst

Lines changed: 6 additions & 0 deletions
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

‎pymongo/_azure_helpers.py

Lines changed: 3 additions & 1 deletion
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}"

‎pymongo/_csot.py

Lines changed: 3 additions & 2 deletions
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)

‎pymongo/_lazy_import.py

Lines changed: 38 additions & 0 deletions
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

‎pymongo/auth_aws.py

Lines changed: 23 additions & 46 deletions
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

‎pymongo/compression_support.py

Lines changed: 9 additions & 10 deletions
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,))

‎pymongo/errors.py

Lines changed: 1 addition & 11 deletions
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."""

‎pymongo/pyopenssl_context.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,10 @@
2525
from ipaddress import ip_address as _ip_address
2626
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
2727

28-
from cryptography.x509 import load_der_x509_certificate as _load_der_x509_certificate
2928
from OpenSSL import SSL as _SSL
3029
from OpenSSL import crypto as _crypto
31-
from service_identity import CertificateError as _SICertificateError
32-
from service_identity import VerificationError as _SIVerificationError
33-
from service_identity.pyopenssl import verify_hostname as _verify_hostname
34-
from service_identity.pyopenssl import verify_ip_address as _verify_ip_address
3530

31+
from pymongo._lazy_import import lazy_import
3632
from pymongo.errors import ConfigurationError as _ConfigurationError
3733
from pymongo.errors import _CertificateError # type:ignore[attr-defined]
3834
from pymongo.ocsp_cache import _OCSPCache
@@ -41,6 +37,10 @@
4137
from pymongo.socket_checker import _errno_from_exception
4238
from pymongo.write_concern import validate_boolean
4339

40+
_x509 = lazy_import("cryptography.x509")
41+
_service_identity = lazy_import("service_identity")
42+
_service_identity_pyopenssl = lazy_import("service_identity.pyopenssl")
43+
4444
if TYPE_CHECKING:
4545
from ssl import VerifyMode
4646

@@ -340,7 +340,7 @@ def _load_wincerts(self, store: str) -> None:
340340
if encoding == "x509_asn":
341341
if trust is True or oid in trust:
342342
cert_store.add_cert(
343-
_crypto.X509.from_cryptography(_load_der_x509_certificate(cert))
343+
_crypto.X509.from_cryptography(_x509.load_der_x509_certificate(cert))
344344
)
345345

346346
def load_default_certs(self) -> None:
@@ -406,9 +406,12 @@ def wrap_socket(
406406
if self.check_hostname and server_hostname is not None:
407407
try:
408408
if _is_ip_address(server_hostname):
409-
_verify_ip_address(ssl_conn, server_hostname)
409+
_service_identity_pyopenssl.verify_ip_address(ssl_conn, server_hostname)
410410
else:
411-
_verify_hostname(ssl_conn, server_hostname)
412-
except (_SICertificateError, _SIVerificationError) as exc:
411+
_service_identity_pyopenssl.verify_hostname(ssl_conn, server_hostname)
412+
except (
413+
_service_identity.SICertificateError,
414+
_service_identity.SIVerificationError,
415+
) as exc:
413416
raise _CertificateError(str(exc)) from None
414417
return ssl_conn

‎pymongo/srv_resolver.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,19 @@
1717

1818
import ipaddress
1919
import random
20-
from typing import Any, Optional, Union
21-
22-
try:
23-
from dns import resolver
24-
25-
_HAVE_DNSPYTHON = True
26-
except ImportError:
27-
_HAVE_DNSPYTHON = False
20+
from typing import TYPE_CHECKING, Any, Optional, Union
2821

22+
from pymongo._lazy_import import lazy_import
2923
from pymongo.common import CONNECT_TIMEOUT
3024
from pymongo.errors import ConfigurationError
3125

26+
if TYPE_CHECKING:
27+
from dns import resolver
28+
else:
29+
resolver = lazy_import("dns.resolver")
30+
31+
_HAVE_DNSPYTHON = True
32+
3233

3334
# dnspython can return bytes or str from various parts
3435
# of its API depending on version. We always want str.

‎pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ dependencies = [
4545

4646
[project.optional-dependencies]
4747
aws = [
48-
"pymongo-auth-aws<2.0.0",
48+
"pymongo-auth-aws>=1.1.0,<2.0.0",
4949
]
5050
encryption = [
5151
"pymongo[aws]",
@@ -207,6 +207,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|dummy.*)$"
207207
"test/*.py" = ["PT", "E402", "PLW", "SIM", "E741", "PTH", "S", "B904", "E722", "T201",
208208
"RET", "ARG", "F405", "B028", "PGH001", "B018", "F403", "RUF015", "E731", "B007",
209209
"UP031", "F401", "B023", "F811"]
210+
"tools/*.py" = ["T201"]
210211
"green_framework_test.py" = ["T201"]
211212

212213
[tool.coverage.run]

‎tools/compare_import_time.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2024-Present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You 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 implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from __future__ import annotations
15+
16+
import sys
17+
18+
base_sha = sys.argv[-1]
19+
head_sha = sys.argv[-2]
20+
21+
22+
def get_total_time(sha: str) -> int:
23+
with open(f"pymongo-{sha}.log") as fid:
24+
last_line = fid.readlines()[-1]
25+
return int(last_line.split()[4])
26+
27+
28+
base_time = get_total_time(base_sha)
29+
curr_time = get_total_time(head_sha)
30+
31+
# Check if we got 20% or more slower.
32+
change = int((curr_time - base_time) / base_time * 100)
33+
if change > 20:
34+
print(f"PyMongo import got {change} percent worse")
35+
sys.exit(1)
36+
37+
print(f"Import time changed by {change} percent")

‎tools/ensure_future_annotations_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
missing.append(path)
3636

3737
if missing:
38-
print(f"Missing '{pattern}' import in:") # noqa: T201
38+
print(f"Missing '{pattern}' import in:")
3939
for item in missing:
40-
print(item) # noqa: T201
40+
print(item)
4141
sys.exit(1)

‎tools/fail_if_no_c.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
parent_dir = Path(pymongo.__path__[0]).parent
3636
for pkg in ["pymongo", "bson", "grifs"]:
3737
for so_file in Path(f"{parent_dir}/{pkg}").glob("*.so"):
38-
print(f"Checking universal2 compatibility in {so_file}...") # noqa: T201
38+
print(f"Checking universal2 compatibility in {so_file}...")
3939
output = subprocess.check_output(["file", so_file]) # noqa: S603, S607
4040
if "arm64" not in output.decode("utf-8"):
4141
sys.exit("Universal wheel was not compiled with arm64 support")

0 commit comments

Comments
 (0)
Please sign in to comment.