fix
All checks were successful
Deploy Prod / Build (pull_request) Successful in 9s
Deploy Prod / Push (pull_request) Successful in 12s
Deploy Prod / Deploy prod (pull_request) Successful in 10s

This commit is contained in:
Egor Matveev
2024-12-28 22:48:16 +03:00
parent c1249bfcd0
commit 6c6a549aff
2532 changed files with 562109 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
# ====================================================================
# Some popular packages that use setup.cfg (and others not so popular)
# Reference: https://hugovk.github.io/top-pypi-packages/
# ====================================================================
https://github.com/pypa/setuptools/raw/52c990172fec37766b3566679724aa8bf70ae06d/setup.cfg
https://github.com/pypa/wheel/raw/0acd203cd896afec7f715aa2ff5980a403459a3b/setup.cfg
https://github.com/python/importlib_metadata/raw/2f05392ca980952a6960d82b2f2d2ea10aa53239/setup.cfg
https://github.com/jaraco/skeleton/raw/d9008b5c510cd6969127a6a2ab6f832edddef296/setup.cfg
https://github.com/jaraco/zipp/raw/700d3a96390e970b6b962823bfea78b4f7e1c537/setup.cfg
https://github.com/pallets/jinja/raw/7d72eb7fefb7dce065193967f31f805180508448/setup.cfg
https://github.com/tkem/cachetools/raw/2fd87a94b8d3861d80e9e4236cd480bfdd21c90d/setup.cfg
https://github.com/aio-libs/aiohttp/raw/5e0e6b7080f2408d5f1dd544c0e1cf88378b7b10/setup.cfg
https://github.com/pallets/flask/raw/9486b6cf57bd6a8a261f67091aca8ca78eeec1e3/setup.cfg
https://github.com/pallets/click/raw/6411f425fae545f42795665af4162006b36c5e4a/setup.cfg
https://github.com/sqlalchemy/sqlalchemy/raw/533f5718904b620be8d63f2474229945d6f8ba5d/setup.cfg
https://github.com/pytest-dev/pluggy/raw/461ef63291d13589c4e21aa182cd1529257e9a0a/setup.cfg
https://github.com/pytest-dev/pytest/raw/c7be96dae487edbd2f55b561b31b68afac1dabe6/setup.cfg
https://github.com/platformdirs/platformdirs/raw/7b7852128dd6f07511b618d6edea35046bd0c6ff/setup.cfg
https://github.com/pandas-dev/pandas/raw/bc17343f934a33dc231c8c74be95d8365537c376/setup.cfg
https://github.com/django/django/raw/4e249d11a6e56ca8feb4b055b681cec457ef3a3d/setup.cfg
https://github.com/pyscaffold/pyscaffold/raw/de7aa5dc059fbd04307419c667cc4961bc9df4b8/setup.cfg
https://github.com/pypa/virtualenv/raw/f92eda6e3da26a4d28c2663ffb85c4960bdb990c/setup.cfg

View File

@@ -0,0 +1,512 @@
"""Make sure that applying the configuration from pyproject.toml is equivalent to
applying a similar configuration from setup.cfg
To run these tests offline, please have a look on ``./downloads/preload.py``
"""
from __future__ import annotations
import io
import re
import tarfile
from inspect import cleandoc
from pathlib import Path
from unittest.mock import Mock
import pytest
from ini2toml.api import LiteTranslator
from packaging.metadata import Metadata
import setuptools # noqa: F401 # ensure monkey patch to metadata
from setuptools.command.egg_info import write_requirements
from setuptools.config import expand, pyprojecttoml, setupcfg
from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter
from setuptools.dist import Distribution
from setuptools.errors import RemovedConfigError
from .downloads import retrieve_file, urls_from_file
HERE = Path(__file__).parent
EXAMPLES_FILE = "setupcfg_examples.txt"
def makedist(path, **attrs):
return Distribution({"src_root": path, **attrs})
@pytest.mark.parametrize("url", urls_from_file(HERE / EXAMPLES_FILE))
@pytest.mark.filterwarnings("ignore")
@pytest.mark.uses_network
def test_apply_pyproject_equivalent_to_setupcfg(url, monkeypatch, tmp_path):
monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.0.1"))
setupcfg_example = retrieve_file(url)
pyproject_example = Path(tmp_path, "pyproject.toml")
setupcfg_text = setupcfg_example.read_text(encoding="utf-8")
toml_config = LiteTranslator().translate(setupcfg_text, "setup.cfg")
pyproject_example.write_text(toml_config, encoding="utf-8")
dist_toml = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject_example)
dist_cfg = setupcfg.apply_configuration(makedist(tmp_path), setupcfg_example)
pkg_info_toml = core_metadata(dist_toml)
pkg_info_cfg = core_metadata(dist_cfg)
assert pkg_info_toml == pkg_info_cfg
if any(getattr(d, "license_files", None) for d in (dist_toml, dist_cfg)):
assert set(dist_toml.license_files) == set(dist_cfg.license_files)
if any(getattr(d, "entry_points", None) for d in (dist_toml, dist_cfg)):
print(dist_cfg.entry_points)
ep_toml = {
(k, *sorted(i.replace(" ", "") for i in v))
for k, v in dist_toml.entry_points.items()
}
ep_cfg = {
(k, *sorted(i.replace(" ", "") for i in v))
for k, v in dist_cfg.entry_points.items()
}
assert ep_toml == ep_cfg
if any(getattr(d, "package_data", None) for d in (dist_toml, dist_cfg)):
pkg_data_toml = {(k, *sorted(v)) for k, v in dist_toml.package_data.items()}
pkg_data_cfg = {(k, *sorted(v)) for k, v in dist_cfg.package_data.items()}
assert pkg_data_toml == pkg_data_cfg
if any(getattr(d, "data_files", None) for d in (dist_toml, dist_cfg)):
data_files_toml = {(k, *sorted(v)) for k, v in dist_toml.data_files}
data_files_cfg = {(k, *sorted(v)) for k, v in dist_cfg.data_files}
assert data_files_toml == data_files_cfg
assert set(dist_toml.install_requires) == set(dist_cfg.install_requires)
if any(getattr(d, "extras_require", None) for d in (dist_toml, dist_cfg)):
extra_req_toml = {(k, *sorted(v)) for k, v in dist_toml.extras_require.items()}
extra_req_cfg = {(k, *sorted(v)) for k, v in dist_cfg.extras_require.items()}
assert extra_req_toml == extra_req_cfg
PEP621_EXAMPLE = """\
[project]
name = "spam"
version = "2020.0.0"
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
requires-python = ">=3.8"
license = {file = "LICENSE.txt"}
keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
authors = [
{email = "hi@pradyunsg.me"},
{name = "Tzu-Ping Chung"}
]
maintainers = [
{name = "Brett Cannon", email = "brett@python.org"},
{name = "John X. Ãørçeč", email = "john@utf8.org"},
{name = "Γαμα קּ 東", email = "gama@utf8.org"},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python"
]
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
"django>2.1; os_name != 'nt'",
"django>2.0; os_name == 'nt'"
]
[project.optional-dependencies]
test = [
"pytest < 5.0.0",
"pytest-cov[all]"
]
[project.urls]
homepage = "http://example.com"
documentation = "http://readthedocs.org"
repository = "http://github.com"
changelog = "http://github.com/me/spam/blob/master/CHANGELOG.md"
[project.scripts]
spam-cli = "spam:main_cli"
[project.gui-scripts]
spam-gui = "spam:main_gui"
[project.entry-points."spam.magical"]
tomatoes = "spam:main_tomatoes"
"""
PEP621_INTERNATIONAL_EMAIL_EXAMPLE = """\
[project]
name = "spam"
version = "2020.0.0"
authors = [
{email = "hi@pradyunsg.me"},
{name = "Tzu-Ping Chung"}
]
maintainers = [
{name = "Степан Бандера", email = "криївка@оун-упа.укр"},
]
"""
PEP621_EXAMPLE_SCRIPT = """
def main_cli(): pass
def main_gui(): pass
def main_tomatoes(): pass
"""
def _pep621_example_project(
tmp_path,
readme="README.rst",
pyproject_text=PEP621_EXAMPLE,
):
pyproject = tmp_path / "pyproject.toml"
text = pyproject_text
replacements = {'readme = "README.rst"': f'readme = "{readme}"'}
for orig, subst in replacements.items():
text = text.replace(orig, subst)
pyproject.write_text(text, encoding="utf-8")
(tmp_path / readme).write_text("hello world", encoding="utf-8")
(tmp_path / "LICENSE.txt").write_text("--- LICENSE stub ---", encoding="utf-8")
(tmp_path / "spam.py").write_text(PEP621_EXAMPLE_SCRIPT, encoding="utf-8")
return pyproject
def test_pep621_example(tmp_path):
"""Make sure the example in PEP 621 works"""
pyproject = _pep621_example_project(tmp_path)
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert dist.metadata.license == "--- LICENSE stub ---"
assert set(dist.metadata.license_files) == {"LICENSE.txt"}
@pytest.mark.parametrize(
("readme", "ctype"),
[
("Readme.txt", "text/plain"),
("readme.md", "text/markdown"),
("text.rst", "text/x-rst"),
],
)
def test_readme_content_type(tmp_path, readme, ctype):
pyproject = _pep621_example_project(tmp_path, readme)
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert dist.metadata.long_description_content_type == ctype
def test_undefined_content_type(tmp_path):
pyproject = _pep621_example_project(tmp_path, "README.tex")
with pytest.raises(ValueError, match="Undefined content type for README.tex"):
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
def test_no_explicit_content_type_for_missing_extension(tmp_path):
pyproject = _pep621_example_project(tmp_path, "README")
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert dist.metadata.long_description_content_type is None
@pytest.mark.parametrize(
("pyproject_text", "expected_maintainers_meta_value"),
(
pytest.param(
PEP621_EXAMPLE,
(
'Brett Cannon <brett@python.org>, "John X. Ãørçeč" <john@utf8.org>, '
'Γαμα קּ 東 <gama@utf8.org>'
),
id='non-international-emails',
),
pytest.param(
PEP621_INTERNATIONAL_EMAIL_EXAMPLE,
'Степан Бандера <криївка@оун-упа.укр>',
marks=pytest.mark.xfail(
reason="CPython's `email.headerregistry.Address` only supports "
'RFC 5322, as of Nov 10, 2022 and latest Python 3.11.0',
strict=True,
),
id='international-email',
),
),
)
def test_utf8_maintainer_in_metadata( # issue-3663
expected_maintainers_meta_value,
pyproject_text,
tmp_path,
):
pyproject = _pep621_example_project(
tmp_path,
"README",
pyproject_text=pyproject_text,
)
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert dist.metadata.maintainer_email == expected_maintainers_meta_value
pkg_file = tmp_path / "PKG-FILE"
with open(pkg_file, "w", encoding="utf-8") as fh:
dist.metadata.write_pkg_file(fh)
content = pkg_file.read_text(encoding="utf-8")
assert f"Maintainer-email: {expected_maintainers_meta_value}" in content
class TestLicenseFiles:
# TODO: After PEP 639 is accepted, we have to move the license-files
# to the `project` table instead of `tool.setuptools`
def base_pyproject(self, tmp_path, additional_text):
pyproject = _pep621_example_project(tmp_path, "README")
text = pyproject.read_text(encoding="utf-8")
# Sanity-check
assert 'license = {file = "LICENSE.txt"}' in text
assert "[tool.setuptools]" not in text
text = f"{text}\n{additional_text}\n"
pyproject.write_text(text, encoding="utf-8")
return pyproject
def test_both_license_and_license_files_defined(self, tmp_path):
setuptools_config = '[tool.setuptools]\nlicense-files = ["_FILE*"]'
pyproject = self.base_pyproject(tmp_path, setuptools_config)
(tmp_path / "_FILE.txt").touch()
(tmp_path / "_FILE.rst").touch()
# Would normally match the `license_files` patterns, but we want to exclude it
# by being explicit. On the other hand, contents should be added to `license`
license = tmp_path / "LICENSE.txt"
license.write_text("LicenseRef-Proprietary\n", encoding="utf-8")
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"}
assert dist.metadata.license == "LicenseRef-Proprietary\n"
def test_default_patterns(self, tmp_path):
setuptools_config = '[tool.setuptools]\nzip-safe = false'
# ^ used just to trigger section validation
pyproject = self.base_pyproject(tmp_path, setuptools_config)
license_files = "LICENCE-a.html COPYING-abc.txt AUTHORS-xyz NOTICE,def".split()
for fname in license_files:
(tmp_path / fname).write_text(f"{fname}\n", encoding="utf-8")
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
assert (tmp_path / "LICENSE.txt").exists() # from base example
assert set(dist.metadata.license_files) == {*license_files, "LICENSE.txt"}
class TestPyModules:
# https://github.com/pypa/setuptools/issues/4316
def dist(self, name):
toml_config = f"""
[project]
name = "test"
version = "42.0"
[tool.setuptools]
py-modules = [{name!r}]
"""
pyproject = Path("pyproject.toml")
pyproject.write_text(cleandoc(toml_config), encoding="utf-8")
return pyprojecttoml.apply_configuration(Distribution({}), pyproject)
@pytest.mark.parametrize("module", ["pip-run", "abc-d.λ-xyz-e"])
def test_valid_module_name(self, tmp_path, monkeypatch, module):
monkeypatch.chdir(tmp_path)
assert module in self.dist(module).py_modules
@pytest.mark.parametrize("module", ["pip run", "-pip-run", "pip-run-stubs"])
def test_invalid_module_name(self, tmp_path, monkeypatch, module):
monkeypatch.chdir(tmp_path)
with pytest.raises(ValueError, match="py-modules"):
self.dist(module).py_modules
class TestExtModules:
def test_pyproject_sets_attribute(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
pyproject = Path("pyproject.toml")
toml_config = """
[project]
name = "test"
version = "42.0"
[tool.setuptools]
ext-modules = [
{name = "my.ext", sources = ["hello.c", "world.c"]}
]
"""
pyproject.write_text(cleandoc(toml_config), encoding="utf-8")
with pytest.warns(pyprojecttoml._ExperimentalConfiguration):
dist = pyprojecttoml.apply_configuration(Distribution({}), pyproject)
assert len(dist.ext_modules) == 1
assert dist.ext_modules[0].name == "my.ext"
assert set(dist.ext_modules[0].sources) == {"hello.c", "world.c"}
class TestDeprecatedFields:
def test_namespace_packages(self, tmp_path):
pyproject = tmp_path / "pyproject.toml"
config = """
[project]
name = "myproj"
version = "42"
[tool.setuptools]
namespace-packages = ["myproj.pkg"]
"""
pyproject.write_text(cleandoc(config), encoding="utf-8")
with pytest.raises(RemovedConfigError, match="namespace-packages"):
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
class TestPresetField:
def pyproject(self, tmp_path, dynamic, extra_content=""):
content = f"[project]\nname = 'proj'\ndynamic = {dynamic!r}\n"
if "version" not in dynamic:
content += "version = '42'\n"
file = tmp_path / "pyproject.toml"
file.write_text(content + extra_content, encoding="utf-8")
return file
@pytest.mark.parametrize(
("attr", "field", "value"),
[
("classifiers", "classifiers", ["Private :: Classifier"]),
("entry_points", "scripts", {"console_scripts": ["foobar=foobar:main"]}),
("entry_points", "gui-scripts", {"gui_scripts": ["bazquux=bazquux:main"]}),
pytest.param(
*("install_requires", "dependencies", ["six"]),
marks=[
pytest.mark.filterwarnings("ignore:.*install_requires. overwritten")
],
),
],
)
def test_not_listed_in_dynamic(self, tmp_path, attr, field, value):
"""Setuptools cannot set a field if not listed in ``dynamic``"""
pyproject = self.pyproject(tmp_path, [])
dist = makedist(tmp_path, **{attr: value})
msg = re.compile(f"defined outside of `pyproject.toml`:.*{field}", re.S)
with pytest.warns(_MissingDynamic, match=msg):
dist = pyprojecttoml.apply_configuration(dist, pyproject)
dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
assert not dist_value
@pytest.mark.parametrize(
("attr", "field", "value"),
[
("install_requires", "dependencies", []),
("extras_require", "optional-dependencies", {}),
("install_requires", "dependencies", ["six"]),
("classifiers", "classifiers", ["Private :: Classifier"]),
],
)
def test_listed_in_dynamic(self, tmp_path, attr, field, value):
pyproject = self.pyproject(tmp_path, [field])
dist = makedist(tmp_path, **{attr: value})
dist = pyprojecttoml.apply_configuration(dist, pyproject)
dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
assert dist_value == value
def test_warning_overwritten_dependencies(self, tmp_path):
src = "[project]\nname='pkg'\nversion='0.1'\ndependencies=['click']\n"
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(src, encoding="utf-8")
dist = makedist(tmp_path, install_requires=["wheel"])
with pytest.warns(match="`install_requires` overwritten"):
dist = pyprojecttoml.apply_configuration(dist, pyproject)
assert "wheel" not in dist.install_requires
def test_optional_dependencies_dont_remove_env_markers(self, tmp_path):
"""
Internally setuptools converts dependencies with markers to "extras".
If ``install_requires`` is given by ``setup.py``, we have to ensure that
applying ``optional-dependencies`` does not overwrite the mandatory
dependencies with markers (see #3204).
"""
# If setuptools replace its internal mechanism that uses `requires.txt`
# this test has to be rewritten to adapt accordingly
extra = "\n[project.optional-dependencies]\nfoo = ['bar>1']\n"
pyproject = self.pyproject(tmp_path, ["dependencies"], extra)
install_req = ['importlib-resources (>=3.0.0) ; python_version < "3.7"']
dist = makedist(tmp_path, install_requires=install_req)
dist = pyprojecttoml.apply_configuration(dist, pyproject)
assert "foo" in dist.extras_require
egg_info = dist.get_command_obj("egg_info")
write_requirements(egg_info, tmp_path, tmp_path / "requires.txt")
reqs = (tmp_path / "requires.txt").read_text(encoding="utf-8")
assert "importlib-resources" in reqs
assert "bar" in reqs
assert ':python_version < "3.7"' in reqs
@pytest.mark.parametrize(
("field", "group"),
[("scripts", "console_scripts"), ("gui-scripts", "gui_scripts")],
)
@pytest.mark.filterwarnings("error")
def test_scripts_dont_require_dynamic_entry_points(self, tmp_path, field, group):
# Issue 3862
pyproject = self.pyproject(tmp_path, [field])
dist = makedist(tmp_path, entry_points={group: ["foobar=foobar:main"]})
dist = pyprojecttoml.apply_configuration(dist, pyproject)
assert group in dist.entry_points
class TestMeta:
def test_example_file_in_sdist(self, setuptools_sdist):
"""Meta test to ensure tests can run from sdist"""
with tarfile.open(setuptools_sdist) as tar:
assert any(name.endswith(EXAMPLES_FILE) for name in tar.getnames())
class TestInteropCommandLineParsing:
def test_version(self, tmp_path, monkeypatch, capsys):
# See pypa/setuptools#4047
# This test can be removed once the CLI interface of setup.py is removed
monkeypatch.chdir(tmp_path)
toml_config = """
[project]
name = "test"
version = "42.0"
"""
pyproject = Path(tmp_path, "pyproject.toml")
pyproject.write_text(cleandoc(toml_config), encoding="utf-8")
opts = {"script_args": ["--version"]}
dist = pyprojecttoml.apply_configuration(Distribution(opts), pyproject)
dist.parse_command_line() # <-- there should be no exception here.
captured = capsys.readouterr()
assert "42.0" in captured.out
# --- Auxiliary Functions ---
def core_metadata(dist) -> str:
with io.StringIO() as buffer:
dist.metadata.write_pkg_file(buffer)
pkg_file_txt = buffer.getvalue()
# Make sure core metadata is valid
Metadata.from_email(pkg_file_txt, validate=True) # can raise exceptions
skip_prefixes: tuple[str, ...] = ()
skip_lines = set()
# ---- DIFF NORMALISATION ----
# PEP 621 is very particular about author/maintainer metadata conversion, so skip
skip_prefixes += ("Author:", "Author-email:", "Maintainer:", "Maintainer-email:")
# May be redundant with Home-page
skip_prefixes += ("Project-URL: Homepage,", "Home-page:")
# May be missing in original (relying on default) but backfilled in the TOML
skip_prefixes += ("Description-Content-Type:",)
# Remove empty lines
skip_lines.add("")
result = []
for line in pkg_file_txt.splitlines():
if line.startswith(skip_prefixes) or line in skip_lines:
continue
result.append(line + "\n")
return "".join(result)

View File

@@ -0,0 +1,221 @@
import os
import sys
from pathlib import Path
import pytest
from setuptools.config import expand
from setuptools.discovery import find_package_path
from distutils.errors import DistutilsOptionError
def write_files(files, root_dir):
for file, content in files.items():
path = root_dir / file
path.parent.mkdir(exist_ok=True, parents=True)
path.write_text(content, encoding="utf-8")
def test_glob_relative(tmp_path, monkeypatch):
files = {
"dir1/dir2/dir3/file1.txt",
"dir1/dir2/file2.txt",
"dir1/file3.txt",
"a.ini",
"b.ini",
"dir1/c.ini",
"dir1/dir2/a.ini",
}
write_files({k: "" for k in files}, tmp_path)
patterns = ["**/*.txt", "[ab].*", "**/[ac].ini"]
monkeypatch.chdir(tmp_path)
assert set(expand.glob_relative(patterns)) == files
# Make sure the same APIs work outside cwd
assert set(expand.glob_relative(patterns, tmp_path)) == files
def test_read_files(tmp_path, monkeypatch):
dir_ = tmp_path / "dir_"
(tmp_path / "_dir").mkdir(exist_ok=True)
(tmp_path / "a.txt").touch()
files = {"a.txt": "a", "dir1/b.txt": "b", "dir1/dir2/c.txt": "c"}
write_files(files, dir_)
secrets = Path(str(dir_) + "secrets")
secrets.mkdir(exist_ok=True)
write_files({"secrets.txt": "secret keys"}, secrets)
with monkeypatch.context() as m:
m.chdir(dir_)
assert expand.read_files(list(files)) == "a\nb\nc"
cannot_access_msg = r"Cannot access '.*\.\..a\.txt'"
with pytest.raises(DistutilsOptionError, match=cannot_access_msg):
expand.read_files(["../a.txt"])
cannot_access_secrets_msg = r"Cannot access '.*secrets\.txt'"
with pytest.raises(DistutilsOptionError, match=cannot_access_secrets_msg):
expand.read_files(["../dir_secrets/secrets.txt"])
# Make sure the same APIs work outside cwd
assert expand.read_files(list(files), dir_) == "a\nb\nc"
with pytest.raises(DistutilsOptionError, match=cannot_access_msg):
expand.read_files(["../a.txt"], dir_)
class TestReadAttr:
@pytest.mark.parametrize(
"example",
[
# No cookie means UTF-8:
b"__version__ = '\xc3\xa9'\nraise SystemExit(1)\n",
# If a cookie is present, honor it:
b"# -*- coding: utf-8 -*-\n__version__ = '\xc3\xa9'\nraise SystemExit(1)\n",
b"# -*- coding: latin1 -*-\n__version__ = '\xe9'\nraise SystemExit(1)\n",
],
)
def test_read_attr_encoding_cookie(self, example, tmp_path):
(tmp_path / "mod.py").write_bytes(example)
assert expand.read_attr('mod.__version__', root_dir=tmp_path) == 'é'
def test_read_attr(self, tmp_path, monkeypatch):
files = {
"pkg/__init__.py": "",
"pkg/sub/__init__.py": "VERSION = '0.1.1'",
"pkg/sub/mod.py": (
"VALUES = {'a': 0, 'b': {42}, 'c': (0, 1, 1)}\nraise SystemExit(1)"
),
}
write_files(files, tmp_path)
with monkeypatch.context() as m:
m.chdir(tmp_path)
# Make sure it can read the attr statically without evaluating the module
assert expand.read_attr('pkg.sub.VERSION') == '0.1.1'
values = expand.read_attr('lib.mod.VALUES', {'lib': 'pkg/sub'})
assert values['a'] == 0
assert values['b'] == {42}
# Make sure the same APIs work outside cwd
assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1'
values = expand.read_attr('lib.mod.VALUES', {'lib': 'pkg/sub'}, tmp_path)
assert values['c'] == (0, 1, 1)
@pytest.mark.parametrize(
"example",
[
"VERSION: str\nVERSION = '0.1.1'\nraise SystemExit(1)\n",
"VERSION: str = '0.1.1'\nraise SystemExit(1)\n",
],
)
def test_read_annotated_attr(self, tmp_path, example):
files = {
"pkg/__init__.py": "",
"pkg/sub/__init__.py": example,
}
write_files(files, tmp_path)
# Make sure this attribute can be read statically
assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1'
def test_import_order(self, tmp_path):
"""
Sometimes the import machinery will import the parent package of a nested
module, which triggers side-effects and might create problems (see issue #3176)
``read_attr`` should bypass these limitations by resolving modules statically
(via ast.literal_eval).
"""
files = {
"src/pkg/__init__.py": "from .main import func\nfrom .about import version",
"src/pkg/main.py": "import super_complicated_dep\ndef func(): return 42",
"src/pkg/about.py": "version = '42'",
}
write_files(files, tmp_path)
attr_desc = "pkg.about.version"
package_dir = {"": "src"}
# `import super_complicated_dep` should not run, otherwise the build fails
assert expand.read_attr(attr_desc, package_dir, tmp_path) == "42"
@pytest.mark.parametrize(
("package_dir", "file", "module", "return_value"),
[
({"": "src"}, "src/pkg/main.py", "pkg.main", 42),
({"pkg": "lib"}, "lib/main.py", "pkg.main", 13),
({}, "single_module.py", "single_module", 70),
({}, "flat_layout/pkg.py", "flat_layout.pkg", 836),
],
)
def test_resolve_class(monkeypatch, tmp_path, package_dir, file, module, return_value):
monkeypatch.setattr(sys, "modules", {}) # reproducibility
files = {file: f"class Custom:\n def testing(self): return {return_value}"}
write_files(files, tmp_path)
cls = expand.resolve_class(f"{module}.Custom", package_dir, tmp_path)
assert cls().testing() == return_value
@pytest.mark.parametrize(
("args", "pkgs"),
[
({"where": ["."], "namespaces": False}, {"pkg", "other"}),
({"where": [".", "dir1"], "namespaces": False}, {"pkg", "other", "dir2"}),
({"namespaces": True}, {"pkg", "other", "dir1", "dir1.dir2"}),
({}, {"pkg", "other", "dir1", "dir1.dir2"}), # default value for `namespaces`
],
)
def test_find_packages(tmp_path, args, pkgs):
files = {
"pkg/__init__.py",
"other/__init__.py",
"dir1/dir2/__init__.py",
}
write_files({k: "" for k in files}, tmp_path)
package_dir = {}
kwargs = {"root_dir": tmp_path, "fill_package_dir": package_dir, **args}
where = kwargs.get("where", ["."])
assert set(expand.find_packages(**kwargs)) == pkgs
for pkg in pkgs:
pkg_path = find_package_path(pkg, package_dir, tmp_path)
assert os.path.exists(pkg_path)
# Make sure the same APIs work outside cwd
where = [
str((tmp_path / p).resolve()).replace(os.sep, "/") # ensure posix-style paths
for p in args.pop("where", ["."])
]
assert set(expand.find_packages(where=where, **args)) == pkgs
@pytest.mark.parametrize(
("files", "where", "expected_package_dir"),
[
(["pkg1/__init__.py", "pkg1/other.py"], ["."], {}),
(["pkg1/__init__.py", "pkg2/__init__.py"], ["."], {}),
(["src/pkg1/__init__.py", "src/pkg1/other.py"], ["src"], {"": "src"}),
(["src/pkg1/__init__.py", "src/pkg2/__init__.py"], ["src"], {"": "src"}),
(
["src1/pkg1/__init__.py", "src2/pkg2/__init__.py"],
["src1", "src2"],
{"pkg1": "src1/pkg1", "pkg2": "src2/pkg2"},
),
(
["src/pkg1/__init__.py", "pkg2/__init__.py"],
["src", "."],
{"pkg1": "src/pkg1"},
),
],
)
def test_fill_package_dir(tmp_path, files, where, expected_package_dir):
write_files({k: "" for k in files}, tmp_path)
pkg_dir = {}
kwargs = {"root_dir": tmp_path, "fill_package_dir": pkg_dir, "namespaces": False}
pkgs = expand.find_packages(where=where, **kwargs)
assert set(pkg_dir.items()) == set(expected_package_dir.items())
for pkg in pkgs:
pkg_path = find_package_path(pkg, pkg_dir, tmp_path)
assert os.path.exists(pkg_path)

View File

@@ -0,0 +1,396 @@
import re
from configparser import ConfigParser
from inspect import cleandoc
import jaraco.path
import pytest
import tomli_w
from path import Path
import setuptools # noqa: F401 # force distutils.core to be patched
from setuptools.config.pyprojecttoml import (
_ToolsTypoInMetadata,
apply_configuration,
expand_configuration,
read_configuration,
validate,
)
from setuptools.dist import Distribution
from setuptools.errors import OptionError
import distutils.core
EXAMPLE = """
[project]
name = "myproj"
keywords = ["some", "key", "words"]
dynamic = ["version", "readme"]
requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
dependencies = [
'importlib-metadata>=0.12;python_version<"3.8"',
'importlib-resources>=1.0;python_version<"3.7"',
'pathlib2>=2.3.3,<3;python_version < "3.4" and sys.platform != "win32"',
]
[project.optional-dependencies]
docs = [
"sphinx>=3",
"sphinx-argparse>=0.2.5",
"sphinx-rtd-theme>=0.4.3",
]
testing = [
"pytest>=1",
"coverage>=3,<5",
]
[project.scripts]
exec = "pkg.__main__:exec"
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
zip-safe = true
platforms = ["any"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.cmdclass]
sdist = "pkg.mod.CustomSdist"
[tool.setuptools.dynamic.version]
attr = "pkg.__version__.VERSION"
[tool.setuptools.dynamic.readme]
file = ["README.md"]
content-type = "text/markdown"
[tool.setuptools.package-data]
"*" = ["*.txt"]
[tool.setuptools.data-files]
"data" = ["_files/*.txt"]
[tool.distutils.sdist]
formats = "gztar"
[tool.distutils.bdist_wheel]
universal = true
"""
def create_example(path, pkg_root):
files = {
"pyproject.toml": EXAMPLE,
"README.md": "hello world",
"_files": {
"file.txt": "",
},
}
packages = {
"pkg": {
"__init__.py": "",
"mod.py": "class CustomSdist: pass",
"__version__.py": "VERSION = (3, 10)",
"__main__.py": "def exec(): print('hello')",
},
}
assert pkg_root # Meta-test: cannot be empty string.
if pkg_root == ".":
files = {**files, **packages}
# skip other files: flat-layout will raise error for multi-package dist
else:
# Use this opportunity to ensure namespaces are discovered
files[pkg_root] = {**packages, "other": {"nested": {"__init__.py": ""}}}
jaraco.path.build(files, prefix=path)
def verify_example(config, path, pkg_root):
pyproject = path / "pyproject.toml"
pyproject.write_text(tomli_w.dumps(config), encoding="utf-8")
expanded = expand_configuration(config, path)
expanded_project = expanded["project"]
assert read_configuration(pyproject, expand=True) == expanded
assert expanded_project["version"] == "3.10"
assert expanded_project["readme"]["text"] == "hello world"
assert "packages" in expanded["tool"]["setuptools"]
if pkg_root == ".":
# Auto-discovery will raise error for multi-package dist
assert set(expanded["tool"]["setuptools"]["packages"]) == {"pkg"}
else:
assert set(expanded["tool"]["setuptools"]["packages"]) == {
"pkg",
"other",
"other.nested",
}
assert expanded["tool"]["setuptools"]["include-package-data"] is True
assert "" in expanded["tool"]["setuptools"]["package-data"]
assert "*" not in expanded["tool"]["setuptools"]["package-data"]
assert expanded["tool"]["setuptools"]["data-files"] == [
("data", ["_files/file.txt"])
]
def test_read_configuration(tmp_path):
create_example(tmp_path, "src")
pyproject = tmp_path / "pyproject.toml"
config = read_configuration(pyproject, expand=False)
assert config["project"].get("version") is None
assert config["project"].get("readme") is None
verify_example(config, tmp_path, "src")
@pytest.mark.parametrize(
("pkg_root", "opts"),
[
(".", {}),
("src", {}),
("lib", {"packages": {"find": {"where": ["lib"]}}}),
],
)
def test_discovered_package_dir_with_attr_directive_in_config(tmp_path, pkg_root, opts):
create_example(tmp_path, pkg_root)
pyproject = tmp_path / "pyproject.toml"
config = read_configuration(pyproject, expand=False)
assert config["project"].get("version") is None
assert config["project"].get("readme") is None
config["tool"]["setuptools"].pop("packages", None)
config["tool"]["setuptools"].pop("package-dir", None)
config["tool"]["setuptools"].update(opts)
verify_example(config, tmp_path, pkg_root)
ENTRY_POINTS = {
"console_scripts": {"a": "mod.a:func"},
"gui_scripts": {"b": "mod.b:func"},
"other": {"c": "mod.c:func [extra]"},
}
class TestEntryPoints:
def write_entry_points(self, tmp_path):
entry_points = ConfigParser()
entry_points.read_dict(ENTRY_POINTS)
with open(tmp_path / "entry-points.txt", "w", encoding="utf-8") as f:
entry_points.write(f)
def pyproject(self, dynamic=None):
project = {"dynamic": dynamic or ["scripts", "gui-scripts", "entry-points"]}
tool = {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}
return {"project": project, "tool": {"setuptools": tool}}
def test_all_listed_in_dynamic(self, tmp_path):
self.write_entry_points(tmp_path)
expanded = expand_configuration(self.pyproject(), tmp_path)
expanded_project = expanded["project"]
assert len(expanded_project["scripts"]) == 1
assert expanded_project["scripts"]["a"] == "mod.a:func"
assert len(expanded_project["gui-scripts"]) == 1
assert expanded_project["gui-scripts"]["b"] == "mod.b:func"
assert len(expanded_project["entry-points"]) == 1
assert expanded_project["entry-points"]["other"]["c"] == "mod.c:func [extra]"
@pytest.mark.parametrize("missing_dynamic", ("scripts", "gui-scripts"))
def test_scripts_not_listed_in_dynamic(self, tmp_path, missing_dynamic):
self.write_entry_points(tmp_path)
dynamic = {"scripts", "gui-scripts", "entry-points"} - {missing_dynamic}
msg = f"defined outside of `pyproject.toml`:.*{missing_dynamic}"
with pytest.raises(OptionError, match=re.compile(msg, re.S)):
expand_configuration(self.pyproject(dynamic), tmp_path)
class TestClassifiers:
def test_dynamic(self, tmp_path):
# Let's create a project example that has dynamic classifiers
# coming from a txt file.
create_example(tmp_path, "src")
classifiers = cleandoc(
"""
Framework :: Flask
Programming Language :: Haskell
"""
)
(tmp_path / "classifiers.txt").write_text(classifiers, encoding="utf-8")
pyproject = tmp_path / "pyproject.toml"
config = read_configuration(pyproject, expand=False)
dynamic = config["project"]["dynamic"]
config["project"]["dynamic"] = list({*dynamic, "classifiers"})
dynamic_config = config["tool"]["setuptools"]["dynamic"]
dynamic_config["classifiers"] = {"file": "classifiers.txt"}
# When the configuration is expanded,
# each line of the file should be an different classifier.
validate(config, pyproject)
expanded = expand_configuration(config, tmp_path)
assert set(expanded["project"]["classifiers"]) == {
"Framework :: Flask",
"Programming Language :: Haskell",
}
def test_dynamic_without_config(self, tmp_path):
config = """
[project]
name = "myproj"
version = '42'
dynamic = ["classifiers"]
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(config), encoding="utf-8")
with pytest.raises(OptionError, match="No configuration .* .classifiers."):
read_configuration(pyproject)
def test_dynamic_readme_from_setup_script_args(self, tmp_path):
config = """
[project]
name = "myproj"
version = '42'
dynamic = ["readme"]
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(config), encoding="utf-8")
dist = Distribution(attrs={"long_description": "42"})
# No error should occur because of missing `readme`
dist = apply_configuration(dist, pyproject)
assert dist.metadata.long_description == "42"
def test_dynamic_without_file(self, tmp_path):
config = """
[project]
name = "myproj"
version = '42'
dynamic = ["classifiers"]
[tool.setuptools.dynamic]
classifiers = {file = ["classifiers.txt"]}
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(config), encoding="utf-8")
with pytest.warns(UserWarning, match="File .*classifiers.txt. cannot be found"):
expanded = read_configuration(pyproject)
assert "classifiers" not in expanded["project"]
@pytest.mark.parametrize(
"example",
(
"""
[project]
name = "myproj"
version = "1.2"
[my-tool.that-disrespect.pep518]
value = 42
""",
),
)
def test_ignore_unrelated_config(tmp_path, example):
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(example), encoding="utf-8")
# Make sure no error is raised due to 3rd party configs in pyproject.toml
assert read_configuration(pyproject) is not None
@pytest.mark.parametrize(
("example", "error_msg"),
[
(
"""
[project]
name = "myproj"
version = "1.2"
requires = ['pywin32; platform_system=="Windows"' ]
""",
"configuration error: .project. must not contain ..requires.. properties",
),
],
)
def test_invalid_example(tmp_path, example, error_msg):
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(example), encoding="utf-8")
pattern = re.compile(f"invalid pyproject.toml.*{error_msg}.*", re.M | re.S)
with pytest.raises(ValueError, match=pattern):
read_configuration(pyproject)
@pytest.mark.parametrize("config", ("", "[tool.something]\nvalue = 42"))
def test_empty(tmp_path, config):
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(config, encoding="utf-8")
# Make sure no error is raised
assert read_configuration(pyproject) == {}
@pytest.mark.parametrize("config", ("[project]\nname = 'myproj'\nversion='42'\n",))
def test_include_package_data_by_default(tmp_path, config):
"""Builds with ``pyproject.toml`` should consider ``include-package-data=True`` as
default.
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(config, encoding="utf-8")
config = read_configuration(pyproject)
assert config["tool"]["setuptools"]["include-package-data"] is True
def test_include_package_data_in_setuppy(tmp_path):
"""Builds with ``pyproject.toml`` should consider ``include_package_data`` set in
``setup.py``.
See https://github.com/pypa/setuptools/issues/3197#issuecomment-1079023889
"""
files = {
"pyproject.toml": "[project]\nname = 'myproj'\nversion='42'\n",
"setup.py": "__import__('setuptools').setup(include_package_data=False)",
}
jaraco.path.build(files, prefix=tmp_path)
with Path(tmp_path):
dist = distutils.core.run_setup("setup.py", {}, stop_after="config")
assert dist.get_name() == "myproj"
assert dist.get_version() == "42"
assert dist.include_package_data is False
def test_warn_tools_typo(tmp_path):
"""Test that the common ``tools.setuptools`` typo in ``pyproject.toml`` issues a warning
See https://github.com/pypa/setuptools/issues/4150
"""
config = """
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "myproj"
version = '42'
[tools.setuptools]
packages = ["package"]
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(cleandoc(config), encoding="utf-8")
with pytest.warns(_ToolsTypoInMetadata):
read_configuration(pyproject)

View File

@@ -0,0 +1,109 @@
from inspect import cleandoc
import pytest
from jaraco import path
from setuptools.config.pyprojecttoml import apply_configuration
from setuptools.dist import Distribution
from setuptools.warnings import SetuptoolsWarning
def test_dynamic_dependencies(tmp_path):
files = {
"requirements.txt": "six\n # comment\n",
"pyproject.toml": cleandoc(
"""
[project]
name = "myproj"
version = "1.0"
dynamic = ["dependencies"]
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic.dependencies]
file = ["requirements.txt"]
"""
),
}
path.build(files, prefix=tmp_path)
dist = Distribution()
dist = apply_configuration(dist, tmp_path / "pyproject.toml")
assert dist.install_requires == ["six"]
def test_dynamic_optional_dependencies(tmp_path):
files = {
"requirements-docs.txt": "sphinx\n # comment\n",
"pyproject.toml": cleandoc(
"""
[project]
name = "myproj"
version = "1.0"
dynamic = ["optional-dependencies"]
[tool.setuptools.dynamic.optional-dependencies.docs]
file = ["requirements-docs.txt"]
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
"""
),
}
path.build(files, prefix=tmp_path)
dist = Distribution()
dist = apply_configuration(dist, tmp_path / "pyproject.toml")
assert dist.extras_require == {"docs": ["sphinx"]}
def test_mixed_dynamic_optional_dependencies(tmp_path):
"""
Test that if PEP 621 was loosened to allow mixing of dynamic and static
configurations in the case of fields containing sub-fields (groups),
things would work out.
"""
files = {
"requirements-images.txt": "pillow~=42.0\n # comment\n",
"pyproject.toml": cleandoc(
"""
[project]
name = "myproj"
version = "1.0"
dynamic = ["optional-dependencies"]
[project.optional-dependencies]
docs = ["sphinx"]
[tool.setuptools.dynamic.optional-dependencies.images]
file = ["requirements-images.txt"]
"""
),
}
path.build(files, prefix=tmp_path)
pyproject = tmp_path / "pyproject.toml"
with pytest.raises(ValueError, match="project.optional-dependencies"):
apply_configuration(Distribution(), pyproject)
def test_mixed_extras_require_optional_dependencies(tmp_path):
files = {
"pyproject.toml": cleandoc(
"""
[project]
name = "myproj"
version = "1.0"
optional-dependencies.docs = ["sphinx"]
"""
),
}
path.build(files, prefix=tmp_path)
pyproject = tmp_path / "pyproject.toml"
with pytest.warns(SetuptoolsWarning, match=".extras_require. overwritten"):
dist = Distribution({"extras_require": {"hello": ["world"]}})
dist = apply_configuration(dist, pyproject)
assert dist.extras_require == {"docs": ["sphinx"]}

View File

@@ -0,0 +1,965 @@
import configparser
import contextlib
import inspect
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from packaging.requirements import InvalidRequirement
from setuptools.config.setupcfg import ConfigHandler, Target, read_configuration
from setuptools.dist import Distribution, _Distribution
from setuptools.warnings import SetuptoolsDeprecationWarning
from ..textwrap import DALS
from distutils.errors import DistutilsFileError, DistutilsOptionError
class ErrConfigHandler(ConfigHandler[Target]):
"""Erroneous handler. Fails to implement required methods."""
section_prefix = "**err**"
def make_package_dir(name, base_dir, ns=False):
dir_package = base_dir
for dir_name in name.split('/'):
dir_package = dir_package.mkdir(dir_name)
init_file = None
if not ns:
init_file = dir_package.join('__init__.py')
init_file.write('')
return dir_package, init_file
def fake_env(
tmpdir, setup_cfg, setup_py=None, encoding='ascii', package_path='fake_package'
):
if setup_py is None:
setup_py = 'from setuptools import setup\nsetup()\n'
tmpdir.join('setup.py').write(setup_py)
config = tmpdir.join('setup.cfg')
config.write(setup_cfg.encode(encoding), mode='wb')
package_dir, init_file = make_package_dir(package_path, tmpdir)
init_file.write(
'VERSION = (1, 2, 3)\n'
'\n'
'VERSION_MAJOR = 1'
'\n'
'def get_version():\n'
' return [3, 4, 5, "dev"]\n'
'\n'
)
return package_dir, config
@contextlib.contextmanager
def get_dist(tmpdir, kwargs_initial=None, parse=True):
kwargs_initial = kwargs_initial or {}
with tmpdir.as_cwd():
dist = Distribution(kwargs_initial)
dist.script_name = 'setup.py'
parse and dist.parse_config_files()
yield dist
def test_parsers_implemented():
with pytest.raises(NotImplementedError):
handler = ErrConfigHandler(None, {}, False, Mock())
handler.parsers
class TestConfigurationReader:
def test_basic(self, tmpdir):
_, config = fake_env(
tmpdir,
'[metadata]\n'
'version = 10.1.1\n'
'keywords = one, two\n'
'\n'
'[options]\n'
'scripts = bin/a.py, bin/b.py\n',
)
config_dict = read_configuration('%s' % config)
assert config_dict['metadata']['version'] == '10.1.1'
assert config_dict['metadata']['keywords'] == ['one', 'two']
assert config_dict['options']['scripts'] == ['bin/a.py', 'bin/b.py']
def test_no_config(self, tmpdir):
with pytest.raises(DistutilsFileError):
read_configuration('%s' % tmpdir.join('setup.cfg'))
def test_ignore_errors(self, tmpdir):
_, config = fake_env(
tmpdir,
'[metadata]\nversion = attr: none.VERSION\nkeywords = one, two\n',
)
with pytest.raises(ImportError):
read_configuration('%s' % config)
config_dict = read_configuration('%s' % config, ignore_option_errors=True)
assert config_dict['metadata']['keywords'] == ['one', 'two']
assert 'version' not in config_dict['metadata']
config.remove()
class TestMetadata:
def test_basic(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'version = 10.1.1\n'
'description = Some description\n'
'long_description_content_type = text/something\n'
'long_description = file: README\n'
'name = fake_name\n'
'keywords = one, two\n'
'provides = package, package.sub\n'
'license = otherlic\n'
'download_url = http://test.test.com/test/\n'
'maintainer_email = test@test.com\n',
)
tmpdir.join('README').write('readme contents\nline2')
meta_initial = {
# This will be used so `otherlic` won't replace it.
'license': 'BSD 3-Clause License',
}
with get_dist(tmpdir, meta_initial) as dist:
metadata = dist.metadata
assert metadata.version == '10.1.1'
assert metadata.description == 'Some description'
assert metadata.long_description_content_type == 'text/something'
assert metadata.long_description == 'readme contents\nline2'
assert metadata.provides == ['package', 'package.sub']
assert metadata.license == 'BSD 3-Clause License'
assert metadata.name == 'fake_name'
assert metadata.keywords == ['one', 'two']
assert metadata.download_url == 'http://test.test.com/test/'
assert metadata.maintainer_email == 'test@test.com'
def test_license_cfg(self, tmpdir):
fake_env(
tmpdir,
DALS(
"""
[metadata]
name=foo
version=0.0.1
license=Apache 2.0
"""
),
)
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.name == "foo"
assert metadata.version == "0.0.1"
assert metadata.license == "Apache 2.0"
def test_file_mixed(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\nlong_description = file: README.rst, CHANGES.rst\n\n',
)
tmpdir.join('README.rst').write('readme contents\nline2')
tmpdir.join('CHANGES.rst').write('changelog contents\nand stuff')
with get_dist(tmpdir) as dist:
assert dist.metadata.long_description == (
'readme contents\nline2\nchangelog contents\nand stuff'
)
def test_file_sandboxed(self, tmpdir):
tmpdir.ensure("README")
project = tmpdir.join('depth1', 'depth2')
project.ensure(dir=True)
fake_env(project, '[metadata]\nlong_description = file: ../../README\n')
with get_dist(project, parse=False) as dist:
with pytest.raises(DistutilsOptionError):
dist.parse_config_files() # file: out of sandbox
def test_aliases(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'author_email = test@test.com\n'
'home_page = http://test.test.com/test/\n'
'summary = Short summary\n'
'platform = a, b\n'
'classifier =\n'
' Framework :: Django\n'
' Programming Language :: Python :: 3.5\n',
)
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.author_email == 'test@test.com'
assert metadata.url == 'http://test.test.com/test/'
assert metadata.description == 'Short summary'
assert metadata.platforms == ['a', 'b']
assert metadata.classifiers == [
'Framework :: Django',
'Programming Language :: Python :: 3.5',
]
def test_multiline(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'name = fake_name\n'
'keywords =\n'
' one\n'
' two\n'
'classifiers =\n'
' Framework :: Django\n'
' Programming Language :: Python :: 3.5\n',
)
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.keywords == ['one', 'two']
assert metadata.classifiers == [
'Framework :: Django',
'Programming Language :: Python :: 3.5',
]
def test_dict(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'project_urls =\n'
' Link One = https://example.com/one/\n'
' Link Two = https://example.com/two/\n',
)
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.project_urls == {
'Link One': 'https://example.com/one/',
'Link Two': 'https://example.com/two/',
}
def test_version(self, tmpdir):
package_dir, config = fake_env(
tmpdir, '[metadata]\nversion = attr: fake_package.VERSION\n'
)
sub_a = package_dir.mkdir('subpkg_a')
sub_a.join('__init__.py').write('')
sub_a.join('mod.py').write('VERSION = (2016, 11, 26)')
sub_b = package_dir.mkdir('subpkg_b')
sub_b.join('__init__.py').write('')
sub_b.join('mod.py').write(
'import third_party_module\nVERSION = (2016, 11, 26)'
)
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
config.write('[metadata]\nversion = attr: fake_package.get_version\n')
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '3.4.5.dev'
config.write('[metadata]\nversion = attr: fake_package.VERSION_MAJOR\n')
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1'
config.write('[metadata]\nversion = attr: fake_package.subpkg_a.mod.VERSION\n')
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '2016.11.26'
config.write('[metadata]\nversion = attr: fake_package.subpkg_b.mod.VERSION\n')
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '2016.11.26'
def test_version_file(self, tmpdir):
fake_env(tmpdir, '[metadata]\nversion = file: fake_package/version.txt\n')
tmpdir.join('fake_package', 'version.txt').write('1.2.3\n')
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
tmpdir.join('fake_package', 'version.txt').write('1.2.3\n4.5.6\n')
with pytest.raises(DistutilsOptionError):
with get_dist(tmpdir) as dist:
dist.metadata.version
def test_version_with_package_dir_simple(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'version = attr: fake_package_simple.VERSION\n'
'[options]\n'
'package_dir =\n'
' = src\n',
package_path='src/fake_package_simple',
)
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
def test_version_with_package_dir_rename(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'version = attr: fake_package_rename.VERSION\n'
'[options]\n'
'package_dir =\n'
' fake_package_rename = fake_dir\n',
package_path='fake_dir',
)
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
def test_version_with_package_dir_complex(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'version = attr: fake_package_complex.VERSION\n'
'[options]\n'
'package_dir =\n'
' fake_package_complex = src/fake_dir\n',
package_path='src/fake_dir',
)
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
def test_unknown_meta_item(self, tmpdir):
fake_env(tmpdir, '[metadata]\nname = fake_name\nunknown = some\n')
with get_dist(tmpdir, parse=False) as dist:
dist.parse_config_files() # Skip unknown.
def test_usupported_section(self, tmpdir):
fake_env(tmpdir, '[metadata.some]\nkey = val\n')
with get_dist(tmpdir, parse=False) as dist:
with pytest.raises(DistutilsOptionError):
dist.parse_config_files()
def test_classifiers(self, tmpdir):
expected = set([
'Framework :: Django',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
])
# From file.
_, config = fake_env(tmpdir, '[metadata]\nclassifiers = file: classifiers\n')
tmpdir.join('classifiers').write(
'Framework :: Django\n'
'Programming Language :: Python :: 3\n'
'Programming Language :: Python :: 3.5\n'
)
with get_dist(tmpdir) as dist:
assert set(dist.metadata.classifiers) == expected
# From list notation
config.write(
'[metadata]\n'
'classifiers =\n'
' Framework :: Django\n'
' Programming Language :: Python :: 3\n'
' Programming Language :: Python :: 3.5\n'
)
with get_dist(tmpdir) as dist:
assert set(dist.metadata.classifiers) == expected
def test_interpolation(self, tmpdir):
fake_env(tmpdir, '[metadata]\ndescription = %(message)s\n')
with pytest.raises(configparser.InterpolationMissingOptionError):
with get_dist(tmpdir):
pass
def test_non_ascii_1(self, tmpdir):
fake_env(tmpdir, '[metadata]\ndescription = éàïôñ\n', encoding='utf-8')
with get_dist(tmpdir):
pass
def test_non_ascii_3(self, tmpdir):
fake_env(tmpdir, '\n# -*- coding: invalid\n')
with get_dist(tmpdir):
pass
def test_non_ascii_4(self, tmpdir):
fake_env(
tmpdir,
'# -*- coding: utf-8\n[metadata]\ndescription = éàïôñ\n',
encoding='utf-8',
)
with get_dist(tmpdir) as dist:
assert dist.metadata.description == 'éàïôñ'
def test_not_utf8(self, tmpdir):
"""
Config files encoded not in UTF-8 will fail
"""
fake_env(
tmpdir,
'# vim: set fileencoding=iso-8859-15 :\n[metadata]\ndescription = éàïôñ\n',
encoding='iso-8859-15',
)
with pytest.raises(UnicodeDecodeError):
with get_dist(tmpdir):
pass
def test_warn_dash_deprecation(self, tmpdir):
# warn_dash_deprecation() is a method in setuptools.dist
# remove this test and the method when no longer needed
fake_env(
tmpdir,
'[metadata]\n'
'author-email = test@test.com\n'
'maintainer_email = foo@foo.com\n',
)
msg = "Usage of dash-separated 'author-email' will not be supported"
with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.author_email == 'test@test.com'
assert metadata.maintainer_email == 'foo@foo.com'
def test_make_option_lowercase(self, tmpdir):
# remove this test and the method make_option_lowercase() in setuptools.dist
# when no longer needed
fake_env(tmpdir, '[metadata]\nName = foo\ndescription = Some description\n')
msg = "Usage of uppercase key 'Name' in 'metadata' will not be supported"
with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
with get_dist(tmpdir) as dist:
metadata = dist.metadata
assert metadata.name == 'foo'
assert metadata.description == 'Some description'
class TestOptions:
def test_basic(self, tmpdir):
fake_env(
tmpdir,
'[options]\n'
'zip_safe = True\n'
'include_package_data = yes\n'
'package_dir = b=c, =src\n'
'packages = pack_a, pack_b.subpack\n'
'namespace_packages = pack1, pack2\n'
'scripts = bin/one.py, bin/two.py\n'
'eager_resources = bin/one.py, bin/two.py\n'
'install_requires = docutils>=0.3; pack ==1.1, ==1.3; hey\n'
'setup_requires = docutils>=0.3; spack ==1.1, ==1.3; there\n'
'dependency_links = http://some.com/here/1, '
'http://some.com/there/2\n'
'python_requires = >=1.0, !=2.8\n'
'py_modules = module1, module2\n',
)
deprec = pytest.warns(SetuptoolsDeprecationWarning, match="namespace_packages")
with deprec, get_dist(tmpdir) as dist:
assert dist.zip_safe
assert dist.include_package_data
assert dist.package_dir == {'': 'src', 'b': 'c'}
assert dist.packages == ['pack_a', 'pack_b.subpack']
assert dist.namespace_packages == ['pack1', 'pack2']
assert dist.scripts == ['bin/one.py', 'bin/two.py']
assert dist.dependency_links == ([
'http://some.com/here/1',
'http://some.com/there/2',
])
assert dist.install_requires == ([
'docutils>=0.3',
'pack==1.1,==1.3',
'hey',
])
assert dist.setup_requires == ([
'docutils>=0.3',
'spack ==1.1, ==1.3',
'there',
])
assert dist.python_requires == '>=1.0, !=2.8'
assert dist.py_modules == ['module1', 'module2']
def test_multiline(self, tmpdir):
fake_env(
tmpdir,
'[options]\n'
'package_dir = \n'
' b=c\n'
' =src\n'
'packages = \n'
' pack_a\n'
' pack_b.subpack\n'
'namespace_packages = \n'
' pack1\n'
' pack2\n'
'scripts = \n'
' bin/one.py\n'
' bin/two.py\n'
'eager_resources = \n'
' bin/one.py\n'
' bin/two.py\n'
'install_requires = \n'
' docutils>=0.3\n'
' pack ==1.1, ==1.3\n'
' hey\n'
'setup_requires = \n'
' docutils>=0.3\n'
' spack ==1.1, ==1.3\n'
' there\n'
'dependency_links = \n'
' http://some.com/here/1\n'
' http://some.com/there/2\n',
)
deprec = pytest.warns(SetuptoolsDeprecationWarning, match="namespace_packages")
with deprec, get_dist(tmpdir) as dist:
assert dist.package_dir == {'': 'src', 'b': 'c'}
assert dist.packages == ['pack_a', 'pack_b.subpack']
assert dist.namespace_packages == ['pack1', 'pack2']
assert dist.scripts == ['bin/one.py', 'bin/two.py']
assert dist.dependency_links == ([
'http://some.com/here/1',
'http://some.com/there/2',
])
assert dist.install_requires == ([
'docutils>=0.3',
'pack==1.1,==1.3',
'hey',
])
assert dist.setup_requires == ([
'docutils>=0.3',
'spack ==1.1, ==1.3',
'there',
])
def test_package_dir_fail(self, tmpdir):
fake_env(tmpdir, '[options]\npackage_dir = a b\n')
with get_dist(tmpdir, parse=False) as dist:
with pytest.raises(DistutilsOptionError):
dist.parse_config_files()
def test_package_data(self, tmpdir):
fake_env(
tmpdir,
'[options.package_data]\n'
'* = *.txt, *.rst\n'
'hello = *.msg\n'
'\n'
'[options.exclude_package_data]\n'
'* = fake1.txt, fake2.txt\n'
'hello = *.dat\n',
)
with get_dist(tmpdir) as dist:
assert dist.package_data == {
'': ['*.txt', '*.rst'],
'hello': ['*.msg'],
}
assert dist.exclude_package_data == {
'': ['fake1.txt', 'fake2.txt'],
'hello': ['*.dat'],
}
def test_packages(self, tmpdir):
fake_env(tmpdir, '[options]\npackages = find:\n')
with get_dist(tmpdir) as dist:
assert dist.packages == ['fake_package']
def test_find_directive(self, tmpdir):
dir_package, config = fake_env(tmpdir, '[options]\npackages = find:\n')
make_package_dir('sub_one', dir_package)
make_package_dir('sub_two', dir_package)
with get_dist(tmpdir) as dist:
assert set(dist.packages) == set([
'fake_package',
'fake_package.sub_two',
'fake_package.sub_one',
])
config.write(
'[options]\n'
'packages = find:\n'
'\n'
'[options.packages.find]\n'
'where = .\n'
'include =\n'
' fake_package.sub_one\n'
' two\n'
)
with get_dist(tmpdir) as dist:
assert dist.packages == ['fake_package.sub_one']
config.write(
'[options]\n'
'packages = find:\n'
'\n'
'[options.packages.find]\n'
'exclude =\n'
' fake_package.sub_one\n'
)
with get_dist(tmpdir) as dist:
assert set(dist.packages) == set(['fake_package', 'fake_package.sub_two'])
def test_find_namespace_directive(self, tmpdir):
dir_package, config = fake_env(
tmpdir, '[options]\npackages = find_namespace:\n'
)
make_package_dir('sub_one', dir_package)
make_package_dir('sub_two', dir_package, ns=True)
with get_dist(tmpdir) as dist:
assert set(dist.packages) == {
'fake_package',
'fake_package.sub_two',
'fake_package.sub_one',
}
config.write(
'[options]\n'
'packages = find_namespace:\n'
'\n'
'[options.packages.find]\n'
'where = .\n'
'include =\n'
' fake_package.sub_one\n'
' two\n'
)
with get_dist(tmpdir) as dist:
assert dist.packages == ['fake_package.sub_one']
config.write(
'[options]\n'
'packages = find_namespace:\n'
'\n'
'[options.packages.find]\n'
'exclude =\n'
' fake_package.sub_one\n'
)
with get_dist(tmpdir) as dist:
assert set(dist.packages) == {'fake_package', 'fake_package.sub_two'}
def test_extras_require(self, tmpdir):
fake_env(
tmpdir,
'[options.extras_require]\n'
'pdf = ReportLab>=1.2; RXP\n'
'rest = \n'
' docutils>=0.3\n'
' pack ==1.1, ==1.3\n',
)
with get_dist(tmpdir) as dist:
assert dist.extras_require == {
'pdf': ['ReportLab>=1.2', 'RXP'],
'rest': ['docutils>=0.3', 'pack==1.1,==1.3'],
}
assert set(dist.metadata.provides_extras) == {'pdf', 'rest'}
@pytest.mark.parametrize(
"config",
[
"[options.extras_require]\nfoo = bar;python_version<'3'",
"[options.extras_require]\nfoo = bar;os_name=='linux'",
"[options.extras_require]\nfoo = bar;python_version<'3'\n",
"[options.extras_require]\nfoo = bar;os_name=='linux'\n",
"[options]\ninstall_requires = bar;python_version<'3'",
"[options]\ninstall_requires = bar;os_name=='linux'",
"[options]\ninstall_requires = bar;python_version<'3'\n",
"[options]\ninstall_requires = bar;os_name=='linux'\n",
],
)
def test_raises_accidental_env_marker_misconfig(self, config, tmpdir):
fake_env(tmpdir, config)
match = (
r"One of the parsed requirements in `(install_requires|extras_require.+)` "
"looks like a valid environment marker.*"
)
with pytest.raises(InvalidRequirement, match=match):
with get_dist(tmpdir) as _:
pass
@pytest.mark.parametrize(
"config",
[
"[options.extras_require]\nfoo = bar;python_version<3",
"[options.extras_require]\nfoo = bar;python_version<3\n",
"[options]\ninstall_requires = bar;python_version<3",
"[options]\ninstall_requires = bar;python_version<3\n",
],
)
def test_warn_accidental_env_marker_misconfig(self, config, tmpdir):
fake_env(tmpdir, config)
match = (
r"One of the parsed requirements in `(install_requires|extras_require.+)` "
"looks like a valid environment marker.*"
)
with pytest.warns(SetuptoolsDeprecationWarning, match=match):
with get_dist(tmpdir) as _:
pass
@pytest.mark.parametrize(
"config",
[
"[options.extras_require]\nfoo =\n bar;python_version<'3'",
"[options.extras_require]\nfoo = bar;baz\nboo = xxx;yyy",
"[options.extras_require]\nfoo =\n bar;python_version<'3'\n",
"[options.extras_require]\nfoo = bar;baz\nboo = xxx;yyy\n",
"[options.extras_require]\nfoo =\n bar\n python_version<3\n",
"[options]\ninstall_requires =\n bar;python_version<'3'",
"[options]\ninstall_requires = bar;baz\nboo = xxx;yyy",
"[options]\ninstall_requires =\n bar;python_version<'3'\n",
"[options]\ninstall_requires = bar;baz\nboo = xxx;yyy\n",
"[options]\ninstall_requires =\n bar\n python_version<3\n",
],
)
@pytest.mark.filterwarnings("error::setuptools.SetuptoolsDeprecationWarning")
def test_nowarn_accidental_env_marker_misconfig(self, config, tmpdir, recwarn):
fake_env(tmpdir, config)
num_warnings = len(recwarn)
with get_dist(tmpdir) as _:
pass
# The examples are valid, no warnings shown
assert len(recwarn) == num_warnings
def test_dash_preserved_extras_require(self, tmpdir):
fake_env(tmpdir, '[options.extras_require]\nfoo-a = foo\nfoo_b = test\n')
with get_dist(tmpdir) as dist:
assert dist.extras_require == {'foo-a': ['foo'], 'foo_b': ['test']}
def test_entry_points(self, tmpdir):
_, config = fake_env(
tmpdir,
'[options.entry_points]\n'
'group1 = point1 = pack.module:func, '
'.point2 = pack.module2:func_rest [rest]\n'
'group2 = point3 = pack.module:func2\n',
)
with get_dist(tmpdir) as dist:
assert dist.entry_points == {
'group1': [
'point1 = pack.module:func',
'.point2 = pack.module2:func_rest [rest]',
],
'group2': ['point3 = pack.module:func2'],
}
expected = (
'[blogtool.parsers]\n'
'.rst = some.nested.module:SomeClass.some_classmethod[reST]\n'
)
tmpdir.join('entry_points').write(expected)
# From file.
config.write('[options]\nentry_points = file: entry_points\n')
with get_dist(tmpdir) as dist:
assert dist.entry_points == expected
def test_case_sensitive_entry_points(self, tmpdir):
fake_env(
tmpdir,
'[options.entry_points]\n'
'GROUP1 = point1 = pack.module:func, '
'.point2 = pack.module2:func_rest [rest]\n'
'group2 = point3 = pack.module:func2\n',
)
with get_dist(tmpdir) as dist:
assert dist.entry_points == {
'GROUP1': [
'point1 = pack.module:func',
'.point2 = pack.module2:func_rest [rest]',
],
'group2': ['point3 = pack.module:func2'],
}
def test_data_files(self, tmpdir):
fake_env(
tmpdir,
'[options.data_files]\n'
'cfg =\n'
' a/b.conf\n'
' c/d.conf\n'
'data = e/f.dat, g/h.dat\n',
)
with get_dist(tmpdir) as dist:
expected = [
('cfg', ['a/b.conf', 'c/d.conf']),
('data', ['e/f.dat', 'g/h.dat']),
]
assert sorted(dist.data_files) == sorted(expected)
def test_data_files_globby(self, tmpdir):
fake_env(
tmpdir,
'[options.data_files]\n'
'cfg =\n'
' a/b.conf\n'
' c/d.conf\n'
'data = *.dat\n'
'icons = \n'
' *.ico\n'
'audio = \n'
' *.wav\n'
' sounds.db\n',
)
# Create dummy files for glob()'s sake:
tmpdir.join('a.dat').write('')
tmpdir.join('b.dat').write('')
tmpdir.join('c.dat').write('')
tmpdir.join('a.ico').write('')
tmpdir.join('b.ico').write('')
tmpdir.join('c.ico').write('')
tmpdir.join('beep.wav').write('')
tmpdir.join('boop.wav').write('')
tmpdir.join('sounds.db').write('')
with get_dist(tmpdir) as dist:
expected = [
('cfg', ['a/b.conf', 'c/d.conf']),
('data', ['a.dat', 'b.dat', 'c.dat']),
('icons', ['a.ico', 'b.ico', 'c.ico']),
('audio', ['beep.wav', 'boop.wav', 'sounds.db']),
]
assert sorted(dist.data_files) == sorted(expected)
def test_python_requires_simple(self, tmpdir):
fake_env(
tmpdir,
DALS(
"""
[options]
python_requires=>=2.7
"""
),
)
with get_dist(tmpdir) as dist:
dist.parse_config_files()
def test_python_requires_compound(self, tmpdir):
fake_env(
tmpdir,
DALS(
"""
[options]
python_requires=>=2.7,!=3.0.*
"""
),
)
with get_dist(tmpdir) as dist:
dist.parse_config_files()
def test_python_requires_invalid(self, tmpdir):
fake_env(
tmpdir,
DALS(
"""
[options]
python_requires=invalid
"""
),
)
with pytest.raises(Exception):
with get_dist(tmpdir) as dist:
dist.parse_config_files()
def test_cmdclass(self, tmpdir):
module_path = Path(tmpdir, "src/custom_build.py") # auto discovery for src
module_path.parent.mkdir(parents=True, exist_ok=True)
module_path.write_text(
"from distutils.core import Command\nclass CustomCmd(Command): pass\n",
encoding="utf-8",
)
setup_cfg = """
[options]
cmdclass =
customcmd = custom_build.CustomCmd
"""
fake_env(tmpdir, inspect.cleandoc(setup_cfg))
with get_dist(tmpdir) as dist:
cmdclass = dist.cmdclass['customcmd']
assert cmdclass.__name__ == "CustomCmd"
assert cmdclass.__module__ == "custom_build"
assert module_path.samefile(inspect.getfile(cmdclass))
def test_requirements_file(self, tmpdir):
fake_env(
tmpdir,
DALS(
"""
[options]
install_requires = file:requirements.txt
[options.extras_require]
colors = file:requirements-extra.txt
"""
),
)
tmpdir.join('requirements.txt').write('\ndocutils>=0.3\n\n')
tmpdir.join('requirements-extra.txt').write('colorama')
with get_dist(tmpdir) as dist:
assert dist.install_requires == ['docutils>=0.3']
assert dist.extras_require == {'colors': ['colorama']}
saved_dist_init = _Distribution.__init__
class TestExternalSetters:
# During creation of the setuptools Distribution() object, we call
# the init of the parent distutils Distribution object via
# _Distribution.__init__ ().
#
# It's possible distutils calls out to various keyword
# implementations (i.e. distutils.setup_keywords entry points)
# that may set a range of variables.
#
# This wraps distutil's Distribution.__init__ and simulates
# pbr or something else setting these values.
def _fake_distribution_init(self, dist, attrs):
saved_dist_init(dist, attrs)
# see self._DISTUTILS_UNSUPPORTED_METADATA
dist.metadata.long_description_content_type = 'text/something'
# Test overwrite setup() args
dist.metadata.project_urls = {
'Link One': 'https://example.com/one/',
'Link Two': 'https://example.com/two/',
}
@patch.object(_Distribution, '__init__', autospec=True)
def test_external_setters(self, mock_parent_init, tmpdir):
mock_parent_init.side_effect = self._fake_distribution_init
dist = Distribution(attrs={'project_urls': {'will_be': 'ignored'}})
assert dist.metadata.long_description_content_type == 'text/something'
assert dist.metadata.project_urls == {
'Link One': 'https://example.com/one/',
'Link Two': 'https://example.com/two/',
}