perf: defer the requests imports in poetry.utils.helpers - #11004
Conversation
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="tests/utils/test_helpers_lazy_imports.py" line_range="65-74" />
<code_context>
+def test_downloader_retries_only_the_resumable_requests_errors() -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Consider an additional test for `Downloader` when retry limit is exceeded to guard the non-resumable path
This test already covers resumable errors and non-resumable exceptions well. Please also add a case where resumable failures exceed `max_retries` (e.g. the stub raises `requests.exceptions.ConnectionError` on every call) and assert that, after the configured number of retries, the exception is propagated. This will confirm the lazy import refactor preserved the retry limit behaviour and that resumable errors are not retried indefinitely.
Suggested implementation:
```python
out = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, check=True
).stdout.strip()
assert out == "False False", f"expected neither to be imported, got {out}"
def test_downloader_stops_retrying_after_max_retries(tmp_path, monkeypatch) -> None:
"""
Ensure resumable failures are not retried indefinitely and that the configured
retry limit is respected: once the maximum number of retries is exceeded,
the last requests.exceptions.ConnectionError is propagated.
"""
import requests.exceptions
# import the Downloader from the helpers module under test
from poetry.utils.helpers import Downloader # adjust import to match project layout
# choose a small max_retries value to keep the test fast and deterministic
max_retries = 3
# create a destination path in the tmp directory
dest = tmp_path / "downloaded-file"
call_count = {"value": 0}
def always_failing_get(*args, **kwargs):
# track how many times the underlying request function is called
call_count["value"] += 1
# emulate a resumable requests error on every attempt
raise requests.exceptions.ConnectionError("synthetic connection error")
# patch the low-level requests API used by Downloader so that every call fails
import requests
monkeypatch.setattr(requests, "get", always_failing_get)
downloader = Downloader(max_retries=max_retries)
# The Downloader should try up to max_retries times and then propagate the
# last ConnectionError instead of retrying indefinitely.
try:
downloader.download("https://example.invalid/resource", dest)
except requests.exceptions.ConnectionError as exc:
# ensure we actually attempted the configured number of retries
assert call_count["value"] == max_retries
# the propagated exception should be the same type we raised
assert isinstance(exc, requests.exceptions.ConnectionError)
else:
# if no exception was raised, the retry limit behaviour is broken
raise AssertionError(
"Downloader.download() did not propagate ConnectionError "
f"after {max_retries} resumable failures"
)
def test_downloader_retries_only_the_resumable_requests_errors() -> None:
"""`except ConnectionError` inside this module must be the requests class, not the
builtin. A module-level ``__getattr__`` serves attribute access on the module object
but never bare global lookup inside the module's own functions, so a lazy rewrite can
silently bind the builtin -- which requests' ConnectionError is not a subclass of --
and stop retrying resumable downloads."""
import requests.exceptions
def run_with(exc: type[BaseException], tmp_path_factory: object = None) -> int:
import tempfile
```
The new test assumes the following about your existing `Downloader` implementation and test setup; please align these details with the actual code:
1. **Downloader import and constructor**
- Adjust `from poetry.utils.helpers import Downloader` to import `Downloader` from the correct module under test (e.g. `from poetry.utils.helpers_lazy_imports import Downloader` or wherever it actually lives).
- If `Downloader` does not take `max_retries` as a constructor argument, pass the retry limit using the real API (for example, `Downloader(retries=max_retries)`, `Downloader(session, max_retries=max_retries)`, or via configuration on `download()`).
2. **Download method signature**
- The test uses `downloader.download(url, dest)`. If your real method has a different signature (e.g. `download(url, dest, checksum=None)` or returns a value), update the call and assertions accordingly while keeping the behaviour-under-test: it should perform `max_retries` attempts and then raise `requests.exceptions.ConnectionError`.
3. **Requests patching location**
- The test currently patches `requests.get`. If `Downloader` uses a different entry point (e.g. `requests.Session.get`, `requests.request`, or `urllib3`), update the `monkeypatch.setattr(...)` target to match the actual call used in the resumable path, so all attempts hit the failing stub.
4. **Fixtures and typing**
- The test uses `tmp_path` and `monkeypatch` as standard pytest fixtures. Ensure your test file is configured to use these fixtures (they are built-in in pytest).
- If you use type hints for fixtures, you can annotate them (e.g. `tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch`) and add any necessary imports (`import pathlib`, `import pytest`) at the top of the file.
Once these adjustments are made to reflect your actual `Downloader` implementation, this test will verify that resumable errors respect the configured retry limit and are not retried indefinitely, preserving the intended behaviour after the lazy import refactor.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
I gave it a quick try and it seems to be worth it. However, I wonder whether we could extract We can omit the reexports of requests and authenticator in helpers since this should not be used by anybody. However, when moving Please let me know if you want to give this proposal a try. Otherwise, I may have a look for myself. |
Refs python-poetry#11004 review feedback. Co-Authored-By: Aiden <aiden@weco.ai>
|
Thanks, I gave this a try. I moved I also added coverage for retry exhaustion: with Validation on this exact commit: 60 focused tests passed; the broader affected set passed 182 tests with 1 skip; mypy and targeted pre-commit checks are clean. The paired import measurement moved from 149.85 ms to 78.08 ms. |
console/application.py imports two filesystem helpers from poetry.utils.helpers, but that module imports requests.exceptions, requests.utils and poetry.utils.authenticator at module level. All four are used only inside Downloader, so every poetry command pays to import the HTTP stack at startup, including ones that never open a socket. Move them to their point of use and keep them importable from the module via a PEP 562 __getattr__. Importing poetry.console.application goes from 156.8 ms to 82.3 ms, and requests and urllib3 stay out of sys.modules. The retry site imports ConnectionError into function scope rather than relying on the module __getattr__: a module-level __getattr__ serves attribute access on the module object but not bare global lookup inside the module own functions, so the lazy form would silently bind the builtin ConnectionError, which requests ConnectionError is not a subclass of.
CI runs mypy over tests as well as src; Literal[False] on __exit__ and a return annotation on iter_content.
Refs python-poetry#11004 review feedback. Co-Authored-By: Aiden <aiden@weco.ai>
7e36ae5 to
916bd02
Compare
Summary
src/poetry/console/application.pyimports two filesystem helpers frompoetry.utils.helpers:poetry/utils/helpers.pyimports the HTTP stack at module level:All four are used only inside
Downloader, but every poetry command pays for them atstartup, including ones that never open a socket. This moves them to their point of use
and keeps them importable from the module through a PEP 562
__getattr__.Numbers
Cost of
import poetry.console.applicationin a fresh interpreter, measured as the walltime of
python -B -c "import poetry.console.application"minus the wall time ofpython -B -c "pass"in the same window, median of 9, on an otherwise idle core,CPython 3.12.13:
requestsandurllib3drop out ofsys.modulesentirely for commands that do not usethem, which is where the time goes.
Two caveats worth stating. The absolute number depends on what else is installed in the
environment: in a second virtualenv on the same machine
requestsalone costs 104 msrather than 36 ms, and the same change measured 317 ms to 117 ms there. The ratio is the
stable part, not the milliseconds. And this is process startup only; it does not make
any command's actual work faster.
For context on why startup is worth anything here, #10625 measured
poetry --versionatabout 217 ms and
poetry --version --no-pluginsat about 116 ms. That issue and #10992are about the plugin-discovery half of that gap. This is the other half, the part that
remains with
--no-plugins, and the two are independent.Behaviour is unchanged
ChunkedEncodingError,ConnectionError,atomic_openandget_default_authenticatorremain importable from
poetry.utils.helpersand resolve to the same objects.dir()onthe module is unchanged.
The part that needed care: a module-level
__getattr__serves attribute access on themodule object, but never a bare global name lookup inside the module's own functions.
So writing
with
ConnectionErrorsupplied lazily would silently bind the builtinConnectionError.requests.exceptions.ConnectionErroris not a subclass of it, soresumable downloads would stop retrying and turn into hard failures, and nothing in the
existing test suite would notice. The retry site imports both names into function scope
instead, where the local binding shadows the builtin correctly.
Tests
tests/utils/test_helpers_lazy_imports.py, 11 tests:dir(), and is the same objectAttributeErrorpoetry.console.applicationleavesrequestsandurllib3out ofsys.modules- this is the regression the change exists to prevent, and it fails oncurrent
mainDownloaderstill retriesConnectionError,ChunkedEncodingErrorandSSLError(the last subclasses requests'
ConnectionError, so it is caught by the same clause,and only if that name is the requests class), and still propagates
ValueErrorandAttributeErrorrather than swallowing them in a broadexceptI checked these fail when they should. On unmodified
mainthe import test fails. On avariant that lets
ConnectionErrorfall through to the builtin, and on one that widensthe clause to
except Exception, the retry test fails.tests/utilsgives 358 passed / 23 failed / 21 skipped; the same 23 fail by name on anunmodified checkout of this base and need Python interpreter managers this environment
does not have.
mypyreports no issues on the changed file.ruff checkis clean onboth files, apart from one pre-existing
PLC0206inmerge_dictsthat is present onmaintoo and is untouched here.Notes
I tried a number of shapes for this, each scored against the unmodified module for
identical public surface and identical retry behaviour. The record is at
https://dashboard.weco.ai/share/kStcipB3sa9sf-YTFM_fv0G6_ZyLa61H
The larger variants also defer
logging,hashlib,tarfile,zipfileand themodule-level
prioritised_hash_typestables. They measured between 78.8 ms and 81.7 ms,a 2.9 ms span against a 3.8 ms run-to-run noise band, so they are not distinguishable
from this one. They are three times the diff for no measurable gain, and they move
module-level public attributes behind a hook, so I kept the smaller change. Happy to
push the broader version if you would prefer it.
Pull Request Check List