Skip to content

Commit 2582a8b

Browse files
authored
Fix missing requirements with pyproject.toml (#3223)
2 parents 461f9a6 + 603bb98 commit 2582a8b

File tree

7 files changed

+90
-24
lines changed

7 files changed

+90
-24
lines changed

changelog.d/3223.misc.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed missing requirements with environment markers when
2+
``optional-dependencies`` is set in ``pyproject.toml``.

setuptools/__init__.py

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def __init__(self, attrs):
5858
# Prevent accidentally triggering discovery with incomplete set of attrs
5959
self.set_defaults._disable()
6060

61+
def _get_project_config_files(self, filenames=None):
62+
"""Ignore ``pyproject.toml``, they are not related to setup_requires"""
63+
try:
64+
cfg, toml = super()._split_standard_project_metadata(filenames)
65+
return cfg, ()
66+
except Exception:
67+
return filenames, ()
68+
6169
def finalize_options(self):
6270
"""
6371
Disable finalize_options to avoid building the working set.

setuptools/config/_apply_pyprojecttoml.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ def _python_requires(dist: "Distribution", val: dict, _root_dir):
194194
_set_config(dist, "python_requires", SpecifierSet(val))
195195

196196

197+
def _dependencies(dist: "Distribution", val: list, _root_dir):
198+
existing = getattr(dist, "install_requires", [])
199+
_set_config(dist, "install_requires", existing + val)
200+
201+
202+
def _optional_dependencies(dist: "Distribution", val: dict, _root_dir):
203+
existing = getattr(dist, "extras_require", {})
204+
_set_config(dist, "extras_require", {**existing, **val})
205+
206+
197207
def _unify_entry_points(project_table: dict):
198208
project = project_table
199209
entry_points = project.pop("entry-points", project.pop("entry_points", {}))
@@ -293,7 +303,7 @@ def _some_attrgetter(*items):
293303
"""
294304
def _acessor(obj):
295305
values = (_attrgetter(i)(obj) for i in items)
296-
return next((i for i in values if i), None)
306+
return next((i for i in values if i is not None), None)
297307
return _acessor
298308

299309

@@ -303,8 +313,8 @@ def _acessor(obj):
303313
"authors": partial(_people, kind="author"),
304314
"maintainers": partial(_people, kind="maintainer"),
305315
"urls": _project_urls,
306-
"dependencies": "install_requires",
307-
"optional_dependencies": "extras_require",
316+
"dependencies": _dependencies,
317+
"optional_dependencies": _optional_dependencies,
308318
"requires_python": _python_requires,
309319
}
310320

setuptools/config/pyprojecttoml.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from setuptools.errors import FileError, OptionError
1010

1111
from . import expand as _expand
12-
from ._apply_pyprojecttoml import apply, _PREVIOUSLY_DEFINED, _WouldIgnoreField
12+
from ._apply_pyprojecttoml import apply as _apply
13+
from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _WouldIgnoreField
1314

1415
if TYPE_CHECKING:
1516
from setuptools.dist import Distribution # noqa
@@ -44,13 +45,15 @@ def validate(config: dict, filepath: _Path):
4445

4546

4647
def apply_configuration(
47-
dist: "Distribution", filepath: _Path, ignore_option_errors=False,
48+
dist: "Distribution",
49+
filepath: _Path,
50+
ignore_option_errors=False,
4851
) -> "Distribution":
4952
"""Apply the configuration from a ``pyproject.toml`` file into an existing
5053
distribution object.
5154
"""
5255
config = read_configuration(filepath, True, ignore_option_errors, dist)
53-
return apply(dist, config, filepath)
56+
return _apply(dist, config, filepath)
5457

5558

5659
def read_configuration(
@@ -279,11 +282,12 @@ def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, st
279282
)
280283
# `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
281284
# might have already been set by setup.py/extensions, so avoid overwriting.
282-
self.project_cfg.update({k: v for k, v in obtained_dynamic.items() if v})
285+
updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
286+
self.project_cfg.update(updates)
283287

284288
def _ensure_previously_set(self, dist: "Distribution", field: str):
285289
previous = _PREVIOUSLY_DEFINED[field](dist)
286-
if not previous and not self.ignore_option_errors:
290+
if previous is None and not self.ignore_option_errors:
287291
msg = (
288292
f"No configuration found for dynamic {field!r}.\n"
289293
"Some dynamic fields need to be specified via `tool.setuptools.dynamic`"

setuptools/config/setupcfg.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def apply_configuration(dist: "Distribution", filepath: _Path) -> "Distribution"
7070
def _apply(
7171
dist: "Distribution", filepath: _Path,
7272
other_files: Iterable[_Path] = (),
73-
ignore_option_errors: bool = False
73+
ignore_option_errors: bool = False,
7474
) -> Tuple["ConfigHandler", ...]:
7575
"""Read configuration from ``filepath`` and applies to the ``dist`` object."""
7676
from setuptools.dist import _Distribution
@@ -677,9 +677,8 @@ def parse_section_extras_require(self, section_options):
677677
:param dict section_options:
678678
"""
679679
parse_list = partial(self._parse_list, separator=';')
680-
self['extras_require'] = self._parse_section_to_dict(
681-
section_options, parse_list
682-
)
680+
parsed = self._parse_section_to_dict(section_options, parse_list)
681+
self['extras_require'] = parsed
683682

684683
def parse_section_data_files(self, section_options):
685684
"""Parses `data_files` configuration file section.

setuptools/dist.py

+25-7
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,11 @@ def __init__(self, attrs=None):
468468
},
469469
)
470470

471+
# Save the original dependencies before they are processed into the egg format
472+
self._orig_extras_require = {}
473+
self._orig_install_requires = []
474+
self._tmp_extras_require = defaultdict(ordered_set.OrderedSet)
475+
471476
self.set_defaults = ConfigDiscovery(self)
472477

473478
self._set_metadata_defaults(attrs)
@@ -540,6 +545,8 @@ def _finalize_requires(self):
540545
self.metadata.python_requires = self.python_requires
541546

542547
if getattr(self, 'extras_require', None):
548+
# Save original before it is messed by _convert_extras_requirements
549+
self._orig_extras_require = self._orig_extras_require or self.extras_require
543550
for extra in self.extras_require.keys():
544551
# Since this gets called multiple times at points where the
545552
# keys have become 'converted' extras, ensure that we are only
@@ -548,6 +555,10 @@ def _finalize_requires(self):
548555
if extra:
549556
self.metadata.provides_extras.add(extra)
550557

558+
if getattr(self, 'install_requires', None) and not self._orig_install_requires:
559+
# Save original before it is messed by _move_install_requirements_markers
560+
self._orig_install_requires = self.install_requires
561+
551562
self._convert_extras_requirements()
552563
self._move_install_requirements_markers()
553564

@@ -558,7 +569,8 @@ def _convert_extras_requirements(self):
558569
`"extra:{marker}": ["barbazquux"]`.
559570
"""
560571
spec_ext_reqs = getattr(self, 'extras_require', None) or {}
561-
self._tmp_extras_require = defaultdict(list)
572+
tmp = defaultdict(ordered_set.OrderedSet)
573+
self._tmp_extras_require = getattr(self, '_tmp_extras_require', tmp)
562574
for section, v in spec_ext_reqs.items():
563575
# Do not strip empty sections.
564576
self._tmp_extras_require[section]
@@ -596,7 +608,8 @@ def is_simple_req(req):
596608
for r in complex_reqs:
597609
self._tmp_extras_require[':' + str(r.marker)].append(r)
598610
self.extras_require = dict(
599-
(k, [str(r) for r in map(self._clean_req, v)])
611+
# list(dict.fromkeys(...)) ensures a list of unique strings
612+
(k, list(dict.fromkeys(str(r) for r in map(self._clean_req, v))))
600613
for k, v in self._tmp_extras_require.items()
601614
)
602615

@@ -814,10 +827,8 @@ def _set_command_options(self, command_obj, option_dict=None): # noqa: C901
814827
except ValueError as e:
815828
raise DistutilsOptionError(e) from e
816829

817-
def parse_config_files(self, filenames=None, ignore_option_errors=False):
818-
"""Parses configuration files from various levels
819-
and loads configuration.
820-
"""
830+
def _get_project_config_files(self, filenames):
831+
"""Add default file and split between INI and TOML"""
821832
tomlfiles = []
822833
standard_project_metadata = Path(self.src_root or os.curdir, "pyproject.toml")
823834
if filenames is not None:
@@ -826,8 +837,15 @@ def parse_config_files(self, filenames=None, ignore_option_errors=False):
826837
tomlfiles = list(parts[1]) # 2nd element => predicate is True
827838
elif standard_project_metadata.exists():
828839
tomlfiles = [standard_project_metadata]
840+
return filenames, tomlfiles
841+
842+
def parse_config_files(self, filenames=None, ignore_option_errors=False):
843+
"""Parses configuration files from various levels
844+
and loads configuration.
845+
"""
846+
inifiles, tomlfiles = self._get_project_config_files(filenames)
829847

830-
self._parse_config_files(filenames=filenames)
848+
self._parse_config_files(filenames=inifiles)
831849

832850
setupcfg.parse_configuration(
833851
self, self.command_options, ignore_option_errors=ignore_option_errors

setuptools/tests/config/test_apply_pyprojecttoml.py

+30-5
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from setuptools.dist import Distribution
1515
from setuptools.config import setupcfg, pyprojecttoml
1616
from setuptools.config import expand
17-
from setuptools.config._apply_pyprojecttoml import _WouldIgnoreField
17+
from setuptools.config._apply_pyprojecttoml import _WouldIgnoreField, _some_attrgetter
18+
from setuptools.command.egg_info import write_requirements
1819

1920

2021
EXAMPLES = (Path(__file__).parent / "setupcfg_examples.txt").read_text()
@@ -207,12 +208,12 @@ def test_license_and_license_files(tmp_path):
207208

208209

209210
class TestPresetField:
210-
def pyproject(self, tmp_path, dynamic):
211+
def pyproject(self, tmp_path, dynamic, extra_content=""):
211212
content = f"[project]\nname = 'proj'\ndynamic = {dynamic!r}\n"
212213
if "version" not in dynamic:
213214
content += "version = '42'\n"
214215
file = tmp_path / "pyproject.toml"
215-
file.write_text(content, encoding="utf-8")
216+
file.write_text(content + extra_content, encoding="utf-8")
216217
return file
217218

218219
@pytest.mark.parametrize(
@@ -233,12 +234,14 @@ def test_not_listed_in_dynamic(self, tmp_path, attr, field, value):
233234
dist = pyprojecttoml.apply_configuration(dist, pyproject)
234235

235236
# TODO: Once support for pyproject.toml config stabilizes attr should be None
236-
dist_value = getattr(dist, attr, None) or getattr(dist.metadata, attr, object())
237+
dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
237238
assert dist_value == value
238239

239240
@pytest.mark.parametrize(
240241
"attr, field, value",
241242
[
243+
("install_requires", "dependencies", []),
244+
("extras_require", "optional-dependencies", {}),
242245
("install_requires", "dependencies", ["six"]),
243246
("classifiers", "classifiers", ["Private :: Classifier"]),
244247
]
@@ -247,9 +250,31 @@ def test_listed_in_dynamic(self, tmp_path, attr, field, value):
247250
pyproject = self.pyproject(tmp_path, [field])
248251
dist = makedist(tmp_path, **{attr: value})
249252
dist = pyprojecttoml.apply_configuration(dist, pyproject)
250-
dist_value = getattr(dist, attr, None) or getattr(dist.metadata, attr, object())
253+
dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
251254
assert dist_value == value
252255

256+
def test_optional_dependencies_dont_remove_env_markers(self, tmp_path):
257+
"""
258+
Internally setuptools converts dependencies with markers to "extras".
259+
If ``install_requires`` is given by ``setup.py``, we have to ensure that
260+
applying ``optional-dependencies`` does not overwrite the mandatory
261+
dependencies with markers (see #3204).
262+
"""
263+
# If setuptools replace its internal mechanism that uses `requires.txt`
264+
# this test has to be rewritten to adapt accordingly
265+
extra = "\n[project.optional-dependencies]\nfoo = ['bar>1']\n"
266+
pyproject = self.pyproject(tmp_path, ["dependencies"], extra)
267+
install_req = ['importlib-resources (>=3.0.0) ; python_version < "3.7"']
268+
dist = makedist(tmp_path, install_requires=install_req)
269+
dist = pyprojecttoml.apply_configuration(dist, pyproject)
270+
assert "foo" in dist.extras_require
271+
assert ':python_version < "3.7"' in dist.extras_require
272+
egg_info = dist.get_command_obj("egg_info")
273+
write_requirements(egg_info, tmp_path, tmp_path / "requires.txt")
274+
reqs = (tmp_path / "requires.txt").read_text(encoding="utf-8")
275+
assert "importlib-resources" in reqs
276+
assert "bar" in reqs
277+
253278

254279
# --- Auxiliary Functions ---
255280

0 commit comments

Comments
 (0)