Update maintainers in the AUTHORS.rst

Updated the maintainers list with our rotations in the last year. This will align with the published metadata in #7012 once it's merged.

@@ -1,17 +1,17 @@
 Requests was lovingly created by [NAME_REDACTED]

-Keepers of the Crystals
-```````````````````````
+Requests Maintainers
+````````````````````

+- [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED]
 - [NAME_REDACTED] [CONTRIBUTOR_REDACTED]
-- [NAME_REDACTED] [CONTRIBUTOR_REDACTED]

-Previous Keepers of Crystals
-````````````````````````````
+Previous Maintainers
+````````````````````
+
 - [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED], reluctant Keeper of the Master Crystal.
 - [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED]
-- [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED]
-
+- [NAME_REDACTED] [CONTRIBUTOR_REDACTED]

 Patches and Suggestions
 ```````````````````````

```suggestion
- [NAME_EMAIL_REDACTED] [CONTRIBUTOR_REDACTED]
```

I completely missed that 😅 Apologies.

Maybe we should rename "Keepers of the Crystals" too at some point

Yep, I'm onboard with that. We can just swap it to maintainers and former.


Migrate build system to PEP 517

This PR follows up on the original [announcement ](https://github.com/psf/requests/releases/tag/v2.32.0) of migrating Requests to a PEP 517 compliant backend in 2.33.0. This PR proposes moving to ~~hatchling which has wide adoption, but there's an argument for staying on setuptools~~ setuptools after reviewing our current needs. I'd be curious to hear from other maintainers on if there's a strong preference in either direction based on other projects.

[USER_REDACTED] or [USER_REDACTED] may have input from migrating urllib3 as well. Presumably users with urllib3 2.x are already capable of building with this setup in their environment.

@@ -1,3 +1,76 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "requests"
+description = "Python HTTP for Humans."
+readme = "README.md"
+license = {text = "Apache-2.0"}
+authors = [
+    { name = "[NAME_REDACTED]", email = "[EMAIL_REDACTED]" },
+]
+maintainers = [
+    {name = "[NAME_REDACTED]", email="[EMAIL_REDACTED]"},
+    {name = "[NAME_REDACTED]", email="[EMAIL_REDACTED]"}
+]
+requires-python = ">=3.9"
+dependencies = [
+    "charset_normalizer>=2,<4",
+    "idna>=2.5,<4",
+    "urllib3>=1.21.1,<3",
+    "certifi>=2017.4.17"
+]
+dynamic = ["version"]
+
+classifiers = [
+    "[NAME_REDACTED] :: 5 - Production/Stable",
+    "Environment :: [NAME_REDACTED]",
+    "[NAME_REDACTED] :: Developers",
+    "License :: OSI Approved :: [NAME_REDACTED]",
+    "[NAME_REDACTED] :: English",
+    "[NAME_REDACTED] :: OS Independent",
+    "[NAME_REDACTED] :: Python",
+    "[NAME_REDACTED] :: Python :: 3",
+    "[NAME_REDACTED] :: Python :: 3.9",
+    "[NAME_REDACTED] :: Python :: 3.10",
+    "[NAME_REDACTED] :: Python :: 3.11",
+    "[NAME_REDACTED] :: Python :: 3.12",
+    "[NAME_REDACTED] :: Python :: 3.13",
+    "[NAME_REDACTED] :: Python :: 3.14",
+    "[NAME_REDACTED] :: Python :: 3 :: Only",
+    "[NAME_REDACTED] :: Python :: Implementation :: CPython",
+    "[NAME_REDACTED] :: Python :: Implementation :: PyPy",
+    "Topic :: Internet :: WWW/HTTP",
+    "Topic :: [NAME_REDACTED] :: Libraries"
+]
+
+[project.urls]
+Documentation = "https://requests.readthedocs.io"
+Source = "https://github.com/psf/requests"
+
+[project.optional-dependencies]
+security = []
+socks = ["PySocks>=1.5.6, !=1.5.7"]
+use_chardet_on_py3 = ["chardet>=3.0.2,<6"]
+test = [
+    "pytest-httpbin==2.1.0",
+    "pytest-cov",
+    "pytest-mock",
+    "pytest-xdist",
+    "PySocks>=1.5.6, !=1.5.7",
+    "pytest>=3"
+]
+
+[tool.setuptools]
+license-files = ["LICENSE", "NOTICE"]
+
+[tool.setuptools.dynamic]
+version = {attr = "requests.__version__.__version__"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
 [tool.ruff]
 target-version = "py310"
 src = ["src/requests", "tests"]
@@ -12,7 +85,8 @@ select = [
     "UP",     # pyupgrade
     "T10",    # flake8-debugger (replaces debug-statements hook)
 ]
-ignore = ["E203", "E501", "UP038", "UP031"]
+# UP036: Remove once Python 3.10 is the minimum supported version
+ignore = ["E203", "E501", "UP038", "UP031", "UP036"]
 per-file-ignores = {"src/requests/__init__.py" = ["E402", "F401"], "src/requests/compat.py" = ["E402", "F401"], "tests/compat.py" = ["F401"]}

 [tool.ruff.lint.isort]

@@ -1,10 +0,0 @@
-[metadata]
-license_file = LICENSE
-provides-extra =
-    socks
-    use_chardet_on_py3
-requires-dist =
-    certifi>=2017.4.17
-    charset_normalizer>=2,<4
-    idna>=2.5,<4
-    urllib3>=1.21.1,<3

@@ -1,105 +1,9 @@
-#!/usr/bin/env python
-import os
 import sys
-from codecs import open

-from setuptools import setup
-
-CURRENT_PYTHON = sys.version_info[:2]
-REQUIRED_PYTHON = (3, 9)
-
-if CURRENT_PYTHON < REQUIRED_PYTHON:
-    sys.stderr.write(
-        """
-==========================
-[NAME_REDACTED] version
-==========================
-This version of Requests requires at least Python {}.{}, but
-you're trying to install it on Python {}.{}. To resolve this,
-consider upgrading to a supported Python version.
-
-If you can't upgrade your Python version, you'll need to
-pin to an older version of Requests (<2.32.0).
-""".format(*(REQUIRED_PYTHON + CURRENT_PYTHON))
-    )
+if sys.version_info < (3, 9):
+    sys.stderr.write("Requests requires Python 3.9 or later.\n")
     sys.exit(1)

+from setuptools import setup

-# 'setup.py publish' shortcut.
-if sys.argv[-1] == "publish":
-    os.system("python setup.py sdist bdist_wheel")
-    os.system("twine upload dist/*")
-    sys.exit()
-
-requires = [
-    "charset_normalizer>=2,<4",
-    "idna>=2.5,<4",
-    "urllib3>=1.21.1,<3",
-    "certifi>=2017.4.17",
-]
-test_requirements = [
-    "pytest-httpbin==2.1.0",
-    "pytest-cov",
-    "pytest-mock",
-    "pytest-xdist",
-    "PySocks>=1.5.6, !=1.5.7",
-    "pytest>=3",
-]
-
-about = {}
-here = os.path.abspath(os.path.dirname(__file__))
-with open(os.path.join(here, "src", "requests", "__version__.py"), "r", "utf-8") as f:
-    exec(f.read(), about)
-
-with open("README.md", "r", "utf-8") as f:
-    readme = f.read()
-
-setup(
-    name=about["__title__"],
-    version=about["__version__"],
-    description=about["__description__"],
-    long_description=readme,
-    long_description_content_type="text/markdown",
-    author=about["__author__"],
-    author_email=about["__author_email__"],
-    url=about["__url__"],
-    packages=["requests"],
-    package_data={"": ["LICENSE", "NOTICE"]},
-    package_dir={"": "src"},
-    include_package_data=True,
-    python_requires=">=3.9",
-    install_requires=requires,
-    license=about["__license__"],
-    zip_safe=False,
-    classifiers=[
-        "[NAME_REDACTED] :: 5 - Production/Stable",
-        "Environment :: [NAME_REDACTED]",
-        "[NAME_REDACTED] :: Developers",
-        "License :: OSI Approved :: [NAME_REDACTED]",
-        "[NAME_REDACTED] :: English",
-        "[NAME_REDACTED] :: OS Independent",
-        "[NAME_REDACTED] :: Python",
-        "[NAME_REDACTED] :: Python :: 3",
-        "[NAME_REDACTED] :: Python :: 3.9",
-        "[NAME_REDACTED] :: Python :: 3.10",
-        "[NAME_REDACTED] :: Python :: 3.11",
-        "[NAME_REDACTED] :: Python :: 3.12",
-        "[NAME_REDACTED] :: Python :: 3.13",
-        "[NAME_REDACTED] :: Python :: 3.14",
-        "[NAME_REDACTED] :: Python :: 3 :: Only",
-        "[NAME_REDACTED] :: Python :: Implementation :: CPython",
-        "[NAME_REDACTED] :: Python :: Implementation :: PyPy",
-        "Topic :: Internet :: WWW/HTTP",
-        "Topic :: [NAME_REDACTED] :: Libraries",
-    ],
-    tests_require=test_requirements,
-    extras_require={
-        "security": [],
-        "socks": ["PySocks>=1.5.6, !=1.5.7"],
-        "use_chardet_on_py3": ["chardet>=3.0.2,<6"],
-    },
-    project_urls={
-        "Documentation": "https://requests.readthedocs.io",
-        "Source": "https://github.com/psf/requests",
-    },
-)
+setup()

I'll note here that we left the text identifier instead of using [PEP 639](https://peps.python.org/pep-0639/) intentionally. While it is marked for deprecation in Feb 2027, the minimum supported version (setuptools 77) is still quite recent and I'm not comfortable bringing the floor up that high yet. We'll evaluate how this initial migration goes and address that later.

It wouldn't be a day ending in y if something in [NAME_REDACTED] wasn't being deprecated with hard to find warning messages, that's for sure. ;)


fix CVE 2024 47081: manual url parsing leads to netloc credentials leak

https://seclists.org/fulldisclosure/2025/Jun/2

Honestly I have no idea why this lib used `netloc` + manual parsing instead of `hostname` as I can see references to `hostname` as early as from [the python 2.6 docs](https://docs.python.org/2.6/library/urlparse.html).

@@ -219,14 +219,7 @@ def get_netrc_auth(url, raise_errors=False):
         netrc_path = None

         for f in netrc_locations:
-            try:
-                loc = os.path.expanduser(f)
-            except KeyError:
-                # os.path.expanduser can fail when $HOME is undefined and
-                # getpwuid fails. See https://bugs.python.org/issue20164 &
-                # https://github.com/psf/requests/issues/1846
-                return
-
+            loc = os.path.expanduser(f)
             if os.path.exists(loc):
                 netrc_path = loc
                 break

@@ -23,6 +23,7 @@
     get_encoding_from_headers,
     get_encodings_from_content,
     get_environ_proxies,
+    get_netrc_auth,
     guess_filename,
     guess_json_utf,
     is_ipv4_address,
@@ -152,6 +153,24 @@ def test_super_len_with_no_matches(self):
         assert super_len(object()) == 0

+class TestGetNetrcAuth:
+    def test_works(self, tmp_path, monkeypatch):
+        netrc_path = tmp_path / ".netrc"
+        monkeypatch.setenv("NETRC", str(netrc_path))
+        with open(netrc_path, "w") as f:
+            f.write("machine example.com login aaaa password bbbb\n")
+        auth = get_netrc_auth("http://example.com/thing")
+        assert auth == ("aaaa", "bbbb")
+
+    def test_not_vulnerable_to_bad_url_parsing(self, tmp_path, monkeypatch):
+        netrc_path = tmp_path / ".netrc"
+        monkeypatch.setenv("NETRC", str(netrc_path))
+        with open(netrc_path, "w") as f:
+            f.write("machine example.com login aaaa password bbbb\n")
+        auth = get_netrc_auth("http://example.com:[USER_REDACTED].com/'")
+        assert auth is None
+
+
 class TestToKeyValList:
     [USER_REDACTED].mark.parametrize(
         "value, expected",

About the test, I created another test for the same issue in a [different PR](https://github.com/psf/requests/pull/6962/). So at this point, the issue is fixed and it could be nice to have a test for this case, I'm okay to close my PR if this test is good enough, or keep it if maintainers are okay to have two tests for this case.

This is fixed now in main: https://github.com/psf/requests/commit/96ba401c1296ab1dda74a2365ef36d88f7d144ef


docs: fix dead links to kenreitz.org

I noticed a few dead links. Looking back through the [wayback machine](https://web.archive.org/web/20220203091232/https://kenreitz.org/essays/2012/06/14/the-future-of-python-http), I was able to find the new location of these articles.

@@ -58,7 +58,7 @@

 # General information about the project.
 project = u"Requests"
-copyright = u'MMXVIX. A [NAME_REDACTED] Project'
+copyright = u'MMXVIX. A [NAME_REDACTED]'
 author = u"[NAME_REDACTED]"

 # The version info for the project you're documenting, acts as replacement for

@@ -22,19 +22,17 @@ The guide is split into sections based on the type of contribution you're
 thinking of making, with a section that covers general guidelines for all
 contributors.

-[NAME_REDACTED]
-----------
+Code of Conduct
+---------------

-    **Be cordial or be on your way**. *—[NAME_REDACTED]*
+The Python community is made up of members from around the globe with a diverse
+set of skills, personalities, and experiences. It is through these differences
+that our community experiences great successes and continued growth. When you're
+working with members of the community, follow the
+`[NAME_REDACTED] Code of Conduct`_ to help steer your interactions
+and keep Python a positive, successful, and growing community.

-Requests has one very important rule governing all forms of contribution,
-including reporting bugs or requesting features. This golden rule is
-"`be cordial or be on your way`_".
-
-**All contributions are welcome**, as long as
-everyone involved is treated with respect.
-
-.. _be cordial or be on your way: https://kenreitz.org/essays/2013/01/27/be-cordial-or-be-on-your-way
+.. _Python [NAME_REDACTED] of Conduct: https://policies.python.org/python.org/code-of-conduct/

 .. _early-feedback:

@@ -969,11 +969,9 @@ Requests will automatically parse these link headers and make them easily consum
 [NAME_REDACTED]
 ------------------

-As of v1.0.0, Requests has moved to a modular internal design. Part of the
-reason this was done was to implement [NAME_REDACTED], originally
-`described here`_. [NAME_REDACTED] provide a mechanism to define interaction
-methods for an HTTP service. In particular, they allow you to apply per-service
-configuration.
+As of v1.0.0, Requests has moved to a modular internal design using Transport
+Adapters. These objects provide a mechanism to define interaction methods for an
+HTTP service. In particular, they allow you to apply per-service configuration.

 Requests ships with a single [NAME_REDACTED], the :class:`HTTPAdapter
 <requests.adapters.HTTPAdapter>`. This adapter provides the default Requests
@@ -1053,7 +1051,6 @@ backoff, within a Requests :class:`Session <requests.Session>` using the
     )
     s.mount('https://', HTTPAdapter(max_retries=retries))

-.. _`described here`: https://kenreitz.org/essays/2012/06/14/the-future-of-python-http
 .. _`urllib3`: https://github.com/urllib3/urllib3
 .. _`urllib3.util.Retry`: https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry

At this point I'd prefer we just remove these links. We get PRs every few years whenever the domain changes.

```suggestion
copyright = u'MMXVIX. A [NAME_REDACTED]'
```


Add netrc file search information to authentication documentation

Added paragraph (copied from `quickstart.rst`) with information where Requests search for `.netrc` file.

It should make it easier to find this information when somebody reads regular documentation (as opposite to quickstart documentation).

@@ -44,6 +44,16 @@ set with `headers=`.
 If credentials for the hostname are found, the request is sent with HTTP [NAME_REDACTED].

+Requests will search for the netrc file at `~/.netrc`, `~/_netrc`, or at the path
+specified by the `NETRC` environment variable. `~` denotes the user's home
+directory, which is `$HOME` on Unix based systems and `%USERPROFILE%` on Windows.
+
+Usage of netrc file can be disabled by setting `trust_env` to `False` in the
+Requests session::
+
+    >>> s = requests.Session()
+    >>> s.trust_env = False
+    >>> s.get('https://httpbin.org/basic-auth/user/pass')

 [NAME_REDACTED]
 ---------------------

@@ -222,6 +222,7 @@ Note: Custom headers are given less precedence than more specific sources of inf
   are specified in ``.netrc``, which in turn will be overridden by the  ``auth=``
   parameter. Requests will search for the netrc file at `~/.netrc`, `~/_netrc`,
   or at the path specified by the `NETRC` environment variable.
+  Check details in :ref:`netrc authentication <authentication>`.
 * Authorization headers will be removed if you get redirected off-host.
 * Proxy-Authorization headers will be overridden by [NAME_REDACTED] the URL.
 * Content-Length headers will be overridden when we can determine the length of the content.

This doesn't list the Windows paths or how to disable this altogether.

[USER_REDACTED] please check updated commit.

This is not at all what I meant.

`trust_env=False` is the Requests mechanism for overriding this behaviour.

This may also work, but telling folks to use `/dev/null` I believe causes pathological behaviour on some versions of Python today.

I'm coming from Linux world where `/dev/null` is very respected thing 😄

I pushed new commit - I hope it looks better now.


start testing on 3.13rc

@@ -12,7 +12,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.9", "pypy-3.10"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9", "pypy-3.10"]
         os: [ubuntu-22.04, macOS-latest, windows-latest]
         # Python 3.8 and 3.9 do not run on macOS-latest which
         # is now using arm64 hardware.
@@ -31,6 +31,7 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
         cache: 'pip'
+        allow-prereleases: true
     - name: Install dependencies
       run: |
         make

@@ -1,7 +1,7 @@
 -e .[socks]
 pytest>=2.8.0,<9
 pytest-cov
-pytest-httpbin==2.0.0
+pytest-httpbin==2.1.0
 httpbin~=0.10.0
 trustme
 wheel

@@ -40,7 +40,7 @@
     "certifi>=2017.4.17",
 ]
 test_requirements = [
-    "pytest-httpbin==2.0.0",
+    "pytest-httpbin==2.1.0",
     "pytest-cov",
     "pytest-mock",
     "pytest-xdist",
@@ -87,6 +87,7 @@
         "[NAME_REDACTED] :: Python :: 3.10",
         "[NAME_REDACTED] :: Python :: 3.11",
         "[NAME_REDACTED] :: Python :: 3.12",
+        "[NAME_REDACTED] :: Python :: 3.13",
         "[NAME_REDACTED] :: Python :: 3 :: Only",
         "[NAME_REDACTED] :: Python :: Implementation :: CPython",
         "[NAME_REDACTED] :: Python :: Implementation :: PyPy",

@@ -1,5 +1,5 @@
 [tox]
-envlist = py{38,39,310,311,312}-{default, use_chardet_on_py3}
+envlist = py{38,39,310,311,312,313}-{default, use_chardet_on_py3}

 [testenv]
 deps = -rrequirements-dev.txt

I think we're also missing an update for the [`tox`](https://github.com/psf/requests/blob/f12ccbef6d6b95564da8d22e280d28c39d53f0e9/tox.ini#L2) file.

```suggestion
pytest-httpbin==2.1.0
```

```suggestion
    "pytest-httpbin==2.1.0",
```

Do we still need this or can it be removed now that `pytest-httpbin` is fully released?

This is needed for 3.13rc. it doesn't effect pip installs


Revert caching a default SSLContext

This PR reverts the changes from #6667 to the previous behavior. Due to the number of edge cases and concurrency issues we've encountered with this change, we've decided the benefit doesn't currently outweigh the pain to existing infrastructure. We've iterated on a few tries to keep this functionality in place, but are still receiving reports of novel issues with this behavior.

We may be able to revisit this in a later version of Requests but we'll need a much more comprehensive test plan.

@@ -27,7 +27,6 @@
 from urllib3.util import Timeout as TimeoutSauce
 from urllib3.util import parse_url
 from urllib3.util.retry import Retry
-from urllib3.util.ssl_ import create_urllib3_context

 from .auth import _basic_auth_str
 from .compat import basestring, urlparse
@@ -74,19 +73,6 @@ def SOCKSProxyManager(*args, **kwargs):
 DEFAULT_POOL_TIMEOUT = None

-try:
-    import ssl  # noqa: F401
-
-    _preloaded_ssl_context = create_urllib3_context()
-    _preloaded_ssl_context.load_verify_locations(
-        extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
-    )
-except ImportError:
-    # Bypass default SSLContext creation when Python
-    # interpreter isn't built with the ssl module.
-    _preloaded_ssl_context = None
-
-
 def _urllib3_request_context(
     request: "PreparedRequest",
     verify: "bool | str | None",
@@ -99,19 +85,9 @@ def _urllib3_request_context(
     scheme = parsed_request_url.scheme.lower()
     port = parsed_request_url.port

-    # Determine if we have and should use our default SSLContext
-    # to optimize performance on standard requests.
-    poolmanager_kwargs = getattr(poolmanager, "connection_pool_kw", {})
-    has_poolmanager_ssl_context = poolmanager_kwargs.get("ssl_context")
-    should_use_default_ssl_context = (
-        _preloaded_ssl_context is not None and not has_poolmanager_ssl_context
-    )
-
     cert_reqs = "CERT_REQUIRED"
     if verify is False:
         cert_reqs = "CERT_NONE"
-    elif verify is True and should_use_default_ssl_context:
-        pool_kwargs["ssl_context"] = _preloaded_ssl_context
     elif isinstance(verify, str):
         if not os.path.isdir(verify):
             pool_kwargs["ca_certs"] = verify
@@ -314,26 +290,27 @@ def cert_verify(self, conn, url, verify, cert):
         :param cert: The SSL certificate to verify.
         """
         if url.lower().startswith("https") and verify:
-            conn.cert_reqs = "CERT_REQUIRED"
+            cert_loc = None

-            # Only load the CA certificates if 'verify' is a string indicating the CA bundle to use.
-            # Otherwise, if verify is a boolean, we don't load anything since
-            # the connection will be using a context with the default certificates already loaded,
-            # and this avoids a call to the slow load_verify_locations()
+            # Allow self-specified cert location.
             if verify is not True:
-                # `verify` must be a str with a path then
                 cert_loc = verify

-                if not os.path.exists(cert_loc):
-                    raise OSError(
-                        f"Could not find a suitable TLS CA certificate bundle, "
-                        f"invalid path: {cert_loc}"
-                    )
+            if not cert_loc:
+                cert_loc = extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)

-                if not os.path.isdir(cert_loc):
-                    conn.ca_certs = cert_loc
-                else:
-                    conn.ca_cert_dir = cert_loc
+            if not cert_loc or not os.path.exists(cert_loc):
+                raise OSError(
+                    f"Could not find a suitable TLS CA certificate bundle, "
+                    f"invalid path: {cert_loc}"
+                )
+
+            conn.cert_reqs = "CERT_REQUIRED"
+
+            if not os.path.isdir(cert_loc):
+                conn.ca_certs = cert_loc
+            else:
+                conn.ca_cert_dir = cert_loc
         else:
             conn.cert_reqs = "CERT_NONE"
             conn.ca_certs = None

This is one piece I left in place from #6667. We were unilaterally considering any `verify` string to be `ca_certs` instead of detecting if it was a directory. That seems like a miss from #6655 unless I'm missing something?


Test on urllib3 1.26.x

First step to address #6734. We'll enable testing on urllib3 1.26.x which was lost when we moved the pin to support 2.x. These will fail for now until the underlying issue is addressed, but this is to ensure we're catching any future breakages.

@@ -57,3 +57,23 @@ jobs:
       - name: Run tests
         run: |
           make ci
+
+  urllib3:
+    name: 'urllib3 1.x'
+    runs-on: 'ubuntu-latest'
+    strategy:
+      fail-fast: true
+
+    steps:
+      - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608
+      - name: 'Set up Python 3.8'
+        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d
+        with:
+          python-version: '3.8'
+      - name: Install dependencies
+        run: |
+          make
+          python  -m pip install "urllib3<2"
+      - name: Run tests
+        run: |
+          make ci

@@ -10,6 +10,18 @@
 import importlib
 import sys

+# -------
+# urllib3
+# -------
+from urllib3 import __version__ as urllib3_version
+
+# Detect which major version of urllib3 is being used.
+try:
+    is_urllib3_1 = int(urllib3_version.split(".")[0]) == 1
+except (TypeError, AttributeError):
+    # If we can't discern a version, prefer old functionality.
+    is_urllib3_1 = True
+
 # -------------------
 # [NAME_REDACTED]
 # -------------------

@@ -38,6 +38,7 @@
     getproxies,
     getproxies_environment,
     integer_types,
+    is_urllib3_1,
 )
 from .compat import parse_http_list as _parse_list_header
 from .compat import (
@@ -136,7 +137,9 @@ def super_len(o):
     total_length = None
     current_position = 0

-    if isinstance(o, str):
+    if not is_urllib3_1 and isinstance(o, str):
+        # urllib3 2.x+ treats all strings as utf-8 instead
+        # of latin-1 (iso-8859-1) like http.client.
         o = o.encode("utf-8")

     if hasattr(o, "__len__"):

@@ -25,6 +25,7 @@
     builtin_str,
     cookielib,
     getproxies,
+    is_urllib3_1,
     urlparse,
 )
 from requests.cookies import cookiejar_from_dict, morsel_to_cookie
@@ -1810,23 +1811,6 @@ def test_autoset_header_values_are_native(self, httpbin):

         assert p.headers["Content-Length"] == length

-    def test_content_length_for_bytes_data(self, httpbin):
-        data = "This is a string containing multi-byte UTF-8 ☃"
-        encoded_data = data.encode("utf-8")
-        length = str(len(encoded_data))
-        req = requests.Request("POST", httpbin("post"), data=encoded_data)
-        p = req.prepare()
-
-        assert p.headers["Content-Length"] == length
-
-    def test_content_length_for_string_data_counts_bytes(self, httpbin):
-        data = "This is a string containing multi-byte UTF-8 ☃"
-        length = str(len(data.encode("utf-8")))
-        req = requests.Request("POST", httpbin("post"), data=data)
-        p = req.prepare()
-
-        assert p.headers["Content-Length"] == length
-
     def test_nonhttp_schemes_dont_check_URLs(self):
         test_urls = (
             "data:image/gif;base64,R0lGODlhAQABAHAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==",
@@ -2966,6 +2950,29 @@ def response_handler(sock):
         assert client_cert is not None

+def test_content_length_for_bytes_data(httpbin):
+    data = "This is a string containing multi-byte UTF-8 ☃"
+    encoded_data = data.encode("utf-8")
+    length = str(len(encoded_data))
+    req = requests.Request("POST", httpbin("post"), data=encoded_data)
+    p = req.prepare()
+
+    assert p.headers["Content-Length"] == length
+
+
+[USER_REDACTED].mark.skipif(
+    is_urllib3_1,
+    reason="urllib3 2.x encodes all strings to utf-8, urllib3 1.x uses latin-1",
+)
+def test_content_length_for_string_data_counts_bytes(httpbin):
+    data = "This is a string containing multi-byte UTF-8 ☃"
+    length = str(len(data.encode("utf-8")))
+    req = requests.Request("POST", httpbin("post"), data=data)
+    p = req.prepare()
+
+    assert p.headers["Content-Length"] == length
+
+
 def test_json_decode_errors_are_serializable_deserializable():
     json_decode_error = requests.exceptions.JSONDecodeError(
         "Extra data",

LGTM! Thanks for testing urllib3 :)

I believe this is a string, not an integer. Also, should do a >= check instead of equals.

Good catch on the `int`. For the equivalence, I don't know if `>=` is what we want if this is specifically scoping the 2.x major version. In the same way I wouldn't want an `is_py3` to include Python 4.x. We could do something like `is_gt_urllib3_1` but that seems like it may be premature forwards-compatibility?

Right now our dependencies are scoped at `urllib3<3` and if we add this check to any other behaviors, I'd rather they stay scoped to the major version. That will let tests fail if we major version again and we can make an informed decision at that point when adding support. Otherwise, we may unintentionally carry forward behaviors that are subtly wrong.

I can see similar risks with both sides, so not a hill I'm going to die on, but that was my initial thought process.

I've changed the check from checking for urllib3 2.x to check for 1.x. That leaves us open to forward compatibility without the confusing behavior with `is_urllib3_2` including newer major versions.


Don't create default SSLContext if ssl module isn't present

This PR is to address a [recent regression in Emscripten support for urllib3](https://github.com/urllib3/urllib3/actions/runs/9253144050/job/25452248794). We began unilaterally creating an SSLContext without consideration for Python versions built without an ssl module. This is handled in urllib3 but missed in our usage of `create_urllib3_context`.

This PR expands on the fix in #6716 by [NAME_REDACTED] presence of the `ssl` module before creating and setting the default SSLContext.

@@ -73,10 +73,18 @@ def SOCKSProxyManager(*args, **kwargs):
 DEFAULT_RETRIES = 0
 DEFAULT_POOL_TIMEOUT = None

-_preloaded_ssl_context = create_urllib3_context()
-_preloaded_ssl_context.load_verify_locations(
-    extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
-)
+
+try:
+    import ssl  # noqa: F401
+
+    _preloaded_ssl_context = create_urllib3_context()
+    _preloaded_ssl_context.load_verify_locations(
+        extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
+    )
+except ImportError:
+    # Bypass default SSLContext creation when Python
+    # interpreter isn't built with the ssl module.
+    _preloaded_ssl_context = None

 def _urllib3_request_context(
@@ -90,13 +98,19 @@ def _urllib3_request_context(
     parsed_request_url = urlparse(request.url)
     scheme = parsed_request_url.scheme.lower()
     port = parsed_request_url.port
+
+    # Determine if we have and should use our default SSLContext
+    # to optimize performance on standard requests.
     poolmanager_kwargs = getattr(poolmanager, "connection_pool_kw", {})
     has_poolmanager_ssl_context = poolmanager_kwargs.get("ssl_context")
+    should_use_default_ssl_context = (
+        _preloaded_ssl_context is not None and not has_poolmanager_ssl_context
+    )

     cert_reqs = "CERT_REQUIRED"
     if verify is False:
         cert_reqs = "CERT_NONE"
-    elif verify is True and not has_poolmanager_ssl_context:
+    elif verify is True and should_use_default_ssl_context:
         pool_kwargs["ssl_context"] = _preloaded_ssl_context
     elif isinstance(verify, str):
         if not os.path.isdir(verify):

Rerun have timestamp

Missed this earlier but do we need any logic for verify=True without a condition here?

Are you asking about the behavior if `verify is True` but `should_use_default_ssl_context` is False? This was a no-op prior to #6655 which fellback to letting the SSLContext be created when fetching the connection from the pool. [`_ssl_wrap_socket_and_match_hostname`](https://github.com/urllib3/urllib3/blob/b07a669bd970d69847801148286b726f0570b625/src/urllib3/connection.py#L755-L765) handles this for us, so I don't know if there's any additional concern from our previous behavior unless I'm missing part of the question.

Makes sense. I've a terrible headache so just looking at the branches and concerned about missing something is all.

Nope, that's a good call out. I'll give it one more look before merging but I think we're ok for that case.

Oh, looking again, not on a phone, and without a migraine, I see the `cert_reqs = "CERT_REQUIRED"` line on L110 that I clearly wrote, and which is what I was thinking we may want to be concerned about. So, I'm not nearly as worried.

Looking at this again with something of a performance mindset, do we want to cache somehow lookups to `isdir`? I'm not sure this hurts us at all, but just thinking about things that could slow us down in certain cases now. (Not for this pull request, just putting it out there)

That seems like a reasonable optimization, I guess we'd need to check how much time we're actually spending on the dir check. I assume we'll get feedback if we have cases where this is a bottleneck.


Allow for overriding of specific pool key params

This re-enables the use case of providing a custom SSLContext via a [NAME_REDACTED] as broken in #6655 and reported in #6715

Closes #6715

@@ -6,6 +6,13 @@ dev

 - \[Short description of non-trivial change.\]

+2.32.3 (2024-05-24)
+-------------------
+
+**Bugfixes**
+- Fix bug breaking the ability to specify custom SSLContexts in sub-classes of
+  HTTPAdapter. (#6716)
+
 2.32.2 (2024-05-21)
 -------------------

@@ -83,16 +83,20 @@ def _urllib3_request_context(
     request: "PreparedRequest",
     verify: "bool | str | None",
     client_cert: "typing.Tuple[str, str] | str | None",
+    poolmanager: "PoolManager",
 ) -> "(typing.Dict[str, typing.Any], typing.Dict[str, typing.Any])":
     host_params = {}
     pool_kwargs = {}
     parsed_request_url = urlparse(request.url)
     scheme = parsed_request_url.scheme.lower()
     port = parsed_request_url.port
+    poolmanager_kwargs = getattr(poolmanager, "connection_pool_kw", {})
+    has_poolmanager_ssl_context = poolmanager_kwargs.get("ssl_context")
+
     cert_reqs = "CERT_REQUIRED"
     if verify is False:
         cert_reqs = "CERT_NONE"
-    elif verify is True:
+    elif verify is True and not has_poolmanager_ssl_context:
         pool_kwargs["ssl_context"] = _preloaded_ssl_context
     elif isinstance(verify, str):
         if not os.path.isdir(verify):
@@ -375,23 +379,83 @@ def build_response(self, req, resp):

         return response

+    def build_connection_pool_key_attributes(self, request, verify, cert=None):
+        """Build the PoolKey attributes used by urllib3 to return a connection.
+
+        This looks at the PreparedRequest, the user-specified verify value,
+        and the value of the cert parameter to determine what PoolKey values
+        to use to select a connection from a given urllib3 [NAME_REDACTED].
+
+        The SSL related pool key arguments are not consistently set. As of
+        this writing, use the following to determine what keys may be in that
+        dictionary:
+
+        * If ``verify`` is ``True``, ``"ssl_context"`` will be set and will be the
+          default Requests SSL Context
+        * If ``verify`` is ``False``, ``"ssl_context"`` will not be set but
+          ``"cert_reqs"`` will be set
+        * If ``verify`` is a string, (i.e., it is a user-specified trust bundle)
+          ``"ca_certs"`` will be set if the string is not a directory recognized
+          by :py:func:`os.path.isdir`, otherwise ``"ca_certs_dir"`` will be
+          set.
+        * If ``"cert"`` is specified, ``"cert_file"`` will always be set. If
+          ``"cert"`` is a tuple with a second item, ``"key_file"`` will also
+          be present
+
+        To override these settings, one may subclass this class, call this
+        method and use the above logic to change parameters as desired. For
+        example, if one wishes to use a custom :py:class:`ssl.SSLContext` one
+        must both set ``"ssl_context"`` and based on what else they require,
+        alter the other keys to ensure the desired behaviour.
+
+        :param request:
+            The PreparedReqest being sent over the connection.
+        :type request:
+            :class:`~requests.models.PreparedRequest`
+        :param verify:
+            Either a boolean, in which case it controls whether
+            we verify the server's TLS certificate, or a string, in which case it
+            must be a path to a CA bundle to use.
+        :param cert:
+            (optional) Any user-provided SSL certificate for client
+            authentication (a.k.a., mTLS). This may be a string (i.e., just
+            the path to a file which holds both certificate and key) or a
+            tuple of length 2 with the certificate file path and key file
+            path.
+        :returns:
+            A tuple of two dictionaries. The first is the "host parameters"
+            portion of the [NAME_REDACTED] including scheme, hostname, and port. The
+            second is a dictionary of SSLContext related parameters.
+        """
+        return _urllib3_request_context(request, verify, cert, self.poolmanager)
+
     def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
         """Returns a urllib3 connection for the given request and TLS settings.
         This should not be called from user code, and is only exposed for use
         when subclassing the :class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.

-        :param request: The :class:`PreparedRequest <PreparedRequest>` object
-            to be sent over the connection.
-        :param verify: Either a boolean, in which case it controls whether
-            we verify the server's TLS certificate, or a string, in which case it
-            must be a path to a CA bundle to use.
-        :param proxies: (optional) The proxies dictionary to apply to the request.
-        :param cert: (optional) Any user-provided SSL certificate to be trusted.
-        :rtype: urllib3.ConnectionPool
+        :param request:
+            The :class:`PreparedRequest <PreparedRequest>` object to be sent
+            over the connection.
+        :param verify:
+            Either a boolean, in which case it controls whether we verify the
+            server's TLS certificate, or a string, in which case it must be a
+            path to a CA bundle to use.
+        :param proxies:
+            (optional) The proxies dictionary to apply to the request.
+        :param cert:
+            (optional) Any user-provided SSL certificate to be used for client
+            authentication (a.k.a., mTLS).
+        :rtype:
+            urllib3.ConnectionPool
         """
         proxy = select_proxy(request.url, proxies)
         try:
-            host_params, pool_kwargs = _urllib3_request_context(request, verify, cert)
+            host_params, pool_kwargs = self.build_connection_pool_key_attributes(
+                request,
+                verify,
+                cert,
+            )
         except ValueError as e:
             raise InvalidURL(e, request=request)
         if proxy:

If [USER_REDACTED], [USER_REDACTED] and/or [USER_REDACTED] are willing to give this patch a try, it would be nice to make sure we're not missing any other requirements.

Otherwise this seems straight forward to me, just a couple minor comments to add a date and the current PR reference.

I think this looks good to go. We've received some early indication is solves this class of problems from what's been raised. I'll plan to cut a release with this tomorrow AM in the US.

```suggestion
2.32.3 (2024-05-23)
```

```suggestion
  HTTPAdapter. (#6716)
```


Move _get_connection to get_connection_with_tls_context

This PR is meant to provide a new publicly supported api `get_connection_with_tls_context` to better facilitate custom adapters with our recent fix for CVE-2024-35195.

As described [here](https://github.com/psf/requests/issues/6707#issuecomment-2122490651), we will be deprecating the existing `get_connection` API, and custom adapters will need to migrate their existing `get_connection` implementations to use `get_connection_with_tls_context`. Below is a very simple implementation that will get projects relying on a custom `get_connection` implementation unblocked.

However, we _strongly_ recommend maintainers evaluate if their custom `get_connection` is subject to the same issues as the CVE above and make appropriate changes as needed.

**Minimum new code to work as a pass-through**
This will be backwards compatible between versions of Requests.
```python
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
    return self.get_connection(request.url, proxies)
```

@@ -9,6 +9,7 @@
 import os.path
 import socket  # noqa: F401
 import typing
+import warnings

 from urllib3.exceptions import ClosedPoolError, ConnectTimeoutError
 from urllib3.exceptions import HTTPError as _HTTPError
@@ -374,10 +375,20 @@ def build_response(self, req, resp):

         return response

-    def _get_connection(self, request, verify, proxies=None, cert=None):
-        # Replace the existing get_connection without breaking things and
-        # ensure that TLS settings are considered when we interact with
-        # urllib3 HTTP Pools
+    def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
+        """Returns a urllib3 connection for the given request and TLS settings.
+        This should not be called from user code, and is only exposed for use
+        when subclassing the :class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
+
+        :param request: The :class:`PreparedRequest <PreparedRequest>` object
+            to be sent over the connection.
+        :param verify: Either a boolean, in which case it controls whether
+            we verify the server's TLS certificate, or a string, in which case it
+            must be a path to a CA bundle to use.
+        :param proxies: (optional) The proxies dictionary to apply to the request.
+        :param cert: (optional) Any user-provided SSL certificate to be trusted.
+        :rtype: urllib3.ConnectionPool
+        """
         proxy = select_proxy(request.url, proxies)
         try:
             host_params, pool_kwargs = _urllib3_request_context(request, verify, cert)
@@ -404,14 +415,26 @@ def _get_connection(self, request, verify, proxies=None, cert=None):
         return conn

     def get_connection(self, url, proxies=None):
-        """Returns a urllib3 connection for the given URL. This should not be
+        """DEPRECATED: Users should move to `get_connection_with_tls_context`
+        for all subclasses of HTTPAdapter using Requests>=2.32.2.
+
+        Returns a urllib3 connection for the given URL. This should not be
         called from user code, and is only exposed for use when subclassing the
         :class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.

         :param url: The URL to connect to.
         :param proxies: (optional) A Requests-style dictionary of proxies used on this request.
         :rtype: urllib3.ConnectionPool
         """
+        warnings.warn(
+            (
+                "`get_connection` has been deprecated in favor of "
+                "`get_connection_with_tls_context`. Custom HTTPAdapter subclasses "
+                "will need to migrate for Requests>=2.32.2. Please see "
+                "https://github.com/psf/requests/pull/6710 for more details."
+            ),
+            DeprecationWarning,
+        )
         proxy = select_proxy(url, proxies)

         if proxy:
@@ -529,7 +552,9 @@ def send(
         """

         try:
-            conn = self._get_connection(request, verify, proxies=proxies, cert=cert)
+            conn = self.get_connection_with_tls_context(
+                request, verify, proxies=proxies, cert=cert
+            )
         except LocationValueError as e:
             raise InvalidURL(e, request=request)

Looks good, only had one suggestion that might require a new test.

If this method is deprecated shall we start emitting `DeprecationWarning`?

I thought about that but it's not reachable in any of our code anymore and if someone is using a custom implementation it won't have the warning.

Happy to be wrong if I'm missing something but it seems like it will just be dead code on arrival.

I guess it's better to be safe, this should be addressed in 92075b330a30b9883f466a43d3f7566ab849f91b.


Allow optional char detection dependencies in post-packaging

This PR is a proposal to relax our requirement to have a character detection library present with all installations of Requests. While we cannot remove our reliance on `charset_normalizer` or `chardet`, there are use cases where Requests is repackaged in applications or other tools that do not need our character detection behavior. These changes will enable them to remove those dependencies from their installation.

The current use case for this shift is to allow `pip` to remove these dependencies entirely. That should slim the package ~2MB in 24.1.0 and avoid the overhead of importing the unneeded dependency.

@@ -37,3 +37,23 @@ jobs:
     - name: Run tests
       run: |
         make ci
+
+  no_chardet:
+    name: "[NAME_REDACTED]"
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: true
+
+    steps:
+      - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608
+      - name: 'Set up Python 3.8'
+        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d
+        with:
+          python-version: '3.8'
+      - name: Install dependencies
+        run: |
+          make
+          python -m pip uninstall -y "charset_normalizer" "chardet"
+      - name: Run tests
+        run: |
+          make ci

@@ -83,7 +83,11 @@ def check_compatibility(urllib3_version, chardet_version, charset_normalizer_ver
         # charset_normalizer >= 2.0.0 < 4.0.0
         assert (2, 0, 0) <= (major, minor, patch) < (4, 0, 0)
     else:
-        raise Exception("You need either charset_normalizer or chardet installed")
+        warnings.warn(
+            "Unable to find acceptable character detection dependency "
+            "(chardet or charset_normalizer).",
+            RequestsDependencyWarning,
+        )

 def _check_cryptography(cryptography_version):

@@ -7,13 +7,28 @@
 compatibility until the next major version.
 """

-try:
-    import chardet
-except ImportError:
-    import charset_normalizer as chardet
-
+import importlib
 import sys

+# -------------------
+# [NAME_REDACTED]
+# -------------------
+
+
+def _resolve_char_detection():
+    """Find supported character detection libraries."""
+    chardet = None
+    for lib in ("chardet", "charset_normalizer"):
+        if chardet is None:
+            try:
+                chardet = importlib.import_module(lib)
+            except ImportError:
+                pass
+    return chardet
+
+
+chardet = _resolve_char_detection()
+
 # -------
 # Pythons
 # -------

@@ -789,7 +789,12 @@ def next(self):
     [USER_REDACTED]
     def apparent_encoding(self):
         """The apparent encoding, provided by [NAME_REDACTED]_normalizer or chardet libraries."""
-        return chardet.detect(self.content)["encoding"]
+        if chardet is not None:
+            return chardet.detect(self.content)["encoding"]
+        else:
+            # If no character detection library is available, we'll fall back
+            # to a standard Python utf-8 str.
+            return "utf-8"

     def iter_content(self, chunk_size=1, decode_unicode=False):
         """Iterates over the response data.  When stream=True is set on the

@@ -1,13 +1,6 @@
 import sys

-try:
-    import chardet
-except ImportError:
-    import warnings
-
-    import charset_normalizer as chardet
-
-    warnings.filterwarnings("ignore", "Trying to detect", module="charset_normalizer")
+from .compat import chardet

 # This code exists for backwards compatibility reasons.
 # I don't like it either. Just look the other way. :)
@@ -20,11 +13,11 @@
         if mod == package or mod.startswith(f"{package}."):
             sys.modules[f"requests.packages.{mod}"] = sys.modules[mod]

-target = chardet.__name__
-for mod in list(sys.modules):
-    if mod == target or mod.startswith(f"{target}."):
-        imported_mod = sys.modules[mod]
-        sys.modules[f"requests.packages.{mod}"] = imported_mod
-        mod = mod.replace(target, "chardet")
-        sys.modules[f"requests.packages.{mod}"] = imported_mod
-# Kinda cool, though, right?
+if chardet is not None:
+    target = chardet.__name__
+    for mod in list(sys.modules):
+        if mod == target or mod.startswith(f"{target}."):
+            imported_mod = sys.modules[mod]
+            sys.modules[f"requests.packages.{mod}"] = imported_mod
+            mod = mod.replace(target, "chardet")
+            sys.modules[f"requests.packages.{mod}"] = imported_mod

While this keeps the logic of the earlier code, I think that the library which is required on should be tried first. https://github.com/psf/requests/pull/6714 addresses this.


Avoid reloading root certificates to improve concurrent performance

## Reproducing the problem

Let's consider the following script. It runs a bunch of concurrent requests against a URL, both with certificate verification enabled and disabled, and outputs the time it takes to do it in both cases.

```py
from time import time
from threading import Thread
import requests
import urllib3

urllib3.disable_warnings()

def do_request(verify):
    requests.get('https://example.com', verify=verify)

def measure(verify):
    threads = [Thread(target=do_request, args=(verify,)) for _ in range(30)]

    start = time()
    for t in threads: t.start()
    for t in threads: t.join()
    end = time()

    print(end - start)

measure(verify=True)
measure(verify=False)
```

What's the time difference between the two? It turns out it is highly dependent on your local configuration. On my local machine, with a relatively modern config (Python 3.12 + OpenSSL 3.0.2), the times are `~1.2s` for `verify=True` and `~0.5s` for `verify=False`.

It's a >100% difference, but we initially blamed it on cert verification taking some time. However, we observed even larger differences (>500%) in some environments, and decided to find out what was going on.

## Problem description

Our main use case for requests is running lots of requests concurrently, and we spent some time bisecting this oddity to see if there was room for a performance optimization.

The issue is a bit more clear if you profile the concurrent executions. When verifying certs, these are the top 3 function calls by [NAME_REDACTED]:

```
ncalls  tottime  percall  cumtime  percall filename:lineno(function)
30/1    0.681    0.023    0.002    0.002 {method 'load_verify_locations' of '_ssl._SSLContext' objects}
30/1    0.181    0.006    0.002    0.002 {method 'connect' of '_socket.socket' objects}
60/2    0.180    0.003    1.323    0.662 {method 'read' of '_ssl._SSLSocket' objects}
```

Conversely, this is how the top 3 looks like without cert verification:

```
ncalls  tottime  percall  cumtime  percall filename:lineno(function)
30/1    0.233    0.008    0.001    0.001 {method 'do_handshake' of '_ssl._SSLSocket' objects}
30/1    0.106    0.004    0.002    0.002 {method 'connect' of '_socket.socket' objects}
60/2    0.063    0.001    0.505    0.253 {method 'read' of '_ssl._SSLSocket' objects}
```

In the first case, a full 0.68 seconds are spent in the `load_verify_locations()` function of the `ssl` module, which configures a `SSLContext` object to use a set of CA certificates for validation. Inside it, there is a C FFI call to OpenSSL's `SSL_CTX_load_verify_locations()` which [is known](https://github.com/[USER_REDACTED]n/cpython/issues/95031) to be [quite slow](https://github.com/[USER_REDACTED]nssl/openssl/issues/16871). This happens once per request (hence the `30` on the left).

We believe that, in some cases, there is even some blocking going on, either because each FFI call locks up the GIL or because of some thread safety mechanisms in OpenSSL itself. We also think that this is more or less pronounced depending on internal changes between OpenSSL's versions, hence the variability between environments.

When cert validation isn't needed, these calls are skipped which speeds up concurrent performance dramatically.

## Submitted solution

It isn't possible to skip loading root CA certificates entirely, but it isn't necessary to do it on every request. More specifically, a call to `load_verify_locations()` happens when:

- A new `urllib3.connectionpool.HTTPSConnectionPool` is created.

- On connection, by urllib3's `ssl_wrap_socket()`, when the connection's `ca_certs` or `ca_cert_dir` attributes are set (see [the relevant code](https://github.com/urllib3/urllib3/blob/9929d3c4e03b71ba485148a8390cd9411981f40f/src/urllib3/util/ssl_.py#L438)).

The first case doesn't need to be addressed anymore after the latest addition of `_get_connection()`. Since it now passes  down `pool_kwargs`, this allows urllib3 to use a cached pool with the same settings every time, instead of creating one per request.

The second one is addressed in this PR. If a verified connection is requested, `_urllib3_request_context()` already makes it so that a connection pool using a `SSLContext` with all relevant certificates loaded is always used. Hence, there is no need to trigger a call to `load_verify_locations()` again.

You can test against https://invalid.badssl.com to check that `verify=True` and `verify=False` still behave as expected and are now equally fast.

I'd like to mention that there have been a few changes in Requests since I started drafting this, and I'm not sure that setting `conn.ca_certs` or `conn.ca_certs = cert_loc` in `cert_verify()` is even still needed, since I think that the logic could be moved to `_urllib3_request_context()` and benefit from using a cached context in those cases too.

@@ -26,6 +26,7 @@
 from urllib3.util import Timeout as TimeoutSauce
 from urllib3.util import parse_url
 from urllib3.util.retry import Retry
+from urllib3.util.ssl_ import create_urllib3_context

 from .auth import _basic_auth_str
 from .compat import basestring, urlparse
@@ -71,6 +72,11 @@ def SOCKSProxyManager(*args, **kwargs):
 DEFAULT_RETRIES = 0
 DEFAULT_POOL_TIMEOUT = None

+_preloaded_ssl_context = create_urllib3_context()
+_preloaded_ssl_context.load_verify_locations(
+    extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
+)
+

 def _urllib3_request_context(
     request: "PreparedRequest",
@@ -85,8 +91,13 @@ def _urllib3_request_context(
     cert_reqs = "CERT_REQUIRED"
     if verify is False:
         cert_reqs = "CERT_NONE"
-    if isinstance(verify, str):
-        pool_kwargs["ca_certs"] = verify
+    elif verify is True:
+        pool_kwargs["ssl_context"] = _preloaded_ssl_context
+    elif isinstance(verify, str):
+        if not os.path.isdir(verify):
+            pool_kwargs["ca_certs"] = verify
+        else:
+            pool_kwargs["ca_cert_dir"] = verify
     pool_kwargs["cert_reqs"] = cert_reqs
     if client_cert is not None:
         if isinstance(client_cert, tuple) and len(client_cert) == 2:
@@ -284,27 +295,26 @@ def cert_verify(self, conn, url, verify, cert):
         :param cert: The SSL certificate to verify.
         """
         if url.lower().startswith("https") and verify:
-            cert_loc = None
+            conn.cert_reqs = "CERT_REQUIRED"

-            # Allow self-specified cert location.
+            # Only load the CA certificates if 'verify' is a string indicating the CA bundle to use.
+            # Otherwise, if verify is a boolean, we don't load anything since
+            # the connection will be using a context with the default certificates already loaded,
+            # and this avoids a call to the slow load_verify_locations()
             if verify is not True:
+                # `verify` must be a str with a path then
                 cert_loc = verify

-            if not cert_loc:
-                cert_loc = extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)
-
-            if not cert_loc or not os.path.exists(cert_loc):
-                raise OSError(
-                    f"Could not find a suitable TLS CA certificate bundle, "
-                    f"invalid path: {cert_loc}"
-                )
+                if not os.path.exists(cert_loc):
+                    raise OSError(
+                        f"Could not find a suitable TLS CA certificate bundle, "
+                        f"invalid path: {cert_loc}"
+                    )

-            conn.cert_reqs = "CERT_REQUIRED"
-
-            if not os.path.isdir(cert_loc):
-                conn.ca_certs = cert_loc
-            else:
-                conn.ca_cert_dir = cert_loc
+                if not os.path.isdir(cert_loc):
+                    conn.ca_certs = cert_loc
+                else:
+                    conn.ca_cert_dir = cert_loc
         else:
             conn.cert_reqs = "CERT_NONE"
             conn.ca_certs = None

Just want a second set of eyes from [USER_REDACTED] or [USER_REDACTED]

Cool, this seems reasonable to me. I was curious if we'd be better off doing this per-Adapter instance instead of globally but it seems like that may not be a concern with Christian's response.

Run nodes debug merge

This is actually critical behavior you're removing

Thanks for the catch, I had to adapt the patch quite a bit from what we're using right now and that inconsistency slipped by.

I pushed a change to explicitly use `DEFAULT_CA_BUNDLE_PATH` when `verify=True`. This is done by creating a module-level `SSLContext` with that bundle already loaded, and instructing the connection pool to use that context when no custom bundle is specified. Since the server's cert is verified using the CA certificates loaded in the `SSLContext` used in the request, this should work.

Again, the goal is to avoid setting `ca_certs` or `ca_cert_dir` in the most common use case as it triggers another (in this case redundant) call to `load_verify_locations()` by urllib3:

```py
if ca_certs or ca_cert_dir or ca_cert_data:
        try:
            context.load_verify_locations(ca_certs, ca_cert_dir, ca_cert_data)
```

Since `DEFAULT_CA_BUNDLE_PATH` is sugar for `certifi.where()`, which in turn always returns the path to a single bundle file, I decided to skip checking for `os.path.isdir()` because it should always be False. If you're not comfortable with this please let me know and I'll change it.

I also changed `_urllib3_request_context()` slightly to handle the case where `verify` is a path to a dir instead to a single file, as we should set `ca_cert_dir` instead of `ca_certs` in that case. I believe this is now fully redundant with the corresponding logic in `cert_verify()`.

I tried to write corresponding tests to verify that the `SSLContext`s used in different scenarios have the correct certificates loaded, but I couldn't find a way to access such low-level information about a request in the exposed classes. If it's possible, please give me a few pointers and I'll be glad to expand the test suite.


Use TLS settings in selecting connection pool

Previously, if someone made a request with `verify=False` then made a request where they expected verification to be enabled to the same host, they would potentially reuse a connection where TLS had not been verified.

This fixes that issue.

@@ -8,6 +8,7 @@

 import os.path
 import socket  # noqa: F401
+import typing

 from urllib3.exceptions import ClosedPoolError, ConnectTimeoutError
 from urllib3.exceptions import HTTPError as _HTTPError
@@ -61,12 +62,38 @@ def SOCKSProxyManager(*args, **kwargs):
         raise InvalidSchema("Missing dependencies for SOCKS support.")

+if typing.TYPE_CHECKING:
+    from .models import PreparedRequest
+
+
 DEFAULT_POOLBLOCK = False
 DEFAULT_POOLSIZE = 10
 DEFAULT_RETRIES = 0
 DEFAULT_POOL_TIMEOUT = None

+def _urllib3_request_context(
+    request: "PreparedRequest", verify: "bool | str | None"
+) -> "(typing.Dict[str, typing.Any], typing.Dict[str, typing.Any])":
+    host_params = {}
+    pool_kwargs = {}
+    parsed_request_url = urlparse(request.url)
+    scheme = parsed_request_url.scheme.lower()
+    port = parsed_request_url.port
+    cert_reqs = "CERT_REQUIRED"
+    if verify is False:
+        cert_reqs = "CERT_NONE"
+    if isinstance(verify, str):
+        pool_kwargs["ca_certs"] = verify
+    pool_kwargs["cert_reqs"] = cert_reqs
+    host_params = {
+        "scheme": scheme,
+        "host": parsed_request_url.hostname,
+        "port": port,
+    }
+    return host_params, pool_kwargs
+
+
 class BaseAdapter:
     """The Base Transport Adapter"""

@@ -327,6 +354,35 @@ def build_response(self, req, resp):

         return response

+    def _get_connection(self, request, verify, proxies=None):
+        # Replace the existing get_connection without breaking things and
+        # ensure that TLS settings are considered when we interact with
+        # urllib3 HTTP Pools
+        proxy = select_proxy(request.url, proxies)
+        try:
+            host_params, pool_kwargs = _urllib3_request_context(request, verify)
+        except ValueError as e:
+            raise InvalidURL(e, request=request)
+        if proxy:
+            proxy = prepend_scheme_if_needed(proxy, "http")
+            proxy_url = parse_url(proxy)
+            if not proxy_url.host:
+                raise InvalidProxyURL(
+                    "Please check proxy URL. It is malformed "
+                    "and could be missing the host."
+                )
+            proxy_manager = self.proxy_manager_for(proxy)
+            conn = proxy_manager.connection_from_host(
+                **host_params, pool_kwargs=pool_kwargs
+            )
+        else:
+            # Only scheme should be lower case
+            conn = self.poolmanager.connection_from_host(
+                **host_params, pool_kwargs=pool_kwargs
+            )
+
+        return conn
+
     def get_connection(self, url, proxies=None):
         """Returns a urllib3 connection for the given URL. This should not be
         called from user code, and is only exposed for use when subclassing the
@@ -453,7 +509,7 @@ def send(
         """

         try:
-            conn = self.get_connection(request.url, proxies)
+            conn = self._get_connection(request, verify, proxies)
         except LocationValueError as e:
             raise InvalidURL(e, request=request)

@@ -2828,6 +2828,13 @@ def test_status_code_425(self):
         assert r5 == 425
         assert r6 == 425

+    def test_different_connection_pool_for_tls_settings(self):
+        s = requests.Session()
+        r1 = s.get("https://invalid.badssl.com", verify=False)
+        assert r1.status_code == 421
+        with pytest.raises(requests.exceptions.SSLError):
+            s.get("https://invalid.badssl.com")
+

 def test_json_decode_errors_are_serializable_deserializable():
     json_decode_error = requests.exceptions.JSONDecodeError(

@@ -7,7 +7,7 @@ extras =
     security
     socks
 commands =
-    pytest tests
+    pytest {posargs:tests}

 [testenv:default]

:shipit:

Thank you

Not against this, but I think this is the first time we're introducing typing into Requests. I'm curious if we want to start that or push it into typeshed since this will be precedent for future inline typing?

There may not be a better way to test this but I don't know if we have other tests that require contacting a live site with TLS disabled. That may have some durability issues and means we're going to take the first response we get back. Probably minor, but figured I'd call it out.

This is for a private method (that I fully anticipate people abusing) but we're not advertising things are typed and so it's not something I'm concerned with.

There are many alternatives here, but those are all significantly more effort and this shows the behaviour is fixed before and after handily. I'm sure Linux folks will get pissed but I'm not as bothered about finding time later to do this a different way after we have fixed this

I'll try to prioritize better (offline) tests soon


Trim excess leading path separators

A URL with excess leading / (path-separator)s would cause urllib3 to attempt to reparse the request-uri as a full URI with a host and port. This bypasses that logic in ConnectionPool.urlopen by [NAME_REDACTED] /s with just a single /.

Closes #6643

@@ -390,6 +390,9 @@ def request_url(self, request, proxies):
             using_socks_proxy = proxy_scheme.startswith("socks")

         url = request.path_url
+        if url.startswith("//"):  # Don't confuse urllib3
+            url = f"/{url.lstrip('/')}"
+
         if is_proxied_http_request and not using_socks_proxy:
             url = urldefragauth(request.url)

@@ -0,0 +1,8 @@
+import requests.adapters
+
+
+def test_request_url_trims_leading_path_separators():
+    """See also https://github.com/psf/requests/issues/6643."""
+    a = requests.adapters.HTTPAdapter()
+    p = requests.Request(method="GET", url="http://127.0.0.1:10000//v:h").prepare()
+    assert "/v:h" == a.request_url(p, {})

```suggestion
import requests.adapters
```

```suggestion
    a = requests.adapters.HTTPAdapter()
```

```suggestion
    assert "/v:h" == a.request_url("http://127.0.0.1:10000//v:h", {})
```

```suggestion
    p = requests.Request(method="GET", url="http://127.0.0.1:10000//v:h").prepare()
    assert "/v:h" == a.request_url(p, {})
```

As mentioned on https://github.com/urllib3/urllib3/issues/3352 this could also be

```python
url = f"/{request.path_url.lstrip('/')}"
```

I could benchmark these but I don't particularly care what the implementation is. I just threw this together to show that it can be fixed

It looks like the f-string (Python 3.9-3.12 tested) is ~4x faster but we're talking on the scale of nanoseconds so it's basically moot. I'd vote the f-string for readability, but don't have a strong opinion.

Yeah, I'm also happy to shove this into a branch too like

```python
if path.startswith('//'):
```

To make it clearer that we only care about the separator being repeated. What I want is clarity in the reader as to why we're doing this. My old school brain things the regexp is clearer and the f-string looks sus but  that's just my opinion and I'm not holding it closely

Yeah, branching seems fine to me too.

Did both (f string and branch)


Add note on connection timeout being larger than specified. Fix #5773

[As requested](https://github.com/psf/requests/issues/5773#issuecomment-799787382), here's my take on a PR to improve documentation. I've tried to incorporate the [suggestions made by [USER_REDACTED] at a previous PR](https://github.com/psf/requests/pull/5776#discussion_r596043195), by [NAME_REDACTED] "IPv4+IPv6" particular case to a more general (and correct) "multiple IPs" scenario.

If approved, this PR will also fix #5760, and supersede PRs #5776 and #6597

@@ -1122,4 +1122,18 @@ coffee.

     r = requests.get('https://github.com', timeout=None)

+.. note:: The connect timeout applies to each connection attempt to an IP address.
+          If multiple addresses exist for a domain name, the underlying ``urllib3`` will
+          try each address sequentially until one successfully connects.
+          This may lead to an effective total connection timeout *multiple* times longer
+          than the specified time, e.g. an unresponsive server having both IPv4 and IPv6
+          addresses will have its perceived timeout *doubled*, so take that into account
+          when setting the connection timeout.
+.. note:: Neither the connect nor read timeouts are `wall clock`_. This means
+          that if you start a request, and look at the time, and then look at
+          the time when the request finishes or times out, the real-world time
+          may be greater than what you specified.
+
+
+.. _`wall clock`: https://wiki.php.net/rfc/max_execution_wall_time
 .. _`connect()`: https://linux.die.net/man/2/connect

This should be indented. It should all be indented to match the start of "The" on the previous line

```suggestion
          If multiple addresses exist for a domain name, the underlying ``urllib3`` will
          try each address sequentially until one successfully connects.
          This may lead to an effective total connection timeout *multiple* times longer
          than the specified time, e.g. an unresponsive server having both IPv4 and IPv6
          addresses will have its perceived timeout *doubled*, so take that into account
          when setting the connection timeout.
```

I would also suggest we add:

```suggestion
.. note:: Neither the connect nor read timeouts are `wall clock`_. This means
          that if you start a request, and look at the time, and then look at
          the time when the request finishes or times out, the real-world time
          may be greater than what you specified.

.. _`wall clock`: https://wiki.php.net/rfc/max_execution_wall_time
```


Enhance `super_len` to count encoded bytes for str

This is a possible fix for issue #6586.

I am not at all confident that this is an appropriate change for Requests. All of the unit tests pass locally for me (specifically `make ci`).

@@ -134,6 +134,9 @@ def super_len(o):
     total_length = None
     current_position = 0

+    if isinstance(o, str):
+        o = o.encode("utf-8")
+
     if hasattr(o, "__len__"):
         total_length = len(o)

@@ -1808,6 +1808,23 @@ def test_autoset_header_values_are_native(self, httpbin):

         assert p.headers["Content-Length"] == length

+    def test_content_length_for_bytes_data(self, httpbin):
+        data = "This is a string containing multi-byte UTF-8 ☃"
+        encoded_data = data.encode("utf-8")
+        length = str(len(encoded_data))
+        req = requests.Request("POST", httpbin("post"), data=encoded_data)
+        p = req.prepare()
+
+        assert p.headers["Content-Length"] == length
+
+    def test_content_length_for_string_data_counts_bytes(self, httpbin):
+        data = "This is a string containing multi-byte UTF-8 ☃"
+        length = str(len(data.encode("utf-8")))
+        req = requests.Request("POST", httpbin("post"), data=data)
+        p = req.prepare()
+
+        assert p.headers["Content-Length"] == length
+
     def test_nonhttp_schemes_dont_check_URLs(self):
         test_urls = (
             "data:image/gif;base64,R0lGODlhAQABAHAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==",

I'll leave any final comments and merging to [USER_REDACTED] but this looks reasonable to me. Thanks [USER_REDACTED]!

More simply this can be an if without changing the following conditions and just set `o = o.encode(...)` in the block then let the rest of the logic work. As it will hit another condition which will get it's length

Sounds fine to me. My usual workflow would be to rewrite my commit and do a force push to my branch (in my fork). Is this ok or would you prefer a separate commit for the change?

That's my workflow as well. Feel free to do that


Fix broken links in docs

Hi,

I noticed a broken link to rfc2988 in the [timeout section of the docs](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts) and thought I would fix it.

While doing that, I thought I would run the sphinx `linkcheck` builder to check for other broken links and fix them.

There were a few, some where I've 'fixed'/updated the link are hopefully uncontroversial.

Some are more controversial - removing sections written by [NAME_REDACTED] requests that now have dead links, removing talks with dead links to slides, removing people's now dead GitHub account links. I wasn't sure what the right thing to do here was, but I went with my intuition, and kept commits pretty discrete so I can drop/amend them if the maintainers want me to do something different. If some are controversial enough to slow down the PR, I can always pull out the uncontroversial ones into their own PR for faster merging.

@@ -8,7 +8,7 @@ Keepers of the [NAME_REDACTED] of Crystals
 ````````````````````````````
-- [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED], reluctant Keeper of the Master Crystal.
+- [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED], reluctant Keeper of the Master Crystal.
 - [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED]
 - [NAME_REDACTED] <[EMAIL_REDACTED]> [CONTRIBUTOR_REDACTED]

@@ -14,7 +14,7 @@ dev
   forwarding of `Proxy-Authorization` headers to destination servers when
   following HTTPS redirects.

-  When proxies are defined with user info (https://user:pass@proxy:8080), Requests
+  When proxies are defined with user info (`https://user:pass@proxy:8080`), Requests
   will construct a `Proxy-Authorization` header that is attached to the request to
   authenticate with the proxy.

@@ -291,7 +291,7 @@ versions of Requests.
 For the sake of security we recommend upgrading certifi frequently!

 .. _HTTP persistent connection: https://en.wikipedia.org/wiki/HTTP_persistent_connection
-.. _connection pooling: https://urllib3.readthedocs.io/en/latest/reference/index.html#module-urllib3.connectionpool
+.. _connection pooling: https://urllib3.readthedocs.io/en/latest/reference/urllib3.connectionpool.html
 .. _certifi: https://certifiio.readthedocs.io/
 .. _Mozilla trust store: https://hg.mozilla.org/mozilla-central/raw-file/tip/security/nss/lib/ckfw/builtins/certdata.txt

@@ -948,7 +948,7 @@ [NAME_REDACTED] HTTP APIs feature Link headers. They make APIs more self describing and
 discoverable.

-GitHub uses these for `pagination <https://developer.github.com/v3/#pagination>`_
+GitHub uses these for `pagination <https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api>`_
 in their API, for example::

     >>> url = 'https://api.github.com/users/kennethreitz/repos?page=1&per_page=10'
@@ -1099,7 +1099,7 @@ The **connect** timeout is the number of seconds Requests will wait for your
 client to establish a connection to a remote machine (corresponding to the
 `connect()`_) call on the socket. It's a good practice to set connect timeouts
 to slightly larger than a multiple of 3, which is the default `TCP packet
-retransmission window <https://www.hjp.at/doc/rfc/rfc2988.txt>`_.
+retransmission window <https://datatracker.ietf.org/doc/html/rfc2988>`_.

 Once your client has connected to the server and sent the HTTP request, the
 **read** timeout is the number of seconds the client will wait for the server

@@ -568,6 +568,3 @@ All exceptions that Requests explicitly raises inherit from
 -----------------------

 Ready for more? Check out the :ref:`advanced <advanced>` section.
-
-
-If you're on the job market, consider taking `this programming quiz <https://triplebyte.com/a/b1i2FB8/requests-docs-1>`_. A substantial donation will be made to this project, if you find a job through this platform.

That's great! Thank you, [USER_REDACTED]!
I believe these changes are ready to be merged.

I believe this is a similar app by [NAME_REDACTED], but it's python 2.5 (long deprecated), and doesn't work on the most recent version of iOS anyway.

I was tempted to just delete the whole 'Python for iOS' section here, but I thought I would see what a maintainer thought before deleting the whole section.

This is actually an easy one to fix: https://issackelly.github.com/Consuming-Web-APIs-with-Python-Talk/slides/slides.html is from the very old GitHub pages and they switched the domain from `github.com` to `github.io`

I'd rather not remove these. I don't think there's value in removing these links to folks even if they've deleted their account.

ah, can't believe I didn't think of that 🤦 Thanks for finding it!

I've updated the commit to 'fix' it to the correct link rather than removing the line.

Fair enough - I've removed all cases like this from the relevant commit.

I've left in the case where the GitHub user has changed, but let me know if you want me to remove that too.

> It's a good practice to set connect timeouts to slightly larger than a multiple of 3, which is the default [TCP packet retransmission window](https://datatracker.ietf.org/doc/html/rfc2988).

Despite the fact I already approved this PR, there's still a room for improvement.
[RFC 2988](https://datatracker.ietf.org/doc/html/rfc2988) was obsoleted by [RFC 6298](https://datatracker.ietf.org/doc/html/rfc6298), so this link might be updated even further, but it would require additional changes since newer RFC makes the whole statement not correct due to [this change](https://datatracker.ietf.org/doc/html/rfc6298#section-7).
So this might be handled in an additional PR.

Thanks - seems like a sensible comment, agree it makes sense for an additional PR.


Autoclose specific issue templates

We spend a fair amount of time closing issues because people don't read the template closely or understand what they're being told. Let's take advantage of being able to auto-label an issue based on the template and then trigger a workflow to auto-magically close the issue with a message and lock the issue.

@@ -1,6 +1,9 @@
 ---
 name: Request for Help
 about: Guidance on using Requests.
+labels:
+- "Question/Not a bug"
+- "actions/autoclose-qa"

 ---

@@ -1,6 +1,9 @@
 ---
 name: Feature request
 about: Suggest an idea for this project
+labels:
+- "[NAME_REDACTED]"
+- "actions/autoclose-feat"

 ---

@@ -0,0 +1,33 @@
+name: '[NAME_REDACTED]'
+
+on:
+  issues:
+    types:
+      - labeled
+
+permissions:
+  issues: write
+
+jobs:
+  close_qa:
+    if: github.event.label.name == 'actions/autoclose-qa'
+    runs-on: ubuntu-latest
+    steps:
+      - env:
+          ISSUE_URL: ${{ github.event.issue.html_url }}
+        run: |
+          gh issue close $ISSUE_URL \
+            --comment "As described in the template, we won't be able to answer questions on this issue tracker. Please use [Stack Overflow](https://stackoverflow.com/)" \
+            --reason completed
+          gh issue lock $ISSUE_URL --reason off_topic
+  close_feature_request:
+    if: github.event.label.name == 'actions/autoclose-feat'
+    runs-on: ubuntu-latest
+    steps:
+      - env:
+          ISSUE_URL: ${{ github.event.issue.html_url }}
+        run: |
+          gh issue close $ISSUE_URL \
+            --comment "As described in the template, Requests is not accepting feature requests" \
+            --reason "not planned"
+          gh issue lock $ISSUE_URL --reason off_topic

Two minor comments, otherwise looks good.

I'm not familiar, what is `MY_TOKEN` in this case? Should this be `GITHUB_TOKEN` or were you planning on adding a separate token specifically for this workflow?

nit on phrasing.
```suggestion
            --comment "As described in the template, we won't be able to answer questions on this issue tracker. Please use [Stack Overflow](https://stackoverflow.com/)" \
```

Ah yeah, I based this heavily off the actions docs and didn't look terribly closely at this. I don't think it's necessary honestly

Removed those


v2.30.0

2.30.0 (2023-05-03)
-------------------

**Dependencies**
- ⚠ Added support for urllib3 2.0. ⚠

  This may contain minor breaking changes so we advise careful testing and
  reviewing https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html
  prior to upgrading.

  Users who wish to stay on urllib3 1.x can pin to `urllib3<2`.

@@ -6,6 +6,18 @@ dev

 - \[Short description of non-trivial change.\]

+2.30.0 (2023-05-03)
+-------------------
+
+**Dependencies**
+- ⚠ Added support for urllib3 2.0. ⚠
+
+  This may contain minor breaking changes so we advise careful testing and
+  reviewing https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html
+  prior to upgrading.
+
+  Users who wish to stay on urllib3 1.x can pin to `urllib3<2`.
+
 2.29.0 (2023-04-26)
 -------------------

@@ -66,10 +66,10 @@ def check_compatibility(urllib3_version, chardet_version, charset_normalizer_ver
     # Check urllib3 for compatibility.
     major, minor, patch = urllib3_version  # noqa: F811
     major, minor, patch = int(major), int(minor), int(patch)
-    # urllib3 >= 1.21.1, <= 1.26
-    assert major == 1
-    assert minor >= 21
-    assert minor <= 26
+    # urllib3 >= 1.21.1
+    assert major >= 1
+    if major == 1:
+        assert minor >= 21

     # Check charset_normalizer for compatibility.
     if chardet_version:

@@ -5,8 +5,8 @@
 __title__ = "requests"
 __description__ = "Python HTTP for Humans."
 __url__ = "https://requests.readthedocs.io"
-__version__ = "2.29.0"
-__build__ = 0x022900
+__version__ = "2.30.0"
+__build__ = 0x023000
 __author__ = "[NAME_REDACTED]"
 __author_email__ = "[EMAIL_REDACTED]"
 __license__ = "Apache 2.0"

:shipit:


v2.28.2

2.28.2 (2023-01-12)
-------------------

**Dependencies**

- Requests now supports charset\_normalizer 3.x. (#6261)

**Bugfixes**

- Updated MissingSchema exception to suggest https scheme rather than http. (#6188)

@@ -7,6 +7,17 @@ dev
 - \[Short description of non-trivial change.\]

+2.28.2 (2023-01-12)
+-------------------
+
+**Dependencies**
+
+- Requests now supports charset\_normalizer 3.x. (#6261)
+
+**Bugfixes**
+
+- [NAME_REDACTED] exception to suggest https scheme rather than http. (#6188)
+
 2.28.1 (2022-06-29)
 -------------------

@@ -5,10 +5,10 @@
 __title__ = "requests"
 __description__ = "Python HTTP for Humans."
 __url__ = "https://requests.readthedocs.io"
-__version__ = "2.28.1"
-__build__ = 0x022801
+__version__ = "2.28.2"
+__build__ = 0x022802
 __author__ = "[NAME_REDACTED]"
 __author_email__ = "[EMAIL_REDACTED]"
 __license__ = "Apache 2.0"
-__copyright__ = "Copyright 2022 [NAME_REDACTED]"
+__copyright__ = "[NAME_REDACTED]"
 __cake__ = "\u2728 \U0001f370 \u2728"

LGTM


Add GitHub action to automate linting

This was intended to be a follow up to #6095 but got lost in the shuffle post PyCon. It's currently failing on a single line change that's addressed in #6154. Once that's merged this should be ready to merge and ensure we don't introduce any future inconsistencies.

@@ -0,0 +1,16 @@
+name: Lint code
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  lint:
+    runs-on: ubuntu-20.04
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python
+      uses: actions/setup-python@v3
+    - name: Run pre-commit
+      uses: pre-commit/action@v3.0.0

Looks good, one thought so you won't need to modify config in the future.

Thoughts on following pre-commit's example config and use setup-python without specifying a Python  version?

Fine by me, I'd brought this over from other projects but we can switch to `actions/setup-python@v3` per their Readme.


v2.28.0

2.28.0 (2022-06-09)
-------------------

**Deprecations**

- ⚠ Requests has officially dropped support for Python 2.7. ⚠ (#6091)
- Requests has officially dropped support for Python 3.6 (including pypy3). (#6091)

**Improvements**

- Wrap JSON parsing issues in Request's JSONDecodeError for payloads without
  an encoding to make `json()` API consistent. (#6097)
- Parse header components consistently, raising an InvalidHeader error in
  all invalid cases. (#6154)
- Added provisional 3.11 support with current beta build. (#6155)
- Requests got a makeover and we decided to paint it black. (#6095)

**Bugfixes**

- Fixed bug where setting `CURL_CA_BUNDLE` to an empty string would disable
  cert verification. All Requests 2.x versions before 2.28.0 are affected. (#6074)
- Fixed urllib3 exception leak, wrapping `urllib3.exceptions.SSLError` with
  `requests.exceptions.SSLError` for `content` and `iter_content`. (#6057)
- Fixed issue where invalid Windows registry entires caused proxy resolution
  to raise an exception rather than ignoring the entry. (#6149)
- Fixed issue where entire payload could be included in the error message for
  JSONDecodeError. (#6079

@@ -6,15 +6,34 @@ dev

 - \[Short description of non-trivial change.\]

-**Bugfixes**

-- Fixed urllib3 exception leak, wrapping `urllib3.exceptions.SSLError` with
-  `requests.exceptions.SSLError` for `content` and `iter_content`.
+2.28.0 (2022-06-09)
+-------------------

 **Deprecations**

-- ⚠ Requests has officially dropped support for Python 2.7. ⚠
-- Requests has officially dropped support for Python 3.6 (including pypy3).
+- ⚠ Requests has officially dropped support for Python 2.7. ⚠ (#6091)
+- Requests has officially dropped support for Python 3.6 (including pypy3.6). (#6091)
+
+**Improvements**
+
+- [NAME_REDACTED] parsing issues in Request's JSONDecodeError for payloads without
+  an encoding to make `json()` API consistent. (#6097)
+- Parse header components consistently, raising an InvalidHeader error in
+  all invalid cases. (#6154)
+- Added provisional 3.11 support with current beta build. (#6155)
+- Requests got a makeover and we decided to paint it black. (#6095)
+
+**Bugfixes**
+
+- Fixed bug where setting `CURL_CA_BUNDLE` to an empty string would disable
+  cert verification. All Requests 2.x versions before 2.28.0 are affected. (#6074)
+- Fixed urllib3 exception leak, wrapping `urllib3.exceptions.SSLError` with
+  `requests.exceptions.SSLError` for `content` and `iter_content`. (#6057)
+- Fixed issue where invalid Windows registry entires caused proxy resolution
+  to raise an exception rather than ignoring the entry. (#6149)
+- Fixed issue where entire payload could be included in the error message for
+  JSONDecodeError. (#6036)

 2.27.1 (2022-01-05)
 -------------------

@@ -5,8 +5,8 @@
 __title__ = "requests"
 __description__ = "Python HTTP for Humans."
 __url__ = "https://requests.readthedocs.io"
-__version__ = "2.27.1"
-__build__ = 0x022701
+__version__ = "2.28.0"
+__build__ = 0x022800
 __author__ = "[NAME_REDACTED]"
 __author_email__ = "[EMAIL_REDACTED]"
 __license__ = "Apache 2.0"

So much awesome in this release!!! :shipit::rocket::sparkles::tada:

Thoughts on moving these to the top under a section "Breaking Changes" or "Removals"?

Remember to update this if you don't release today.

Yeah, that sounds fine. We've historically had deprecations at the bottom but I'm fine moving them up for visibility.

Yep, I'll move it to tomorrow. No ones getting paged tonight 😁