Skip to content

Commit 3b86141

Browse files
committed
Allow file: for dependencies and optional-dependencies in pyproject.toml
1 parent e5552d3 commit 3b86141

File tree

5 files changed

+196
-27
lines changed

5 files changed

+196
-27
lines changed

changelog.d/3255.change.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enabled using ``file:`` for dependencies and optional-dependencies in pyproject.toml -- by :user:`akx`

docs/userguide/pyproject_config.rst

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,28 @@ In the ``dynamic`` table, the ``attr`` directive [#directives]_ will read an
181181
attribute from the given module [#attr]_, while ``file`` will read the contents
182182
of all given files and concatenate them in a single string.
183183

184-
================= =================== =========================
185-
Key Directive Notes
186-
================= =================== =========================
187-
``version`` ``attr``, ``file``
188-
``readme`` ``file``
189-
``description`` ``file`` One-line text
190-
``classifiers`` ``file`` Multi-line text with one classifier per line
191-
``entry-points`` ``file`` INI format following :doc:`PyPUG:specifications/entry-points`
192-
(``console_scripts`` and ``gui_scripts`` can be included)
193-
================= =================== =========================
184+
================= =================== =========================
185+
Key Directive Notes
186+
================= =================== =========================
187+
``version`` ``attr``, ``file``
188+
``readme`` ``file``
189+
``description`` ``file`` One-line text
190+
``classifiers`` ``file`` Multi-line text with one classifier per line
191+
``entry-points`` ``file`` INI format following :doc:`PyPUG:specifications/entry-points`
192+
(``console_scripts`` and ``gui_scripts`` can be included)
193+
``dependencies`` ``file`` ``requirements.txt`` format (``#`` comments and blank lines excluded)
194+
``optional-dependencies`` ``file`` ``requirements.txt`` format per group (``#`` comments and blank lines excluded)
195+
========================== =================== =========================
196+
197+
Supporting ``file`` for dependencies is meant for a convenience for packaging
198+
applications with possibly strictly versioned dependencies.
199+
200+
Library packagers are discouraged from using overly strict (or "locked")
201+
dependency versions in their ``dependencies`` and ``optional-dependencies``.
202+
203+
Currently, when specifying ``optional-dependencies`` dynamically, all of the groups
204+
must be specified dynamically; one can not specify some of them statically and
205+
some of them dynamically.
194206

195207
----
196208

setuptools/config/_validate_pyproject/fastjsonschema_validations.py

Lines changed: 38 additions & 7 deletions
Large diffs are not rendered by default.

setuptools/config/pyprojecttoml.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, st
266266
"scripts",
267267
"gui-scripts",
268268
"classifiers",
269+
"dependencies",
270+
"optional-dependencies",
269271
)
270272
# `_obtain` functions are assumed to raise appropriate exceptions/warnings.
271273
obtained_dynamic = {
@@ -278,6 +280,8 @@ def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, st
278280
version=self._obtain_version(dist, package_dir),
279281
readme=self._obtain_readme(dist),
280282
classifiers=self._obtain_classifiers(dist),
283+
dependencies=self._obtain_dependencies(dist),
284+
optional_dependencies=self._obtain_optional_dependencies(dist),
281285
)
282286
# `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
283287
# might have already been set by setup.py/extensions, so avoid overwriting.
@@ -294,18 +298,25 @@ def _ensure_previously_set(self, dist: "Distribution", field: str):
294298
)
295299
raise OptionError(msg)
296300

301+
def _expand_directive(
302+
self, specifier: str, directive, package_dir: Mapping[str, str]
303+
):
304+
with _ignore_errors(self.ignore_option_errors):
305+
root_dir = self.root_dir
306+
if "file" in directive:
307+
return _expand.read_files(directive["file"], root_dir)
308+
if "attr" in directive:
309+
return _expand.read_attr(directive["attr"], package_dir, root_dir)
310+
raise ValueError(f"invalid `{specifier}`: {directive!r}")
311+
return None
312+
297313
def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):
298314
if field in self.dynamic_cfg:
299-
directive = self.dynamic_cfg[field]
300-
with _ignore_errors(self.ignore_option_errors):
301-
root_dir = self.root_dir
302-
if "file" in directive:
303-
return _expand.read_files(directive["file"], root_dir)
304-
if "attr" in directive:
305-
return _expand.read_attr(directive["attr"], package_dir, root_dir)
306-
msg = f"invalid `tool.setuptools.dynamic.{field}`: {directive!r}"
307-
raise ValueError(msg)
308-
return None
315+
return self._expand_directive(
316+
f"tool.setuptools.dynamic.{field}",
317+
self.dynamic_cfg[field],
318+
package_dir,
319+
)
309320
self._ensure_previously_set(dist, field)
310321
return None
311322

@@ -365,6 +376,38 @@ def _obtain_classifiers(self, dist: "Distribution"):
365376
return value.splitlines()
366377
return None
367378

379+
def _obtain_dependencies(self, dist: "Distribution"):
380+
if "dependencies" in self.dynamic:
381+
value = self._obtain(dist, "dependencies", {})
382+
if value:
383+
return _parse_requirements_list(value)
384+
return None
385+
386+
def _obtain_optional_dependencies(self, dist: "Distribution"):
387+
if "optional-dependencies" not in self.dynamic:
388+
return None
389+
if "optional-dependencies" in self.dynamic_cfg:
390+
optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
391+
assert isinstance(optional_dependencies_map, dict)
392+
return {
393+
group: _parse_requirements_list(self._expand_directive(
394+
f"tool.setuptools.dynamic.optional-dependencies.{group}",
395+
directive,
396+
{},
397+
))
398+
for group, directive in optional_dependencies_map.items()
399+
}
400+
self._ensure_previously_set(dist, "optional-dependencies")
401+
return None
402+
403+
404+
def _parse_requirements_list(value):
405+
return [
406+
line
407+
for line in value.splitlines()
408+
if line.strip() and not line.strip().startswith("#")
409+
]
410+
368411

369412
@contextmanager
370413
def _ignore_errors(ignore_option_errors: bool):
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import pytest
2+
3+
from setuptools.config.pyprojecttoml import apply_configuration
4+
from setuptools.dist import Distribution
5+
from setuptools.tests.textwrap import DALS
6+
7+
8+
def test_dynamic_dependencies(tmp_path):
9+
(tmp_path / "requirements.txt").write_text("six\n # comment\n")
10+
pyproject = (tmp_path / "pyproject.toml")
11+
pyproject.write_text(DALS("""
12+
[project]
13+
name = "myproj"
14+
version = "1.0"
15+
dynamic = ["dependencies"]
16+
17+
[build-system]
18+
requires = ["setuptools", "wheel"]
19+
build-backend = "setuptools.build_meta"
20+
21+
[tool.setuptools.dynamic.dependencies]
22+
file = ["requirements.txt"]
23+
"""))
24+
dist = Distribution()
25+
dist = apply_configuration(dist, pyproject)
26+
assert dist.install_requires == ["six"]
27+
28+
29+
def test_dynamic_optional_dependencies(tmp_path):
30+
(tmp_path / "requirements-docs.txt").write_text("sphinx\n # comment\n")
31+
pyproject = (tmp_path / "pyproject.toml")
32+
pyproject.write_text(DALS("""
33+
[project]
34+
name = "myproj"
35+
version = "1.0"
36+
dynamic = ["optional-dependencies"]
37+
38+
[tool.setuptools.dynamic.optional-dependencies.docs]
39+
file = ["requirements-docs.txt"]
40+
41+
[build-system]
42+
requires = ["setuptools", "wheel"]
43+
build-backend = "setuptools.build_meta"
44+
"""))
45+
dist = Distribution()
46+
dist = apply_configuration(dist, pyproject)
47+
assert dist.extras_require == {"docs": ["sphinx"]}
48+
49+
50+
def test_mixed_dynamic_optional_dependencies(tmp_path):
51+
"""
52+
Test that if PEP 621 was loosened to allow mixing of dynamic and static
53+
configurations in the case of fields containing sub-fields (groups),
54+
things would work out.
55+
"""
56+
(tmp_path / "requirements-images.txt").write_text("pillow~=42.0\n # comment\n")
57+
pyproject = (tmp_path / "pyproject.toml")
58+
pyproject.write_text(DALS("""
59+
[project]
60+
name = "myproj"
61+
version = "1.0"
62+
dynamic = ["optional-dependencies"]
63+
64+
[project.optional-dependencies]
65+
docs = ["sphinx"]
66+
67+
[tool.setuptools.dynamic.optional-dependencies.images]
68+
file = ["requirements-images.txt"]
69+
70+
[build-system]
71+
requires = ["setuptools", "wheel"]
72+
build-backend = "setuptools.build_meta"
73+
"""))
74+
# Test that the mix-and-match doesn't currently validate.
75+
with pytest.raises(ValueError, match="project.optional-dependencies"):
76+
apply_configuration(Distribution(), pyproject)
77+
78+
# Explicitly disable the validation and try again, to see that the mix-and-match
79+
# result would be correct.
80+
dist = Distribution()
81+
dist = apply_configuration(dist, pyproject, ignore_option_errors=True)
82+
assert dist.extras_require == {"docs": ["sphinx"], "images": ["pillow~=42.0"]}

0 commit comments

Comments
 (0)