From d65ff0007160ce15b2dcff180bc90072dc7a898b Mon Sep 17 00:00:00 2001 From: finswimmer Date: Sat, 16 May 2026 09:09:51 +0200 Subject: [PATCH] feat: add support for project-specific min-release-age configuration --- docs/configuration.md | 39 ++++++++ docs/pyproject.md | 20 ++++ src/poetry/factory.py | 31 ++++++ src/poetry/json/schemas/poetry.json | 33 +++++++ tests/test_factory.py | 146 ++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 71e5d4e8b07..da32e210dc5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -437,6 +437,19 @@ If a source does not provide upload times for a release, that release is not filtered out by this setting. {{% /note %}} +This setting can also be configured in your `pyproject.toml`: + +```toml +[tool.poetry.config] +solver.min-release-age = 7 +``` + +Values set in `[tool.poetry.config]` take precedence over +[project-local configuration]({{< relref "#project-local-configuration" >}}) +and global configuration. +They can still be overridden by the `POETRY_SOLVER_MIN_RELEASE_AGE` +environment variable. + ### `solver.min-release-age-exclude` **Type**: `string` @@ -456,6 +469,19 @@ regardless of their upload age. poetry config solver.min-release-age-exclude "my-package,other-package" ``` +This setting can also be configured in your `pyproject.toml`: + +```toml +[tool.poetry.config] +solver.min-release-age-exclude = ["my-package", "other-package"] +``` + +Values set in `[tool.poetry.config]` take precedence over +[project-local configuration]({{< relref "#project-local-configuration" >}}) +and global configuration. +They can still be overridden by the `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE` +environment variable. + ### `solver.min-release-age-exclude-source` **Type**: `string` @@ -476,6 +502,19 @@ Sources can be referenced by the name defined in `pyproject.toml` or by URL. poetry config solver.min-release-age-exclude-source "private-repo,https://example.com/simple/" ``` +This setting can also be configured in your `pyproject.toml`: + +```toml +[tool.poetry.config] +solver.min-release-age-exclude-source = ["private-repo", "https://example.com/simple/"] +``` + +Values set in `[tool.poetry.config]` take precedence over +[project-local configuration]({{< relref "#project-local-configuration" >}}) +and global configuration. +They can still be overridden by the `POETRY_SOLVER_MIN_RELEASE_AGE_EXCLUDE_SOURCE` +environment variable. + ### `system-git-client` **Type**: `boolean` diff --git a/docs/pyproject.md b/docs/pyproject.md index 5da05138b46..f839e36a070 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -1012,6 +1012,26 @@ some-package = { setuptools = "<78" } The syntax for specifying constraints is the same as for specifying dependencies in the `tool.poetry` section. +### config + +The `solver.min-release-age`, `solver.min-release-age-exclude`, +and `solver.min-release-age-exclude-source` options can be configured +directly in your `pyproject.toml`: + +```toml +[tool.poetry.config] +solver.min-release-age = 7 +solver.min-release-age-exclude = ["my-package", "other-package"] +solver.min-release-age-exclude-source = ["private-repo"] +``` + +These values override the equivalent settings in +[project-local]({{< relref "configuration#project-local-configuration" >}}) +and [global configuration]({{< relref "configuration#global-configuration" >}}), +but can still be overridden by environment variables. + +For details, see the [configuration documentation]({{< relref "configuration" >}}). + ## Poetry and PEP-517 [PEP-517](https://www.python.org/dev/peps/pep-0517/) introduces a standard way diff --git a/src/poetry/factory.py b/src/poetry/factory.py index 7b2792b35d4..4f8eccc02ad 100644 --- a/src/poetry/factory.py +++ b/src/poetry/factory.py @@ -35,6 +35,7 @@ from cleo.io.io import IO from poetry.core.packages.dependency import Dependency from poetry.core.packages.package import Package + from poetry.core.poetry import Poetry as CorePoetry from tomlkit.toml_document import TOMLDocument from poetry.repositories import RepositoryPool @@ -63,6 +64,33 @@ def _ensure_valid_poetry_version(self, cwd: Path | None) -> None: f" but you are using Poetry {version}" ) + @staticmethod + def _load_project_config( + base_poetry: CorePoetry, + config: Config, + ) -> None: + """Load project config from [tool.poetry.config].""" + project_config = base_poetry.local_config.get("config", {}) + if not project_config: + return + + validated: dict[str, Any] = {} + solver = project_config.get("solver") + if solver: + validated_solver: dict[str, Any] = {} + for key in ( + "min-release-age", + "min-release-age-exclude", + "min-release-age-exclude-source", + ): + if key in solver: + validated_solver[key] = solver[key] + if validated_solver: + validated["solver"] = validated_solver + + if validated: + config.merge(validated) + def create_poetry( self, cwd: Path | None = None, @@ -120,6 +148,9 @@ def create_poetry( config.merge({"repositories": repositories}) + # Load project config from [tool.poetry.config] + self._load_project_config(base_poetry, config) + poetry = Poetry( poetry_file, base_poetry.local_config, diff --git a/src/poetry/json/schemas/poetry.json b/src/poetry/json/schemas/poetry.json index 3a9d79d2b02..a6248330975 100644 --- a/src/poetry/json/schemas/poetry.json +++ b/src/poetry/json/schemas/poetry.json @@ -33,6 +33,39 @@ "$ref": "#/definitions/dependencies" } } + }, + "config": { + "type": "object", + "description": "Poetry configuration values that override global and local config.", + "properties": { + "solver": { + "type": "object", + "description": "Solver-specific configuration.", + "properties": { + "min-release-age": { + "type": "integer", + "minimum": 0, + "description": "Minimum age in days for a package release to be considered." + }, + "min-release-age-exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Package names excluded from the min-release-age filter." + }, + "min-release-age-exclude-source": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Source names or URLs excluded from the min-release-age filter." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "definitions": { diff --git a/tests/test_factory.py b/tests/test_factory.py index 33fc230f4d7..1131f3a6957 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -36,6 +38,42 @@ from tests.types import FixtureDirGetter +def _make_project( + tmp_path: Path, + extra_config: dict[str, Any] | None = None, + poetry_toml: dict[str, Any] | None = None, +) -> Path: + """Create a minimal pyproject.toml (and optionally poetry.toml) project dir.""" + import tomlkit + + project_dir = tmp_path / "project" + project_dir.mkdir() + + pyproject: dict[str, Any] = { + "tool": { + "poetry": { + "name": "test", + "version": "1.0", + "description": "", + "authors": [], + "dependencies": {"python": "^3.10"}, + } + } + } + + if extra_config: + pyproject["tool"]["poetry"]["config"] = extra_config + + with (project_dir / "pyproject.toml").open("w", encoding="utf-8") as f: + tomlkit.dump(pyproject, f) + + if poetry_toml: + with (project_dir / "poetry.toml").open("w", encoding="utf-8") as f: + tomlkit.dump(poetry_toml, f) + + return project_dir + + class MyPlugin(Plugin): def activate(self, poetry: Poetry, io: IO) -> None: io.write_line("Setting readmes") @@ -510,3 +548,111 @@ def test_create_package_source_invalid( Factory().create_poetry(fixture_dir("with_source_pypi_url")) assert str(e.value) == expected + + +def test_project_config_min_release_age(tmp_path: Path) -> None: + project_dir = _make_project(tmp_path, {"solver": {"min-release-age": 7}}) + poetry = Factory().create_poetry(project_dir) + assert poetry._config.get("solver.min-release-age") == 7 + + +def test_project_config_exclude_options(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, + { + "solver": { + "min-release-age": 7, + "min-release-age-exclude": ["foo", "bar"], + "min-release-age-exclude-source": ["private-repo"], + } + }, + ) + poetry = Factory().create_poetry(project_dir) + assert poetry._config.get("solver.min-release-age") == 7 + assert poetry._config.get("solver.min-release-age-exclude") == ["foo", "bar"] + assert poetry._config.get("solver.min-release-age-exclude-source") == [ + "private-repo" + ] + + +def test_project_config_invalid_age_type_raises(tmp_path: Path) -> None: + project_dir = _make_project(tmp_path, {"solver": {"min-release-age": "not-an-int"}}) + with pytest.raises( + RuntimeError, + match=re.escape("tool.poetry.config.solver.min-release-age must be integer"), + ): + Factory().create_poetry(project_dir) + + +def test_project_config_negative_age_raises(tmp_path: Path) -> None: + project_dir = _make_project(tmp_path, {"solver": {"min-release-age": -1}}) + with pytest.raises( + RuntimeError, + match=re.escape( + "tool.poetry.config.solver.min-release-age must be bigger than or equal to 0" + ), + ): + Factory().create_poetry(project_dir) + + +def test_project_config_bad_exclude_type_raises(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, {"solver": {"min-release-age-exclude": "foo,bar"}} + ) + with pytest.raises( + RuntimeError, + match=re.escape( + "tool.poetry.config.solver.min-release-age-exclude must be array" + ), + ): + Factory().create_poetry(project_dir) + + +def test_project_config_unknown_solver_key_raises(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, + {"solver": {"min-release-age": 7, "foo": 1}}, + ) + with pytest.raises( + RuntimeError, + match=re.escape("tool.poetry.config.solver must not contain"), + ): + Factory().create_poetry(project_dir) + + +def test_project_config_unknown_config_key_raises(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, + {"unknown-config-key": 1}, + ) + with pytest.raises( + RuntimeError, + match=re.escape("tool.poetry.config must not contain"), + ): + Factory().create_poetry(project_dir) + + +def test_project_config_overrides_poetry_toml(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, + {"solver": {"min-release-age": 14}}, + poetry_toml={"solver": {"min-release-age": 7}}, + ) + poetry = Factory().create_poetry(project_dir) + assert poetry._config.get("solver.min-release-age") == 14 + + +def test_project_config_overrides_poetry_toml_array_option(tmp_path: Path) -> None: + project_dir = _make_project( + tmp_path, + {"solver": {"min-release-age-exclude": ["foo", "bar"]}}, + poetry_toml={"solver": {"min-release-age-exclude": ["baz"]}}, + ) + poetry = Factory().create_poetry(project_dir) + assert poetry._config.get("solver.min-release-age-exclude") == ["foo", "bar"] + + +def test_project_config_empty_keeps_default(tmp_path: Path) -> None: + project_dir = _make_project(tmp_path) + poetry = Factory().create_poetry(project_dir) + assert poetry._config.get("solver.min-release-age") == 0