Skip to content

Add support for deferred updates / minimum release age#10824

Open
radoering wants to merge 3 commits intopython-poetry:mainfrom
radoering:min-release-age
Open

Add support for deferred updates / minimum release age#10824
radoering wants to merge 3 commits intopython-poetry:mainfrom
radoering:min-release-age

Conversation

@radoering
Copy link
Copy Markdown
Member

Pull Request Check List

Resolves: #10646
Closes: #10763, #10813

  • Added tests for changed code.
  • Updated documentation for changed code.

Adds three new config options (see docs/configuration.md for details) - one per commit:

  • solver.min-release-age
  • solver.min-release-age-exclude
  • solver.min-release-age-exclude-source

Design decisions:

Location

I prefer a config option over a pyproject.toml setting. Both have their merits. See https://github.com/orgs/python-poetry/discussions/10555#discussioncomment-14461277

I put it under solver because the filtering is done during dependency resolution. At install time, the locked versions are installed - no additional check for upload time.

Naming

#10646 (comment) lists different names used by several tools. I decided for the most common one, but abbreviated "minimum" with "min" for consistency - we already have "max-workers" and "max-retries".

Values

I decided to keep it simple for now: Just plain integers, which are "days". I think "days" makes the most sense.
We can still allow ISO 8601 durations (e.g. "PT24H", "P7D", "P30D") or "friendly" durations (e.g. "24 hours", "1 week", "30 days") later because they should not conflict with plain integers.

Overrides for packages

Some tools just allow to exclude packages from the filtering, others allow to set individual min-release-age values per package. Same as before: I want to keep it simple for now so that I decided for min-release-age-exclude being just a list of package names since we already have other settings that are a list of package names. We can still introduce a min-release-age-package (or similar) later if there is enough demand.

Overrides for sources

This is a setting I did not see in other tools (maybe I have just missed it?), but I implemented it anyway because I think it is quite useful and it was simple enough to implement. min-release-age-exclude-source allows to list sources you want to exclude from the filtering. I believe a common use case might be that you only want to do deferred updates for public packages (from PyPI for example) but not for your internal packages from your private index.

Copy link
Copy Markdown

@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 - I've found 3 issues, and left some high level feedback:

  • In HTTPRepository._find_packages, the debug log for ignored versions currently interpolates the full versions list rather than just filtered_out, which makes the message misleading; consider logging only the actually filtered versions (e.g., filtered_out or self._age_filtered_versions[name]).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `HTTPRepository._find_packages`, the debug log for ignored versions currently interpolates the full `versions` list rather than just `filtered_out`, which makes the message misleading; consider logging only the actually filtered versions (e.g., `filtered_out` or `self._age_filtered_versions[name]`).

## Individual Comments

### Comment 1
<location path="src/poetry/repositories/http_repository.py" line_range="200-202" />
<code_context>
+                else:
+                    accepted.append((version, yanked))
+            if filtered_out:
+                self._age_filtered_versions[name] = filtered_out
+                self._log(
+                    f"Ignoring {name} version(s) due to "
+                    f"solver.min-release-age={self._min_release_age}: {versions}",
+                    level="debug",
</code_context>
<issue_to_address>
**issue:** The debug message logs all versions, not just the filtered-out ones, which can be misleading.

Here `versions` still includes both accepted and filtered entries. Since you’ve already collected excluded versions in `filtered_out`, the log should use `filtered_out` (or clearly distinguish accepted vs. ignored) so the message accurately reflects which versions are actually being ignored.
</issue_to_address>

### Comment 2
<location path="src/poetry/repositories/http_repository.py" line_range="100" />
<code_context>
+                    canonicalize_name(n)
+                    for n in (config.get("solver.min-release-age-exclude") or [])
+                }
+        self._age_filtered_versions: dict[NormalizedName, set[Version]] = {}
         # We are tracking if a domain supports range requests or not to avoid
         # unnecessary requests.
</code_context>
<issue_to_address>
**suggestion (bug_risk):** _age_filtered_versions is never reset, so logs may accumulate stale entries across multiple solves.

Because `_age_filtered_versions` is stored on the repository instance and only ever appended to, entries from earlier solves will still be present when `log_age_filtered_versions()` runs in later solves. If this log is meant to reflect only the current solve, clear `_age_filtered_versions` at the start of each solve (or resolution round), or store it in per-solve state instead of on the repository instance.
</issue_to_address>

### Comment 3
<location path="tests/repositories/test_http_repository.py" line_range="161-170" />
<code_context>
+            ],
+            True,
+        ),
+        (  # some files old enough, some files not
+            [
+                Link("https://foo.com/pkg-1.0.tar.gz"),
+                Link(
+                    "https://foo.com/pkg-1.0-py3-none-any.whl",
+                    upload_time="2017-08-15T00:00:00Z",
+                ),
+            ],
+            True,
+        ),
+        (  # all files not old enough
</code_context>
<issue_to_address>
**suggestion (testing):** Parametrized cases for `_is_version_too_recent` contain a duplicated scenario; likely missing the intended "mixed old/new" upload-time case

In `test_is_version_too_recent`, the third and fourth parametrized cases use the same `links` (one `Link` without `upload_time`, one with `upload_time="2017-08-15T00:00:00Z"`) but describe different scenarios. As written, the "some files old enough, some files not" case never exercises a mix of pre‑ and post‑cutoff upload times. Please either adjust that case so one link is before `2017-08-10` and the other after, or remove it if the extra scenario isn’t needed.

```suggestion
        (  # some files old enough, some files not
            [
                Link(
                    "https://foo.com/pkg-1.0.tar.gz",
                    upload_time="2017-08-01T00:00:00Z",
                ),
                Link(
                    "https://foo.com/pkg-1.0-py3-none-any.whl",
                    upload_time="2017-08-15T00:00:00Z",
                ),
            ],
            True,
        ),
```
</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.

Copy link
Copy Markdown

@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 - I've found 3 issues, and left some high level feedback:

  • The matching logic for solver.min-release-age-exclude-source only compares raw strings against name and url, so you may want to normalise URLs (e.g. trailing slashes, case) and/or source names to avoid surprising mismatches between config and repository definitions.
  • In _find_packages, the debug log for filtered versions uses the raw set[Version] representation, whereas log_age_filtered_versions formats them nicely; consider reusing the same formatting helper there so logs are consistent and easier to read.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The matching logic for `solver.min-release-age-exclude-source` only compares raw strings against `name` and `url`, so you may want to normalise URLs (e.g. trailing slashes, case) and/or source names to avoid surprising mismatches between config and repository definitions.
- In `_find_packages`, the debug log for filtered versions uses the raw `set[Version]` representation, whereas `log_age_filtered_versions` formats them nicely; consider reusing the same formatting helper there so logs are consistent and easier to read.

## Individual Comments

### Comment 1
<location path="tests/repositories/test_http_repository.py" line_range="99-108" />
<code_context>
+@pytest.mark.parametrize(
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test case where upload_time is exactly equal to the cutoff to pin down the boundary condition for `_is_version_too_recent`.

The existing `test_is_version_too_recent` cases cover only `< cutoff` and `> cutoff`. Because `_is_version_too_recent` uses a strict `>` comparison, an upload exactly at `_min_release_age_cutoff` should return `False`. Please add a parameterized case with `upload_time == datetime(2017, 8, 10, tzinfo=timezone.utc)` to codify this boundary behavior and guard against future changes to `>=`.

Suggested implementation:

```python
@pytest.mark.parametrize(
    ("upload_time", "expected"),
    [
        (datetime(2017, 8, 9, tzinfo=timezone.utc), False),  # before cutoff
        (datetime(2017, 8, 10, tzinfo=timezone.utc), False),  # exactly at cutoff
        (datetime(2017, 8, 11, tzinfo=timezone.utc), True),  # after cutoff
    ],
)
def test_is_version_too_recent(

```

If the existing `test_is_version_too_recent` uses different parameter names, variable names, or cutoff dates, adjust the three tuple entries accordingly so that:
1. One case is strictly before `_min_release_age_cutoff` and expects `False`.
2. The new case is exactly equal to `_min_release_age_cutoff` and expects `False`.
3. One case is strictly after `_min_release_age_cutoff` and expects `True`.

Also ensure that `datetime` and `timezone` are imported from the `datetime` module at the top of `tests/repositories/test_http_repository.py` (they likely already are).
</issue_to_address>

### Comment 2
<location path="tests/config/test_config.py" line_range="47-48" />
<code_context>
         ("virtualenvs.create", True),
         ("requests.max-retries", 0),
+        ("solver.min-release-age", 0),
     ],
 )
-def test_config_get_default_value(config: Config, name: str, value: bool) -> None:
+def test_config_get_default_value(config: Config, name: str, value: bool | int) -> None:
     assert config.get(name) is value
</code_context>
<issue_to_address>
**suggestion (testing):** Add dedicated tests for `str_list_normalizer` and the config mapping of the new solver-related options.

The new integer default is covered via `("solver.min-release-age", 0)`, but the list-based options and their normalizer aren’t. Please add small tests that:

- Assert `_get_normalizer("solver.min-release-age-exclude-source") is str_list_normalizer`.
- Assert `str_list_normalizer("foo, bar ,  baz") == ["foo", "bar", "baz"]` and that empty segments (e.g. `", ,"`) are dropped.

This will make the normalization behavior explicit and guard against regressions beyond the CLI tests.

Suggested implementation:

```python
def test_config_get_default_value(config: Config, name: str, value: bool | int) -> None:
    assert config.get(name) is value


def test_solver_min_release_age_exclude_source_uses_str_list_normalizer() -> None:
    assert _get_normalizer("solver.min-release-age-exclude-source") is str_list_normalizer


@pytest.mark.parametrize(
    ("value", "expected"),
    [
        ("foo, bar ,  baz", ["foo", "bar", "baz"]),
        (", ,", []),
    ],
)
def test_str_list_normalizer_trims_and_ignores_empty_segments(value: str, expected: list[str]) -> None:
    assert str_list_normalizer(value) == expected

```

To make these tests work, ensure the following at the top of `tests/config/test_config.py`:
1. `pytest` is imported (if not already present):
   `import pytest`
2. `_get_normalizer` and `str_list_normalizer` are imported from the module where they are defined, likely:
   `from poetry.config.config import Config, _get_normalizer, str_list_normalizer`
   adjusting the import path if your project structure differs.
</issue_to_address>

### Comment 3
<location path="docs/configuration.md" line_range="426-434" />
<code_context>
+
+*Introduced in 2.4.0*
+
+A comma-separated list of package names that should be excluded from the
+[`solver.min-release-age`](#solvermin-release-age) filter.
+Versions of these packages will always be considered by the solver,
+regardless of their upload age.
+
+```bash
+poetry config solver.min-release-age-exclude "my-package,other-package"
+```
+
</code_context>
<issue_to_address>
**suggestion:** Consider clarifying how the comma-separated list is parsed (whitespace, case-sensitivity, and name normalization).

It would help to document exactly how this list is interpreted: whether spaces around commas are allowed (e.g. `"my-package, other-package"`), whether matching is case-sensitive, and whether names are normalized like in `pyproject.toml` (hyphens vs underscores). This can help avoid misconfigurations.

```suggestion
### `solver.min-release-age-exclude`

**Type**: `string`

**Default**: *not set*

**Environment Variable**: `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE`

*Introduced in 2.4.0*

A comma-separated list of package names that should be excluded from the
[`solver.min-release-age`](#solvermin-release-age) filter. Versions of these
packages will always be considered by the solver, regardless of their upload age.

Names in this list:

- are split on commas, with surrounding whitespace ignored
  (for example, `poetry config solver.min-release-age-exclude "foo, bar"` is valid),
- are matched case-insensitively, and
- are normalized in the same way as package names in `pyproject.toml`
  (for example, `my_package` and `my-package` are treated as the same name).

```bash
poetry config solver.min-release-age-exclude "my-package,other-package"
```
```
</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.

@dimbleby
Copy link
Copy Markdown
Contributor

dimbleby commented Apr 5, 2026

Havent looked too closely at this, does it bring any reason to revive #9170 (stop using the JSON API)? Feels like it might, but perhaps not.

@radoering
Copy link
Copy Markdown
Member Author

does it bring any reason to revive #9170 (stop using the JSON API)?

I do not think that it changes anything since the JSON API provides the relevant information.

@radoering
Copy link
Copy Markdown
Member Author

radoering commented Apr 6, 2026

  • The matching logic for solver.min-release-age-exclude-source only compares raw strings against name and url, so you may want to normalise URLs (e.g. trailing slashes, case) and/or source names to avoid surprising mismatches between config and repository definitions.

Good point. I changed the name comparison to use lower case because we consider source names case-insensitive. I did not change the URL comparison because I think there is no normalization for URLs of sources.
Actually, LegacyRepository does an .rstrip('/')...

  • In _find_packages, the debug log for filtered versions uses the raw set[Version] representation, whereas log_age_filtered_versions formats them nicely; consider reusing the same formatting helper there so logs are consistent and easier to read.

log_age_filtered_versions is only called once during dependency resolution. _find_packages may be called way more often. Further, the message in _find_packages is only printed in verbose mode so that it is probably not worth doing the work for a nicer formatting here.

Actually, output is worse than I thought: {Version(epoch=0, release=Release(major=2, minor=3, patch=2, extra=(), precision=3, text='2.3.2', _compare_key=(2, 3, 2)), pre=None, post=None, dev=None, local=None, text='2.3.2', _compare_key=(0, Release(major=2, minor=3, patch=2, extra=(), precision=3, text='2.3.2', _compare_key=(2, 3, 2)), ReleaseTag(phase='z', number=0), ReleaseTag(phase='', number=0), ReleaseTag(phase='z', number=0), ((0, ''),)))} Thus, I suppose we have to change it anyway and improve the _log method to be more lazy if performance is an issue.

@radoering
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@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 - I've found 1 issue, and left some high level feedback:

  • In HTTPRepository._is_name_excluded_from_min_release_age and _is_url_excluded_from_min_release_age, you rebuild normalized sets on every call; consider normalizing and storing the lowercased names and stripped URLs once in __init__ to avoid repeated allocations and improve clarity.
  • The base Repository.log_age_filtered_versions currently just passes; if it is expected to be invoked via RepositoryPool.log_age_filtered_versions, it might be clearer to add a docstring or make it explicitly a no-op hook so that future subclasses know they can override it to participate in the logging.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `HTTPRepository._is_name_excluded_from_min_release_age` and `_is_url_excluded_from_min_release_age`, you rebuild normalized sets on every call; consider normalizing and storing the lowercased names and stripped URLs once in `__init__` to avoid repeated allocations and improve clarity.
- The base `Repository.log_age_filtered_versions` currently just `pass`es; if it is expected to be invoked via `RepositoryPool.log_age_filtered_versions`, it might be clearer to add a docstring or make it explicitly a no-op hook so that future subclasses know they can override it to participate in the logging.

## Individual Comments

### Comment 1
<location path="tests/repositories/test_repository_pool.py" line_range="293-302" />
<code_context>
     assert repo2.search("demo") == pool.search("demo") == [demo_package]
+
+
+@pytest.mark.parametrize(("level", "reset"), [("warning", True), ("info", False)])
+def test_log_age_filtered_versions_includes_explicit_repositories(
+    mocker: MockerFixture, level: str, reset: bool
+) -> None:
+    primary = Repository("primary")
+    explicit = Repository("explicit")
+
+    primary_spy = mocker.spy(primary, "log_age_filtered_versions")
+    explicit_spy = mocker.spy(explicit, "log_age_filtered_versions")
+
+    pool = RepositoryPool([primary])
+    pool.add_repository(explicit, priority=Priority.EXPLICIT)
+
+    pool.log_age_filtered_versions(level=level, reset=reset)
+
+    primary_spy.assert_called_once_with(level=level, reset=reset)
+    explicit_spy.assert_called_once_with(level=level, reset=reset)
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding solver-level tests to verify `log_age_filtered_versions` is called on both success and failure paths

This test confirms `RepositoryPool.log_age_filtered_versions` fans out correctly, but `Solver.solve` now also calls it with `level="warning", reset=True` on `SolverProblemError` and `level="info"` on success. Please add solver-level tests (e.g. in `tests/puzzle/test_solver.py`) that spy on `RepositoryPool.log_age_filtered_versions` and assert these two call patterns on failure vs success to lock in this behaviour and avoid regressions.

Suggested implementation:

```python
import logging


```

```python
def test_solver_logs_age_filtered_versions_on_failure(
    mocker: MockerFixture,
    repository_pool: RepositoryPool,
    solver: Solver,
) -> None:
    """
    When the solver fails with a SolverProblemError, it should call
    RepositoryPool.log_age_filtered_versions(level="warning", reset=True).
    """
    log_age_filtered_versions_spy = mocker.spy(
        repository_pool, "log_age_filtered_versions"
    )

    # Force a SolverProblemError from solve()
    mock_solve = mocker.patch.object(
        solver, "_solve", side_effect=SolverProblemError("test failure")
    )

    with pytest.raises(SolverProblemError):
        solver.solve()

    mock_solve.assert_called_once()
    log_age_filtered_versions_spy.assert_called_once_with(level="warning", reset=True)


def test_solver_logs_age_filtered_versions_on_success(
    mocker: MockerFixture,
    repository_pool: RepositoryPool,
    solver: Solver,
) -> None:
    """
    When the solver succeeds, it should call
    RepositoryPool.log_age_filtered_versions(level="info", reset=False).
    """
    log_age_filtered_versions_spy = mocker.spy(
        repository_pool, "log_age_filtered_versions"
    )

    # Use a trivial, successful _solve implementation
    mock_result = mocker.Mock()
    mocker.patch.object(solver, "_solve", return_value=mock_result)

    result = solver.solve()

    assert result is mock_result
    log_age_filtered_versions_spy.assert_called_once_with(level="info", reset=False)


```

1. Ensure the following imports and fixtures are available in `tests/puzzle/test_solver.py` (or adjust the code above to match existing ones):
   - `from poetry.puzzle.solver import Solver, SolverProblemError`
   - `from poetry.repositories import RepositoryPool`
   - `from _pytest.monkeypatch import MockerFixture` (or the correct `mocker` fixture import for your test setup).
   - `import pytest`
2. If `repository_pool` and `solver` fixtures do not already exist in `tests/puzzle/test_solver.py`, create them (or adapt the tests to use the existing construction helpers), ensuring:
   - `solver` is constructed with the `repository_pool` instance used in the spy, so that `solver.solve()` internally calls `repository_pool.log_age_filtered_versions(...)`.
   - `_solve` is the internal method invoked by `Solver.solve` in your codebase; if the internal method name differs, update the `mocker.patch.object(solver, "_solve", ...)` calls accordingly.
3. If your implementation of `Solver.solve` passes different `reset` arguments (e.g., always `True` on both paths or uses configuration), adjust the asserted arguments in `assert_called_once_with(...)` to match the intended behaviour.
</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
Copy Markdown
Member Author

radoering commented Apr 6, 2026

  • In HTTPRepository._is_name_excluded_from_min_release_age and _is_url_excluded_from_min_release_age, you rebuild normalized sets on every call; consider normalizing and storing the lowercased names and stripped URLs once in __init__ to avoid repeated allocations and improve clarity.

The methods are only called once in __init__.

  • The base Repository.log_age_filtered_versions currently just passes; if it is expected to be invoked via RepositoryPool.log_age_filtered_versions, it might be clearer to add a docstring or make it explicitly a no-op hook so that future subclasses know they can override it to participate in the logging.

I added a docstring.

@radoering
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@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 - I've left some high level feedback:

  • In HTTPRepository.__init__, _is_name_excluded_from_min_release_age and _is_url_excluded_from_min_release_age rebuild lowercased/stripped sets every time they are called; consider normalizing the exclude list once up front (e.g. precomputed lowercased names and rstrip('/') URLs) to avoid repeated work.
  • In _find_packages, self._age_filtered_versions[name] = filtered_out overwrites any previously recorded filtered versions for the same package name; if a repository is queried multiple times with different constraints, you may want to accumulate (|=) the versions instead of replacing them so the subsequent logging shows the complete set filtered during the run.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `HTTPRepository.__init__`, `_is_name_excluded_from_min_release_age` and `_is_url_excluded_from_min_release_age` rebuild lowercased/stripped sets every time they are called; consider normalizing the exclude list once up front (e.g. precomputed lowercased names and rstrip('/') URLs) to avoid repeated work.
- In `_find_packages`, `self._age_filtered_versions[name] = filtered_out` overwrites any previously recorded filtered versions for the same package name; if a repository is queried multiple times with different constraints, you may want to accumulate (`|=`) the versions instead of replacing them so the subsequent logging shows the complete set filtered during the run.

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
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@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 - I've left some high level feedback:

  • In _is_url_excluded_from_min_release_age, you only strip trailing slashes but otherwise compare URLs as-is; consider normalizing scheme/host case and perhaps resolving minor differences (e.g. http vs https, trailing /simple/) so that configuration for solver.min-release-age-exclude-source behaves more predictably across equivalent URLs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_is_url_excluded_from_min_release_age`, you only strip trailing slashes but otherwise compare URLs as-is; consider normalizing scheme/host case and perhaps resolving minor differences (e.g. `http` vs `https`, trailing `/simple/`) so that configuration for `solver.min-release-age-exclude-source` behaves more predictably across equivalent URLs.

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
Copy Markdown
Member Author

  • In _is_url_excluded_from_min_release_age, you only strip trailing slashes but otherwise compare URLs as-is; consider normalizing scheme/host case and perhaps resolving minor differences (e.g. http vs https, trailing /simple/) so that configuration for solver.min-release-age-exclude-source behaves more predictably across equivalent URLs.

Out of scope for now. Trailing slashes are only removed because we have already done this ourselves in LegacyRepository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support deferred updates / min release age

2 participants