Skip to content

Comments

drop support for Python 3.9#10634

Merged
radoering merged 2 commits intopython-poetry:mainfrom
radoering:drop-python39
Dec 7, 2025
Merged

drop support for Python 3.9#10634
radoering merged 2 commits intopython-poetry:mainfrom
radoering:drop-python39

Conversation

@radoering
Copy link
Member

@radoering radoering commented Nov 29, 2025

Summary by Sourcery

Drop support for Python 3.9 and align codebase, metadata, and CI configuration with Python 3.10+ only.

Enhancements:

  • Simplify compatibility layer by always using importlib.metadata and add a deprecation path for the old metadata import.
  • Clean up type hints and TYPE_CHECKING imports to rely on Python 3.10+ standard typing features.
  • Standardize subprocess and file I/O encoding usage to always use locale or explicit newline settings, removing version-dependent branches.

Build:

  • Raise the minimum required Python version to 3.10 in pyproject configuration and remove conditional dependencies and tooling for Python <3.10.

CI:

  • Update test matrix workflows to stop running tests on Python 3.9 and adjust plugin pyproject rewriting to target requires-python instead of python.

Tests:

  • Adjust tests and fixtures to reflect the new minimum Python version, including metadata discovery patches, encoding expectations, and typing changes.

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 29, 2025

Reviewer's Guide

Drops Python 3.9 support and standardizes the codebase, tooling, and tests on Python 3.10+, including migrating to stdlib importlib.metadata, simplifying encoding/newline handling, and modernizing type hints and CI/test configuration.

Sequence diagram for deprecated metadata access via poetry.utils._compat

sequenceDiagram
    actor Caller
    participant Compat as poetry.utils._compat
    participant ImportlibMetadata as importlib.metadata

    Caller->>Compat: from poetry.utils._compat import metadata
    Compat->>Compat: __getattr__("metadata")
    Compat->>Caller: ImportlibMetadata

    Caller->>ImportlibMetadata: version("poetry")
    ImportlibMetadata-->>Caller: "2.2.1" (example)

    Caller->>ImportlibMetadata: (recommended) from importlib import metadata
    ImportlibMetadata-->>Caller: metadata module
Loading

Class diagram for modules using importlib.metadata after dropping Python 3.9

classDiagram
    class ImportlibMetadata {
        +version(distribution: str) str
    }

    class CompatModule {
        +bool WINDOWS
        +decode(data: bytes) str
        +encode(text: str) bytes
        +getencoding() str
        +__getattr__(name: str) object
    }

    class PoetryVersionModule {
        +str __version__
    }

    class AboutCommand {
        +handle() int
    }

    CompatModule --> ImportlibMetadata : returns metadata
    PoetryVersionModule --> ImportlibMetadata : uses for version
    AboutCommand --> ImportlibMetadata : uses for metadata
Loading

File-Level Changes

Change Details Files
Raise minimum supported Python to 3.10 and remove 3.9-specific dependencies/tooling.
  • Update requires-python in pyproject metadata to >=3.10,<4.0
  • Remove conditional import of importlib-metadata dependency and setuptools dev dependency for <3.10
  • Adjust Ruff/formatting configuration target version and test-matrix workflow to stop running Python 3.9
pyproject.toml
.github/workflows/tests.yaml
.github/workflows/.tests-matrix.yaml
Migrate metadata access from poetry.utils._compat to the stdlib importlib.metadata with a deprecation shim.
  • Replace uses of poetry.utils._compat.metadata with importlib.metadata in core code and tests
  • Introduce a module-level getattr in poetry.utils._compat that exposes metadata via importlib.metadata and emits a DeprecationWarning
  • Remove metadata from all in _compat so new code won’t import it from there
  • Update tests to patch importlib.metadata.Distribution.discover instead of the compat alias
src/poetry/utils/_compat.py
src/poetry/__version__.py
src/poetry/console/commands/about.py
src/poetry/console/commands/self/show/plugins.py
tests/repositories/test_installed_repository.py
tests/console/commands/test_about.py
tests/console/commands/self/test_show_plugins.py
Simplify subprocess and file I/O encoding/newline handling now that only Python 3.10+ is supported.
  • Always pass encoding="locale" for subprocess.check_output where it was previously conditional on sys.version_info
  • Always write text files with newline="\n" for fixture generation instead of version-conditional kwargs
  • Adjust Windows test helpers to write batch/cmd files using encoding="locale" unconditionally
src/poetry/utils/env/env_manager.py
src/poetry/utils/env/base_env.py
tests/repositories/fixtures/pypi.org/generate.py
tests/console/commands/test_run.py
tests/utils/test_python_manager.py
Modernize typing annotations and imports for Python 3.10+, using PEP 604 unions and collections.abc under TYPE_CHECKING.
  • Switch typing.Callable imports in runtime code and tests to collections.abc.Callable under TYPE_CHECKING
  • Replace Union[...] with PEP 604 style unions (
) in type aliases
  • Tighten/adjust dict and tuple type aliases to use modern syntax and more precise value types
  • Refresh lockfile and related project artifacts to align with the new Python baseline and code changes.
    • Regenerate poetry.lock to reflect dependency graph without 3.9-only packages and with updated constraints
    • Touch related source and test files (e.g., build command, plugin manager, installed_repository, site_packages, helpers, env tests, show command) as needed for compatibility or formatting
    poetry.lock
    src/poetry/console/commands/build.py
    src/poetry/plugins/plugin_manager.py
    src/poetry/repositories/installed_repository.py
    src/poetry/utils/env/site_packages.py
    tests/console/commands/test_show.py
    tests/helpers.py
    tests/utils/env/test_env.py

    Possibly linked issues

    • #0: PR addresses the reported Python 3.9 dulwich incompatibility by officially dropping 3.9 and requiring Python 3.10+.

    Tips and commands

    Interacting with Sourcery

    • Trigger a new review: Comment @sourcery-ai review on the pull request.
    • Continue discussions: Reply directly to Sourcery's review comments.
    • Generate a GitHub issue from a review comment: Ask Sourcery to create an
      issue from a review comment by replying to it. You can also reply to a
      review comment with @sourcery-ai issue to create an issue from it.
    • Generate a pull request title: Write @sourcery-ai anywhere in the pull
      request title to generate a title at any time. You can also comment
      @sourcery-ai title on the pull request to (re-)generate the title at any time.
    • Generate a pull request summary: Write @sourcery-ai summary anywhere in
      the pull request body to generate a PR summary at any time exactly where you
      want it. You can also comment @sourcery-ai summary on the pull request to
      (re-)generate the summary at any time.
    • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
      request to (re-)generate the reviewer's guide at any time.
    • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
      pull request to resolve all Sourcery comments. Useful if you've already
      addressed all the comments and don't want to see them anymore.
    • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
      request to dismiss all existing Sourcery reviews. Especially useful if you
      want to start fresh with a new review - don't forget to comment
      @sourcery-ai review to trigger a new review!

    Customizing Your Experience

    Access your dashboard to:

    • Enable or disable review features such as the Sourcery-generated pull request
      summary, the reviewer's guide, and others.
    • Change the review language.
    • Add, remove or edit custom review instructions.
    • Adjust other review settings.

    Getting Help

    Copy link

    @sourcery-ai sourcery-ai bot left a comment

    Choose a reason for hiding this comment

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

    Hey there - I've reviewed your changes - here's some feedback:

    Blocking issues:

    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

    General comments:

    • Removing metadata from poetry.utils._compat is a behavioral/binary change for anyone importing it directly; consider keeping a shim re-export (or at least a deprecation path) to avoid breaking external users that relied on poetry.utils._compat.metadata.
    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Overall Comments
    - Removing `metadata` from `poetry.utils._compat` is a behavioral/binary change for anyone importing it directly; consider keeping a shim re-export (or at least a deprecation path) to avoid breaking external users that relied on `poetry.utils._compat.metadata`.
    
    ## Individual Comments
    
    ### Comment 1
    <location> `src/poetry/utils/env/base_env.py:449-451` </location>
    <code_context>
                    output = subprocess.check_output(
                        cmd, stderr=stderr, env=env, text=True, encoding="locale", **kwargs
                    )
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 2
    <location> `src/poetry/utils/env/env_manager.py:287-289` </location>
    <code_context>
                    env_dir = subprocess.check_output(
                        [python, "-c", GET_ENV_PATH_ONELINER], text=True, encoding="locale"
                    ).strip("\n")
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 3
    <location> `src/poetry/utils/env/env_manager.py:331-335` </location>
    <code_context>
                python_version_string = subprocess.check_output(
                    [python, "-c", GET_PYTHON_VERSION_ONELINER],
                    text=True,
                    encoding="locale",
                )
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 4
    <location> `src/poetry/utils/env/base_env.py:453` </location>
    <code_context>
        def _run(self, cmd: list[str], **kwargs: Any) -> str:
            """
            Run a command inside the Python environment.
            """
            call = kwargs.pop("call", False)
            env = kwargs.pop("env", dict(os.environ))
            stderr = kwargs.pop("stderr", subprocess.STDOUT)
    
            try:
                if call:
                    assert stderr != subprocess.PIPE
                    subprocess.check_call(cmd, stderr=stderr, env=env, **kwargs)
                    output = ""
                else:
                    output = subprocess.check_output(
                        cmd, stderr=stderr, env=env, text=True, encoding="locale", **kwargs
                    )
            except CalledProcessError as e:
                raise EnvCommandError(e)
    
            return output
    
    </code_context>
    
    <issue_to_address>
    **suggestion (code-quality):** Explicitly raise from a previous error ([`raise-from-previous-error`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/raise-from-previous-error/))
    
    ```suggestion
                raise EnvCommandError(e) from e
    ```
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    @radoering
    Copy link
    Member Author

    @sourcery-ai review

    Copy link

    @sourcery-ai sourcery-ai bot left a comment

    Choose a reason for hiding this comment

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

    Hey there - I've reviewed your changes - here's some feedback:

    Blocking issues:

    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
    • Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

    General comments:

    • In poetry/utils/_compat.py, __getattr__ should raise AttributeError for unknown attributes instead of returning None to preserve normal module attribute semantics and avoid confusing failures in callers relying on attribute access.
    • The Perl one-liner in .github/workflows/.tests-matrix.yaml that rewrites requires-python now builds a somewhat convoluted string ("~='${PYTHON_VERSION}'"); double-check the resulting line in pyproject.toml is syntactically valid and matches the intended version constraint.
    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Overall Comments
    - In `poetry/utils/_compat.py`, `__getattr__` should raise `AttributeError` for unknown attributes instead of returning `None` to preserve normal module attribute semantics and avoid confusing failures in callers relying on attribute access.
    - The Perl one-liner in `.github/workflows/.tests-matrix.yaml` that rewrites `requires-python` now builds a somewhat convoluted string (`"~='${PYTHON_VERSION}'"`); double-check the resulting line in `pyproject.toml` is syntactically valid and matches the intended version constraint.
    
    ## Individual Comments
    
    ### Comment 1
    <location> `src/poetry/utils/_compat.py:55-64` </location>
    <code_context>
             return locale.getencoding()
    
    
    +def __getattr__(name: str) -> object:
    +    if name == "metadata":
    +        warnings.warn(
    +            "Importing `metadata` from `poetry.utils._compat` is deprecated;"
    +            " use `importlib.metadata` directly.",
    +            DeprecationWarning,
    +            stacklevel=2,
    +        )
    +        return _metadata
    +    return None
    +
    +
    </code_context>
    
    <issue_to_address>
    **issue (bug_risk):** Module-level __getattr__ should raise AttributeError for unknown attributes instead of returning None.
    
    Returning `None` here violates the expected module-level `__getattr__` contract and will confuse callers and tools that rely on `AttributeError` (e.g. `hasattr`, `getattr(..., default)`). Please change the final line to `raise AttributeError(name)` so only `"metadata"` is special-cased and all other names behave like normal missing attributes.
    </issue_to_address>
    
    ### Comment 2
    <location> `tests/console/commands/test_about.py:20` </location>
    <code_context>
     from __future__ import annotations
    
    -from poetry.utils._compat import metadata
    +from importlib import metadata
    
    
    </code_context>
    
    <issue_to_address>
    **suggestion (testing):** Consider adding a dedicated test for the deprecated `poetry.utils._compat.metadata` access path and its deprecation warning.
    
    Since `metadata` is now exposed via `__getattr__` on `poetry.utils._compat` with a `DeprecationWarning`, please add a test that:
    
    1. Imports `metadata` from `poetry.utils._compat`.
    2. Uses `pytest.warns` to assert a `DeprecationWarning`.
    3. Optionally confirms the object matches `importlib.metadata` (e.g. via `version` or `Distribution`).
    
    This will validate the deprecated access path and protect against regressions in the shim implementation.
    
    Suggested implementation:
    
    ```python
    def test_about(tester: CommandTester) -> None:
    
    
    def test_about(tester: CommandTester) -> None:
        from importlib import metadata
    
        tester.execute()
    
    
    def test_deprecated_metadata_access_path() -> None:
        import importlib.metadata as importlib_metadata
        from poetry.utils import _compat
    
        import pytest
    
        with pytest.warns(DeprecationWarning):
            compat_metadata = _compat.metadata
    
        assert compat_metadata is importlib_metadata
    
    ```
    
    1. If `pytest` is already imported at the top of `tests/console/commands/test_about.py`, remove the inline `import pytest` inside `test_deprecated_metadata_access_path` and use the existing import instead.
    2. If your test suite prefers grouping imports at the top of the file, move `import importlib.metadata as importlib_metadata` and `from poetry.utils import _compat` to the module-level imports and adjust the test accordingly.
    </issue_to_address>
    
    ### Comment 3
    <location> `src/poetry/utils/env/base_env.py:449-451` </location>
    <code_context>
                    output = subprocess.check_output(
                        cmd, stderr=stderr, env=env, text=True, encoding="locale", **kwargs
                    )
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 4
    <location> `src/poetry/utils/env/env_manager.py:287-289` </location>
    <code_context>
                    env_dir = subprocess.check_output(
                        [python, "-c", GET_ENV_PATH_ONELINER], text=True, encoding="locale"
                    ).strip("\n")
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 5
    <location> `src/poetry/utils/env/env_manager.py:331-335` </location>
    <code_context>
                python_version_string = subprocess.check_output(
                    [python, "-c", GET_PYTHON_VERSION_ONELINER],
                    text=True,
                    encoding="locale",
                )
    </code_context>
    
    <issue_to_address>
    **security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'check_output' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
    
    *Source: opengrep*
    </issue_to_address>
    
    ### Comment 6
    <location> `src/poetry/utils/env/base_env.py:453` </location>
    <code_context>
        def _run(self, cmd: list[str], **kwargs: Any) -> str:
            """
            Run a command inside the Python environment.
            """
            call = kwargs.pop("call", False)
            env = kwargs.pop("env", dict(os.environ))
            stderr = kwargs.pop("stderr", subprocess.STDOUT)
    
            try:
                if call:
                    assert stderr != subprocess.PIPE
                    subprocess.check_call(cmd, stderr=stderr, env=env, **kwargs)
                    output = ""
                else:
                    output = subprocess.check_output(
                        cmd, stderr=stderr, env=env, text=True, encoding="locale", **kwargs
                    )
            except CalledProcessError as e:
                raise EnvCommandError(e)
    
            return output
    
    </code_context>
    
    <issue_to_address>
    **suggestion (code-quality):** Explicitly raise from a previous error ([`raise-from-previous-error`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/raise-from-previous-error/))
    
    ```suggestion
                raise EnvCommandError(e) from e
    ```
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    @radoering radoering merged commit c715b5b into python-poetry:main Dec 7, 2025
    53 of 54 checks passed
    @radoering radoering mentioned this pull request Dec 7, 2025
    @radoering radoering deleted the drop-python39 branch December 13, 2025 17:24
    @github-actions
    Copy link

    This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

    @github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 13, 2026
    Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    1 participant