Skip to content

Commit 550388e

Browse files
authored
Merge pull request #12566 from ichard26/lazy-imports
Avoid network/index related imports for commands that won't need 'em
2 parents 06d21db + 23f4ad5 commit 550388e

File tree

8 files changed

+82
-22
lines changed

8 files changed

+82
-22
lines changed

news/4768.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Reduce startup time of commands (e.g. show, freeze) that do not access the network by 15-30%.

src/pip/_internal/commands/inspect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from pip import __version__
99
from pip._internal.cli import cmdoptions
10-
from pip._internal.cli.req_command import Command
10+
from pip._internal.cli.base_command import Command
1111
from pip._internal.cli.status_codes import SUCCESS
1212
from pip._internal.metadata import BaseDistribution, get_environment
1313
from pip._internal.utils.compat import stdlib_pkgs

src/pip/_internal/distributions/base.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import abc
2-
from typing import Optional
2+
from typing import TYPE_CHECKING, Optional
33

4-
from pip._internal.index.package_finder import PackageFinder
54
from pip._internal.metadata.base import BaseDistribution
65
from pip._internal.req import InstallRequirement
76

7+
if TYPE_CHECKING:
8+
from pip._internal.index.package_finder import PackageFinder
9+
810

911
class AbstractDistribution(metaclass=abc.ABCMeta):
1012
"""A base class for handling installable artifacts.
@@ -44,7 +46,7 @@ def get_metadata_distribution(self) -> BaseDistribution:
4446
@abc.abstractmethod
4547
def prepare_distribution_metadata(
4648
self,
47-
finder: PackageFinder,
49+
finder: "PackageFinder",
4850
build_isolation: bool,
4951
check_build_deps: bool,
5052
) -> None:

src/pip/_internal/distributions/sdist.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import logging
2-
from typing import Iterable, Optional, Set, Tuple
2+
from typing import TYPE_CHECKING, Iterable, Optional, Set, Tuple
33

44
from pip._internal.build_env import BuildEnvironment
55
from pip._internal.distributions.base import AbstractDistribution
66
from pip._internal.exceptions import InstallationError
7-
from pip._internal.index.package_finder import PackageFinder
87
from pip._internal.metadata import BaseDistribution
98
from pip._internal.utils.subprocess import runner_with_spinner_message
109

10+
if TYPE_CHECKING:
11+
from pip._internal.index.package_finder import PackageFinder
12+
1113
logger = logging.getLogger(__name__)
1214

1315

@@ -29,7 +31,7 @@ def get_metadata_distribution(self) -> BaseDistribution:
2931

3032
def prepare_distribution_metadata(
3133
self,
32-
finder: PackageFinder,
34+
finder: "PackageFinder",
3335
build_isolation: bool,
3436
check_build_deps: bool,
3537
) -> None:
@@ -66,7 +68,7 @@ def prepare_distribution_metadata(
6668
self._raise_missing_reqs(missing)
6769
self.req.prepare_metadata()
6870

69-
def _prepare_build_backend(self, finder: PackageFinder) -> None:
71+
def _prepare_build_backend(self, finder: "PackageFinder") -> None:
7072
# Isolate in a BuildEnvironment and install the build-time
7173
# requirements.
7274
pyproject_requires = self.req.pyproject_requires
@@ -110,7 +112,7 @@ def _get_build_requires_editable(self) -> Iterable[str]:
110112
with backend.subprocess_runner(runner):
111113
return backend.get_requires_for_build_editable()
112114

113-
def _install_build_reqs(self, finder: PackageFinder) -> None:
115+
def _install_build_reqs(self, finder: "PackageFinder") -> None:
114116
# Install any extra build dependencies that the backend requests.
115117
# This must be done in a second pass, as the pyproject.toml
116118
# dependencies must be installed before we can call the backend.

src/pip/_internal/distributions/wheel.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from typing import Optional
1+
from typing import TYPE_CHECKING, Optional
22

33
from pip._vendor.packaging.utils import canonicalize_name
44

55
from pip._internal.distributions.base import AbstractDistribution
6-
from pip._internal.index.package_finder import PackageFinder
76
from pip._internal.metadata import (
87
BaseDistribution,
98
FilesystemWheel,
109
get_wheel_distribution,
1110
)
1211

12+
if TYPE_CHECKING:
13+
from pip._internal.index.package_finder import PackageFinder
14+
1315

1416
class WheelDistribution(AbstractDistribution):
1517
"""Represents a wheel distribution.
@@ -33,7 +35,7 @@ def get_metadata_distribution(self) -> BaseDistribution:
3335

3436
def prepare_distribution_metadata(
3537
self,
36-
finder: PackageFinder,
38+
finder: "PackageFinder",
3739
build_isolation: bool,
3840
check_build_deps: bool,
3941
) -> None:

src/pip/_internal/exceptions.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
from itertools import chain, groupby, repeat
1616
from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
1717

18-
from pip._vendor.requests.models import Request, Response
1918
from pip._vendor.rich.console import Console, ConsoleOptions, RenderResult
2019
from pip._vendor.rich.markup import escape
2120
from pip._vendor.rich.text import Text
2221

2322
if TYPE_CHECKING:
2423
from hashlib import _Hash
2524

25+
from pip._vendor.requests.models import Request, Response
26+
2627
from pip._internal.metadata import BaseDistribution
2728
from pip._internal.req.req_install import InstallRequirement
2829

@@ -293,8 +294,8 @@ class NetworkConnectionError(PipError):
293294
def __init__(
294295
self,
295296
error_msg: str,
296-
response: Optional[Response] = None,
297-
request: Optional[Request] = None,
297+
response: Optional["Response"] = None,
298+
request: Optional["Request"] = None,
298299
) -> None:
299300
"""
300301
Initialize NetworkConnectionError with `request` and `response`

src/pip/_internal/req/req_file.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@
2525
from pip._internal.cli import cmdoptions
2626
from pip._internal.exceptions import InstallationError, RequirementsFileParseError
2727
from pip._internal.models.search_scope import SearchScope
28-
from pip._internal.network.session import PipSession
29-
from pip._internal.network.utils import raise_for_status
3028
from pip._internal.utils.encoding import auto_decode
3129
from pip._internal.utils.urls import get_url_scheme
3230

3331
if TYPE_CHECKING:
3432
from pip._internal.index.package_finder import PackageFinder
33+
from pip._internal.network.session import PipSession
3534

3635
__all__ = ["parse_requirements"]
3736

@@ -133,7 +132,7 @@ def __init__(
133132

134133
def parse_requirements(
135134
filename: str,
136-
session: PipSession,
135+
session: "PipSession",
137136
finder: Optional["PackageFinder"] = None,
138137
options: Optional[optparse.Values] = None,
139138
constraint: bool = False,
@@ -210,7 +209,7 @@ def handle_option_line(
210209
lineno: int,
211210
finder: Optional["PackageFinder"] = None,
212211
options: Optional[optparse.Values] = None,
213-
session: Optional[PipSession] = None,
212+
session: Optional["PipSession"] = None,
214213
) -> None:
215214
if opts.hashes:
216215
logger.warning(
@@ -278,7 +277,7 @@ def handle_line(
278277
line: ParsedLine,
279278
options: Optional[optparse.Values] = None,
280279
finder: Optional["PackageFinder"] = None,
281-
session: Optional[PipSession] = None,
280+
session: Optional["PipSession"] = None,
282281
) -> Optional[ParsedRequirement]:
283282
"""Handle a single parsed requirements line; This can result in
284283
creating/yielding requirements, or updating the finder.
@@ -321,7 +320,7 @@ def handle_line(
321320
class RequirementsFileParser:
322321
def __init__(
323322
self,
324-
session: PipSession,
323+
session: "PipSession",
325324
line_parser: LineParser,
326325
) -> None:
327326
self._session = session
@@ -526,7 +525,7 @@ def expand_env_variables(lines_enum: ReqFileLines) -> ReqFileLines:
526525
yield line_number, line
527526

528527

529-
def get_file_content(url: str, session: PipSession) -> Tuple[str, str]:
528+
def get_file_content(url: str, session: "PipSession") -> Tuple[str, str]:
530529
"""Gets the content of a file; it may be a filename, file: URL, or
531530
http: URL. Returns (location, content). Content is unicode.
532531
Respects # -*- coding: declarations on the retrieved files.
@@ -538,6 +537,9 @@ def get_file_content(url: str, session: PipSession) -> Tuple[str, str]:
538537

539538
# Pip has special support for file:// URLs (LocalFSAdapter).
540539
if scheme in ["http", "https", "file"]:
540+
# Delay importing heavy network modules until absolutely necessary.
541+
from pip._internal.network.utils import raise_for_status
542+
541543
resp = session.get(url)
542544
raise_for_status(resp)
543545
return resp.url, resp.text

tests/functional/test_cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Basic CLI functionality checks.
22
"""
3+
import subprocess
4+
import sys
5+
from pathlib import Path
36
from textwrap import dedent
47

58
import pytest
69

10+
from pip._internal.commands import commands_dict
711
from tests.lib import PipTestEnvironment
812

913

@@ -45,3 +49,49 @@ def test_entrypoints_work(entrypoint: str, script: PipTestEnvironment) -> None:
4549
result2 = script.run("fake_pip", "-V", allow_stderr_warning=True)
4650
assert result.stdout == result2.stdout
4751
assert "old script wrapper" in result2.stderr
52+
53+
54+
@pytest.mark.parametrize(
55+
"command",
56+
sorted(
57+
set(commands_dict).symmetric_difference(
58+
# Exclude commands that are expected to use the network.
59+
# TODO: uninstall and list should only import network modules as needed
60+
{"install", "uninstall", "download", "search", "index", "wheel", "list"}
61+
)
62+
),
63+
)
64+
def test_no_network_imports(command: str, tmp_path: Path) -> None:
65+
"""
66+
Verify that commands that don't access the network do NOT import network code.
67+
68+
This helps to reduce the startup time of these commands.
69+
70+
Note: This won't catch lazy network imports, but it'll catch top-level
71+
network imports which were accidently added (which is the most likely way
72+
to regress anyway).
73+
"""
74+
file = tmp_path / f"imported_modules_for_{command}.txt"
75+
code = f"""
76+
import runpy
77+
import sys
78+
79+
sys.argv[1:] = [{command!r}, "--help"]
80+
81+
try:
82+
runpy.run_module("pip", alter_sys=True, run_name="__main__")
83+
finally:
84+
with open({str(file)!r}, "w") as f:
85+
print(*sys.modules.keys(), sep="\\n", file=f)
86+
"""
87+
subprocess.run(
88+
[sys.executable],
89+
input=code,
90+
encoding="utf-8",
91+
check=True,
92+
)
93+
imported = file.read_text().splitlines()
94+
assert not any("pip._internal.index" in mod for mod in imported)
95+
assert not any("pip._internal.network" in mod for mod in imported)
96+
assert not any("requests" in mod for mod in imported)
97+
assert not any("urllib3" in mod for mod in imported)

0 commit comments

Comments
 (0)