Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f22e3b7
Move type checking outside pre-commit
pradyunsg Jul 21, 2024
63d785c
Create full dependency group for type checking
notatallshaw Jul 11, 2025
e8b735f
Move all vendored libraries with stubs into TYPE_CHECKING block
notatallshaw Jul 11, 2025
9931fb5
Add type ignores to distutils.command.install SCHEMES
notatallshaw Jul 11, 2025
02c8b5c
Fix all "Incompatible types in assignment"
notatallshaw Jul 11, 2025
ad9bf38
Add `type: ignore`s to pkg_resources
notatallshaw Jul 11, 2025
47d03cc
Match MultiDomainBasicAuth.__call__ types with it's parent class
notatallshaw Jul 11, 2025
74acb42
Fix remaining `collector.py` type error
notatallshaw Jul 11, 2025
ab0bcc3
Fix remaining type errors in `xmlrpc.py`
notatallshaw Jul 11, 2025
7eebf11
Cast to real responses in test network utils, download, and auth
notatallshaw Jul 11, 2025
b3002e3
Force string in get_user_agent for test network session
notatallshaw Jul 11, 2025
914ccdb
cast to real WorkingSet in test metadata pkg resources
notatallshaw Jul 11, 2025
f0fce92
Remove unneeded `CaseInsensitiveDict` in wheel.py
notatallshaw Jul 11, 2025
f9af7ef
Use real Response in test_operations_prepare
notatallshaw Jul 11, 2025
c0e93c1
Linting
notatallshaw Jul 11, 2025
ac3d85c
Add developer documentation
notatallshaw Jul 11, 2025
a203957
Add missing PyYAML and types-PyYAML dependencies
notatallshaw Jul 11, 2025
eaf7836
Simpler `session.py` fixes
notatallshaw Jul 17, 2025
20c5498
Workaround mixed types in pyproject toml table
notatallshaw Jul 17, 2025
49f5a13
cast test server to mock server
notatallshaw Jul 17, 2025
32e4afa
Fix merge issue in pre-commit
notatallshaw Jul 17, 2025
cc1ad64
Merge branch 'main' into make-mypy-nox-session
notatallshaw Jul 17, 2025
1aea5ba
Remove `if not TYPE_CHECKING`
notatallshaw Jul 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ jobs:
- run: pip install nox
- run: nox -s docs

typecheck:
name: typecheck
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- run: pip install nox
- run: nox -s typecheck

determine-changes:
runs-on: ubuntu-22.04
outputs:
Expand Down Expand Up @@ -252,6 +264,7 @@ jobs:

needs:
- determine-changes
- typecheck
- docs
- packaging
- tests-unix
Expand Down
16 changes: 0 additions & 16 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,6 @@ repos:
- id: ruff-check
args: [--fix]

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.16.1
hooks:
- id: mypy
exclude: tests/data
args: ["--pretty", "--show-error-codes"]
additional_dependencies: [
'keyring==24.2.0',
'nox==2024.03.02',
'pytest',
'types-docutils==0.20.0.3',
'types-setuptools==68.2.0.0',
'types-freezegun==1.1.10',
'types-pyyaml==6.0.12.12',
]

- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
Expand Down
17 changes: 17 additions & 0 deletions docs/html/development/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ To use linters locally, run:
readability problems.


Type Checking
=============

pip uses :pypi:`mypy` for static type checking to help catch type errors early.
Type checking is configured to run across the entire codebase to ensure type safety.

To run type checking locally:

.. code-block:: console

$ nox -s typecheck

This will run mypy on the ``src/pip``, ``tests``, and ``tools`` directories,
as well as ``noxfile.py``. Type checking helps maintain code quality by
catching type-related issues before they make it into the codebase.


Running pip under a debugger
============================

Expand Down
29 changes: 23 additions & 6 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@
"common-wheels": "tests/data/common_wheels",
"protected-pip": "tools/protected_pip.py",
}
REQUIREMENTS = {
"docs": "docs/requirements.txt",
}

AUTHORS_FILE = "AUTHORS.txt"
VERSION_FILE = "src/pip/__init__.py"
Expand Down Expand Up @@ -132,7 +129,7 @@ def test(session: nox.Session) -> None:

@nox.session
def docs(session: nox.Session) -> None:
session.install("-r", REQUIREMENTS["docs"])
session.install("--group", "docs")

def get_sphinx_build_command(kind: str) -> list[str]:
# Having the conf.py in the docs/html is weird but needed because we
Expand Down Expand Up @@ -161,7 +158,7 @@ def get_sphinx_build_command(kind: str) -> list[str]:

@nox.session(name="docs-live")
def docs_live(session: nox.Session) -> None:
session.install("-r", REQUIREMENTS["docs"], "sphinx-autobuild")
session.install("--group", "docs", "sphinx-autobuild")

session.run(
"sphinx-autobuild",
Expand All @@ -174,6 +171,26 @@ def docs_live(session: nox.Session) -> None:
)


@nox.session
def typecheck(session: nox.Session) -> None:
# Install test and test-types dependency groups
run_with_protected_pip(
session,
"install",
"--group",
"all",
)

session.run(
"mypy",
"src/pip",
"tests",
"tools",
"noxfile.py",
"--exclude=tests/data",
)


@nox.session
def lint(session: nox.Session) -> None:
session.install("pre-commit")
Expand Down Expand Up @@ -267,7 +284,7 @@ def coverage(session: nox.Session) -> None:
run_with_protected_pip(session, "install", ".")

# Install test dependencies
run_with_protected_pip(session, "install", "-r", REQUIREMENTS["tests"])
run_with_protected_pip(session, "install", "--group", "docs")

if not os.path.exists(".coverage-output"):
os.mkdir(".coverage-output")
Expand Down
42 changes: 42 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,48 @@ test-common-wheels = [
"pytest-subket >= 0.8.1",
]

docs = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do dependency groups for docs in this PR? Seems like it could be a separate one...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation is to make everything available as a dependency group for type hinting so that all dependencies are exposed in the pyproject.toml and therefore more "noticeable" by future maintainers / tools.

I have no problem splitting the docs group into a separate PR, it just seemed a small enough change to include here (I initially didn't split them out in my previous PR #13475 because I didn't properly test that it worked, but on making this PR I realized that as long as you've installed pip it does).

"sphinx ~= 7.0",
# currently incompatible with sphinxcontrib-towncrier
# https://github.com/sphinx-contrib/sphinxcontrib-towncrier/issues/92
"towncrier < 24",
"furo",
"myst_parser",
"sphinx-copybutton",
"sphinx-inline-tabs",
"sphinxcontrib-towncrier >= 0.2.0a0",
"sphinx-issues"
]

# Libraries that are not required for pip to run,
# but are used in some optional features.
all-optional = ["keyring"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems very curious to me to be using dependency-groups to specify optional extras...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So historically pip has a couple of dependencies it has where if it is present in the environment it will behave differently, notable keyring and truststore (though this is now full vendored). I don't the history of not providing them as an optional dependency, but probably to do with bootstrapping?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pip has historically had no dependencies at all. That's an explicit and deliberate design choice - pip is not a package that you install "normally"1, so it needs to be usable in a situation where no pre-existing package installer is available. Working out how to install optional dependencies while bootstrapping pip is non-trivial (and not something I'd want to support), while once pip is installed, pip install keyring is just as easy as (if not easier than) doing something like pip install --force-reinstall pip[keyring]. Given that we currently don't have any command for listing available extras, it's not even more discoverable.

So rather than have to deal with the support issues of all of this, we simply note that if the user has keyring installed, pip will make use of it.

I don't object to having an all-optional dependency group for developers to install anything that affects the behaviour of pip (although I question the usefulness of it, from my own perspective as a developer), but I don't think we should try to navigate the complexities of making extras work as an end user interface for pip.

Footnotes

  1. Even if you can sort of do so, by using --python or the pip zipapp to install pip. Those still need special care, and aren't what I'd call entirely normal situations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't object to having an all-optional dependency group for developers to install anything that affects the behaviour of pip (although I question the usefulness of it, from my own perspective as a developer), but I don't think we should try to navigate the complexities of making extras work as an end user interface for pip.

The usefulness here to me is for two reasons:

  1. It highlights these situations directly in the pyproject.toml
  2. It allows install all packages that might require type hints to be specified in a very visible place (not hidden in the noxfile or pre-commit config)

But this is not a sticking point for me, I'm willing to put it in the noxfile directly.


nox = ["nox"] # noxfile.py
update-rtd-redirects = ["httpx", "rich", "PyYAML"] # tools/update-rtd-redirects.py

type-checking = [
# Actual type checker:
"mypy",

# Stub libraries that contain type hints as a separate package:
"types-docutils", # via sphinx (test dependency)
"types-requests", # vendored
"types-urllib3", # vendored (can be removed when we upgrade to urllib3 >= 2.0)
"types-setuptools", # test dependency and used in distutils_hack
"types-six", # via python-dateutil via freezegun (test dependency)
"types-PyYAML", # update-rtd-redirects dependency
]

all = [
{include-group = "test"},
{include-group = "docs"},
{include-group = "nox"},
{include-group = "all-optional"},
{include-group = "update-rtd-redirects"},
{include-group = "type-checking"},
]

[tool.setuptools]
package-dir = {"" = "src"}
include-package-data = false
Expand Down
8 changes: 7 additions & 1 deletion src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import shutil
import site
from optparse import SUPPRESS_HELP, Values
from typing import TYPE_CHECKING

from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.requests.exceptions import InvalidProxyURL
from pip._vendor.rich import print_json

# Eagerly import self_outdated_check to avoid crashes. Otherwise,
Expand Down Expand Up @@ -60,6 +60,12 @@
)
from pip._internal.wheel_builder import build, should_build_for_install_command

if TYPE_CHECKING:
# Vendored libraries with type stubs
from requests.exceptions import InvalidProxyURL
else:
from pip._vendor.requests.exceptions import InvalidProxyURL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stylistically, I find the moving of the real imports to be uncomfortable. An alternative pattern:

import typing


from pip._vendor.requests.exceptions import InvalidProxyURL

if typing.TYPE_CHECKING:
    from requests.exceptions import InvalidProxyURL as _InvalidProxyURL
    InvalidProxyURL = _InvalidProxyURL

Alternatively, perhaps we should just vendor typing-requests too...

This latter option will be the least error-prone, as I can see that it will be easy for developers to accidentally use the real requests instead of the vendored one (e.g. in exceptions.py we import requests only, since we are inside a TYPE_CHECKING block).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, perhaps we should just vendor typing-requests too...

I guess this is a drive-by review of your drive-by review, but in general I’m -1 on vendoring things that we only require for type checking. That feels like runtime bloat for something that is only needed at development time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is a drive-by review of your drive-by review

😂 - and it is a very welcome comment 👍

That feels like runtime bloat for something that is only needed at development time

Yes, fully accept. The proposal in this PR currently adds maintenance bloat, which is arguably a more precious resource for the project, but I fully see your perspective.

Given the fact that this is a developer-time requirement, then I think there is a reasonable middle-ground where you could install & vendor the typestub packages at test time, and not actually commit the vendored type libraries.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@philfreo I'm pretty sure your proposed pattern doesn't work for type checkers, it throws an error that InvalidProxyURL is of type pip._vendor.requests.exceptions.InvalidProxyURL can not assign type requests.exceptions.InvalidProxyURL.

I went though several rounds of how to provide the type stub hints to the the type checker without creating any run time differences and landed on the current approach as fairly simple, the most readable, and works in all situations. But I am happy to find out there's a simpler or more readable version.

Copy link
Member Author

@notatallshaw notatallshaw Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a high level the options here are:

  1. Leave as is
  2. Configure mypy not to type check against these vendored libraries
  3. Implement some pattern to get the type checker to recognize the type stubs
  4. Vendor type stubs

And in my opinion here are the downsides:

  1. Because we vendor these libraries the type checker assumes it can pull types from these libraries, so we've historically type checking against the wrong types
  2. We won't be doing any type checking for these libraries, configuration will not apply to other type checkers
  3. Adds some additional complexity where we are importing these libraries
  4. Affects run time distribution of pip

I do not want 1 and 4, because I don't want to be running mypy producing incorrect type checking, and I don't want to affect the pip distribution with type hint stuff.

I prefer 3 because I think I've found a pattern that is fairly readable and maintainable, so if 3 is rejected I would look to implement 2.


logger = getLogger(__name__)


Expand Down
5 changes: 3 additions & 2 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
if TYPE_CHECKING:
from hashlib import _Hash

from pip._vendor.requests.models import Request, Response
# Vendored libraries with type stubs
from requests.models import PreparedRequest, Request, Response

from pip._internal.metadata import BaseDistribution
from pip._internal.network.download import _FileDownload
Expand Down Expand Up @@ -297,7 +298,7 @@ def __init__(
self,
error_msg: str,
response: Response | None = None,
request: Request | None = None,
request: Request | PreparedRequest | None = None,
) -> None:
"""
Initialize NetworkConnectionError with `request` and `response`
Expand Down
15 changes: 11 additions & 4 deletions src/pip/_internal/index/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,12 @@
from html.parser import HTMLParser
from optparse import Values
from typing import (
TYPE_CHECKING,
Callable,
NamedTuple,
Protocol,
)

from pip._vendor import requests
from pip._vendor.requests import Response
from pip._vendor.requests.exceptions import RetryError, SSLError

from pip._internal.exceptions import NetworkConnectionError
from pip._internal.models.link import Link
from pip._internal.models.search_scope import SearchScope
Expand All @@ -38,6 +35,15 @@

from .sources import CandidatesFromPage, LinkSource, build_source

if TYPE_CHECKING:
# Vendored libraries with type stubs
import requests
from requests import Response
from requests.exceptions import RetryError, SSLError
else:
from pip._vendor import requests
from pip._vendor.requests.exceptions import RetryError, SSLError

logger = logging.getLogger(__name__)

ResponseHeaders = MutableMapping[str, str]
Expand Down Expand Up @@ -80,6 +86,7 @@ def _ensure_api_header(response: Response) -> None:
):
return

assert response.request.method is not None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime behaviour change. I guess this is because the type hints suggest that method can be none. If that is the case, do we need to adapt the code to handle that case... ?

(e.g. _NotAPIContent accepts None as a method)

raise _NotAPIContent(content_type, response.request.method)


Expand Down
17 changes: 11 additions & 6 deletions src/pip/_internal/locations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pathlib
import sys
import sysconfig
from typing import Any
from typing import TYPE_CHECKING, Any

from pip._internal.models.scheme import SCHEME_KEYS, Scheme
from pip._internal.utils.compat import WINDOWS
Expand Down Expand Up @@ -80,7 +80,7 @@ def _looks_like_bpo_44860() -> bool:

See <https://bugs.python.org/issue44860>.
"""
from distutils.command.install import INSTALL_SCHEMES
from distutils.command.install import INSTALL_SCHEMES # type: ignore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest that pyproject.toml is used to declare that distutils imports should be ignored, rather than peppering this into the codebase.


try:
unix_user_platlib = INSTALL_SCHEMES["unix_user"]["platlib"]
Expand All @@ -105,7 +105,7 @@ def _looks_like_red_hat_lib() -> bool:

This is the only way I can see to tell a Red Hat-patched Python.
"""
from distutils.command.install import INSTALL_SCHEMES
from distutils.command.install import INSTALL_SCHEMES # type: ignore

return all(
k in INSTALL_SCHEMES
Expand All @@ -117,7 +117,7 @@ def _looks_like_red_hat_lib() -> bool:
@functools.cache
def _looks_like_debian_scheme() -> bool:
"""Debian adds two additional schemes."""
from distutils.command.install import INSTALL_SCHEMES
from distutils.command.install import INSTALL_SCHEMES # type: ignore

return "deb_system" in INSTALL_SCHEMES and "unix_local" in INSTALL_SCHEMES

Expand All @@ -131,8 +131,13 @@ def _looks_like_red_hat_scheme() -> bool:
(fortunately?) done quite unconditionally, so we create a default command
object without any configuration to detect this.
"""
from distutils.command.install import install
from distutils.dist import Distribution
if TYPE_CHECKING:
# Vendored libraries with type stubs
from setuptools._distutils.command.install import install
from setuptools._distutils.dist import Distribution
else:
from distutils.command.install import install
from distutils.dist import Distribution

cmd: Any = install(Distribution())
cmd.finalize_options()
Expand Down
21 changes: 16 additions & 5 deletions src/pip/_internal/locations/_distutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,28 @@
import logging
import os
import sys
from distutils.cmd import Command as DistutilsCommand
from distutils.command.install import SCHEME_KEYS
from distutils.command.install import install as distutils_install_command
from distutils.sysconfig import get_python_lib
from distutils.command.install import SCHEME_KEYS # type: ignore
from typing import TYPE_CHECKING, cast

from pip._internal.models.scheme import Scheme
from pip._internal.utils.compat import WINDOWS
from pip._internal.utils.virtualenv import running_under_virtualenv

from .base import get_major_minor_version

if TYPE_CHECKING:
# Vendored libraries with type stubs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment isn't quite right. I think what you've done here is to remove some imports that were only necessary for type checking into this block, as well as using setuptools as an alias for distutils to support some type checking of distutils_install_command and get_python_lib. I don't know how safe the latter is in practice - is it better to just ignore the distutils imports for type checking altogether?

from setuptools._distutils.cmd import Command as DistutilsCommand
from setuptools._distutils.command.install import (
install as distutils_install_command,
)
from setuptools._distutils.dist import Distribution # noqa: F401
from setuptools._distutils.sysconfig import get_python_lib
else:
from distutils.command.install import install as distutils_install_command
from distutils.sysconfig import get_python_lib


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -65,7 +76,7 @@ def distutils_scheme(
obj: DistutilsCommand | None = None
obj = d.get_command_obj("install", create=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is desirable to avoid cast if we can... is it going to work if we declare:

obj: distutils_install_command | None = None

(this requires that distutils_install_command is a subtype of DistutilsCommand (I don't know if it is, and didn't check))

assert obj is not None
i: distutils_install_command = obj
i = cast(distutils_install_command, obj)
# NOTE: setting user or home has the side-effect of creating the home dir
# or user base for installations during finalize_options()
# ideally, we'd prefer a scheme class that has no side-effects.
Expand Down
6 changes: 3 additions & 3 deletions src/pip/_internal/metadata/pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def from_metadata_file_contents(
}
dist = pkg_resources.DistInfoDistribution(
location=filename,
metadata=InMemoryMetadata(metadata_dict, filename),
metadata=InMemoryMetadata(metadata_dict, filename), # type: ignore[arg-type]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I always insist that a type: ignore is accompanied by a comment (and normally that is only allowed when there is a bug with the hints and/or mypy).

Might be a good idea here too, as it would be easy for this to miss a real problem in the future.

project_name=project_name,
)
return cls(dist)
Expand All @@ -146,7 +146,7 @@ def from_wheel(cls, wheel: Wheel, name: str) -> BaseDistribution:
raise UnsupportedWheel(f"{name} has an invalid wheel, {e}")
dist = pkg_resources.DistInfoDistribution(
location=wheel.location,
metadata=InMemoryMetadata(metadata_dict, wheel.location),
metadata=InMemoryMetadata(metadata_dict, wheel.location), # type: ignore[arg-type]
project_name=name,
)
return cls(dist)
Expand Down Expand Up @@ -176,7 +176,7 @@ def installed_by_distutils(self) -> bool:
# provider has a "path" attribute not present anywhere else. Not the
# best introspection logic, but pip has been doing this for a long time.
try:
return bool(self._dist._provider.path)
return bool(self._dist._provider.path) # type: ignore
except AttributeError:
return False

Expand Down
Loading
Loading