diff options
Diffstat (limited to 'setuptools/tests')
57 files changed, 6516 insertions, 1645 deletions
diff --git a/setuptools/tests/__init__.py b/setuptools/tests/__init__.py index 54dd7d2..564adf2 100644 --- a/setuptools/tests/__init__.py +++ b/setuptools/tests/__init__.py @@ -2,5 +2,9 @@ import locale import pytest + +__all__ = ['fail_on_ascii'] + + is_ascii = locale.getpreferredencoding() == 'ANSI_X3.4-1968' fail_on_ascii = pytest.mark.xfail(is_ascii, reason="Test fails in this locale") diff --git a/setuptools/tests/config/__init__.py b/setuptools/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/setuptools/tests/config/__init__.py diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py new file mode 100644 index 0000000..044f801 --- /dev/null +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -0,0 +1,254 @@ +"""Make sure that applying the configuration from pyproject.toml is equivalent to +applying a similar configuration from setup.cfg +""" +import io +import re +from pathlib import Path +from urllib.request import urlopen +from unittest.mock import Mock + +import pytest +from ini2toml.api import Translator + +import setuptools # noqa ensure monkey patch to metadata +from setuptools.dist import Distribution +from setuptools.config import setupcfg, pyprojecttoml +from setuptools.config import expand + + +EXAMPLES = (Path(__file__).parent / "setupcfg_examples.txt").read_text() +EXAMPLE_URLS = [x for x in EXAMPLES.splitlines() if not x.startswith("#")] +DOWNLOAD_DIR = Path(__file__).parent / "downloads" + + +def makedist(path): + return Distribution({"src_root": path}) + + +@pytest.mark.parametrize("url", EXAMPLE_URLS) +@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, DOWNLOAD_DIR) + pyproject_example = Path(tmp_path, "pyproject.toml") + toml_config = Translator().translate(setupcfg_example.read_text(), "setup.cfg") + pyproject_example.write_text(toml_config) + + 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)): + if ( + "testing" in dist_toml.extras_require + and "testing" not in dist_cfg.extras_require + ): + # ini2toml can automatically convert `tests_require` to `testing` extra + dist_toml.extras_require.pop("testing") + 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"} +] +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_EXAMPLE_SCRIPT = """ +def main_cli(): pass +def main_gui(): pass +def main_tomatoes(): pass +""" + + +def _pep621_example_project(tmp_path, readme="README.rst"): + pyproject = tmp_path / "pyproject.toml" + text = PEP621_EXAMPLE + replacements = {'readme = "README.rst"': f'readme = "{readme}"'} + for orig, subst in replacements.items(): + text = text.replace(orig, subst) + pyproject.write_text(text) + + (tmp_path / readme).write_text("hello world") + (tmp_path / "LICENSE.txt").write_text("--- LICENSE stub ---") + (tmp_path / "spam.py").write_text(PEP621_EXAMPLE_SCRIPT) + 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 + + +# TODO: After PEP 639 is accepted, we have to move the license-files +# to the `project` table instead of `tool.setuptools` +def test_license_and_license_files(tmp_path): + 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 += '\n[tool.setuptools]\nlicense-files = ["_FILE*"]\n' + pyproject.write_text(text, encoding="utf-8") + (tmp_path / "_FILE.txt").touch() + (tmp_path / "_FILE.rst").touch() + + # Would normally match the `license_files` glob patterns, but we want to exclude it + # by being explicit. On the other hand, its contents should be added to `license` + (tmp_path / "LICENSE.txt").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" + + +# --- Auxiliary Functions --- + + +NAME_REMOVE = ("http://", "https://", "github.com/", "/raw/") + + +def retrieve_file(url, download_dir): + file_name = url.strip() + for part in NAME_REMOVE: + file_name = file_name.replace(part, '').strip().strip('/:').strip() + file_name = re.sub(r"[^\-_\.\w\d]+", "_", file_name) + path = Path(download_dir, file_name) + if not path.exists(): + download_dir.mkdir(exist_ok=True, parents=True) + download(url, path) + return path + + +def download(url, dest): + with urlopen(url) as f: + data = f.read() + + with open(dest, "wb") as f: + f.write(data) + + assert Path(dest).exists() + + +def core_metadata(dist) -> str: + with io.StringIO() as buffer: + dist.metadata.write_pkg_file(buffer) + value = "\n".join(buffer.getvalue().strip().splitlines()) + + # ---- DIFF NORMALISATION ---- + # PEP 621 is very particular about author/maintainer metadata conversion, so skip + value = re.sub(r"^(Author|Maintainer)(-email)?:.*$", "", value, flags=re.M) + # May be redundant with Home-page + value = re.sub(r"^Project-URL: Homepage,.*$", "", value, flags=re.M) + # May be missing in original (relying on default) but backfilled in the TOML + value = re.sub(r"^Description-Content-Type:.*$", "", value, flags=re.M) + # ini2toml can automatically convert `tests_require` to `testing` extra + value = value.replace("Provides-Extra: testing\n", "") + # Remove empty lines + value = re.sub(r"^\s*$", "", value, flags=re.M) + value = re.sub(r"^\n", "", value, flags=re.M) + + return value diff --git a/setuptools/tests/config/test_expand.py b/setuptools/tests/config/test_expand.py new file mode 100644 index 0000000..3a59edb --- /dev/null +++ b/setuptools/tests/config/test_expand.py @@ -0,0 +1,155 @@ +import os + +import pytest + +from distutils.errors import DistutilsOptionError +from setuptools.config import expand +from setuptools.discovery import find_package_path + + +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) + + +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_) + + 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"]) + + # 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: + 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)}\n" + "raise 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) + + 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(tmp_path, package_dir, file, module, return_value): + 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, monkeypatch, 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 diff --git a/setuptools/tests/config/test_pyprojecttoml.py b/setuptools/tests/config/test_pyprojecttoml.py new file mode 100644 index 0000000..1b5b90e --- /dev/null +++ b/setuptools/tests/config/test_pyprojecttoml.py @@ -0,0 +1,350 @@ +import logging +from configparser import ConfigParser +from inspect import cleandoc + +import pytest +import tomli_w +from path import Path as _Path + +from setuptools.config.pyprojecttoml import ( + read_configuration, + expand_configuration, + validate, +) +from setuptools.errors import OptionError + + +import setuptools # noqa -- force distutils.core to be patched +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): + pyproject = path / "pyproject.toml" + + files = [ + f"{pkg_root}/pkg/__init__.py", + "_files/file.txt", + ] + if pkg_root != ".": # flat-layout will raise error for multi-package dist + # Ensure namespaces are discovered + files.append(f"{pkg_root}/other/nested/__init__.py") + + for file in files: + (path / file).parent.mkdir(exist_ok=True, parents=True) + (path / file).touch() + + pyproject.write_text(EXAMPLE) + (path / "README.md").write_text("hello world") + (path / f"{pkg_root}/pkg/mod.py").write_text("class CustomSdist: pass") + (path / f"{pkg_root}/pkg/__version__.py").write_text("VERSION = (3, 10)") + (path / f"{pkg_root}/pkg/__main__.py").write_text("def exec(): print('hello')") + + +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]"}, +} + + +def test_expand_entry_point(tmp_path): + entry_points = ConfigParser() + entry_points.read_dict(ENTRY_POINTS) + with open(tmp_path / "entry-points.txt", "w") as f: + entry_points.write(f) + + tool = {"setuptools": {"dynamic": {"entry-points": {"file": "entry-points.txt"}}}} + project = {"dynamic": ["scripts", "gui-scripts", "entry-points"]} + pyproject = {"project": project, "tool": tool} + expanded = expand_configuration(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]" + + project = {"dynamic": ["entry-points"]} + pyproject = {"project": project, "tool": tool} + expanded = expand_configuration(pyproject, tmp_path) + expanded_project = expanded["project"] + assert len(expanded_project["entry-points"]) == 3 + assert "scripts" not in expanded_project + assert "gui-scripts" not in expanded_project + + +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 = """\ + Framework :: Flask + Programming Language :: Haskell + """ + (tmp_path / "classifiers.txt").write_text(cleandoc(classifiers)) + + 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)) + with pytest.raises(OptionError, match="No configuration found"): + read_configuration(pyproject) + + 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)) + with pytest.warns(UserWarning, match="File .*classifiers.txt. cannot be found"): + expanded = read_configuration(pyproject) + assert not expanded["project"]["classifiers"] + + +@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)) + + # 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, value_shown_in_debug", + [ + ( + """ + [project] + name = "myproj" + version = "1.2" + requires = ['pywin32; platform_system=="Windows"' ] + """, + "configuration error: `project` must not contain {'requires'} properties", + '"requires": ["pywin32; platform_system==\\"Windows\\""]', + ), + ], +) +def test_invalid_example(tmp_path, caplog, example, error_msg, value_shown_in_debug): + caplog.set_level(logging.DEBUG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(cleandoc(example)) + + caplog.clear() + with pytest.raises(ValueError, match="invalid pyproject.toml"): + read_configuration(pyproject) + + # Make sure the logs give guidance to the user + error_log = caplog.record_tuples[0] + assert error_log[1] == logging.ERROR + assert error_msg in error_log[2] + + debug_log = caplog.record_tuples[1] + assert debug_log[1] == logging.DEBUG + debug_msg = "".join(line.strip() for line in debug_log[2].splitlines()) + assert value_shown_in_debug in debug_msg + + +@pytest.mark.parametrize("config", ("", "[tool.something]\nvalue = 42")) +def test_empty(tmp_path, config): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(config) + + # 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) + + 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 + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[project]\nname = 'myproj'\nversion='42'\n") + setuppy = tmp_path / "setup.py" + setuppy.write_text("__import__('setuptools').setup(include_package_data=False)") + + 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 diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py new file mode 100644 index 0000000..1f35f83 --- /dev/null +++ b/setuptools/tests/config/test_setupcfg.py @@ -0,0 +1,924 @@ +import configparser +import contextlib +import inspect +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from distutils.errors import DistutilsOptionError, DistutilsFileError +from setuptools.dist import Distribution, _Distribution +from setuptools.config.setupcfg import ConfigHandler, read_configuration +from ..textwrap import DALS + + +class ErrConfigHandler(ConfigHandler): + """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\n' 'setup()\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]\n' 'version = attr: none.VERSION\n' 'keywords = 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]\n' 'long_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\n' 'changelog 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]\n' 'long_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]\n' 'version = 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\n' 'VERSION = (2016, 11, 26)' + ) + + with get_dist(tmpdir) as dist: + assert dist.metadata.version == '1.2.3' + + config.write('[metadata]\n' 'version = attr: fake_package.get_version\n') + with get_dist(tmpdir) as dist: + assert dist.metadata.version == '3.4.5.dev' + + config.write('[metadata]\n' 'version = attr: fake_package.VERSION_MAJOR\n') + with get_dist(tmpdir) as dist: + assert dist.metadata.version == '1' + + config.write( + '[metadata]\n' 'version = attr: fake_package.subpkg_a.mod.VERSION\n' + ) + with get_dist(tmpdir) as dist: + assert dist.metadata.version == '2016.11.26' + + config.write( + '[metadata]\n' 'version = 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): + + _, config = fake_env( + tmpdir, '[metadata]\n' 'version = 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): + + _, config = 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): + + _, config = 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): + + _, config = 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]\n' 'name = fake_name\n' 'unknown = 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]\n' 'key = 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]\n' 'classifiers = 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_deprecated_config_handlers(self, tmpdir): + fake_env( + tmpdir, + '[metadata]\n' + 'version = 10.1.1\n' + 'description = Some description\n' + 'requires = some, requirement\n', + ) + + with pytest.deprecated_call(): + with get_dist(tmpdir) as dist: + metadata = dist.metadata + + assert metadata.version == '10.1.1' + assert metadata.description == 'Some description' + assert metadata.requires == ['some', 'requirement'] + + def test_interpolation(self, tmpdir): + fake_env(tmpdir, '[metadata]\n' 'description = %(message)s\n') + with pytest.raises(configparser.InterpolationMissingOptionError): + with get_dist(tmpdir): + pass + + def test_non_ascii_1(self, tmpdir): + fake_env(tmpdir, '[metadata]\n' 'description = éàïôñ\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]\n' 'description = éàïôñ\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]\n' + 'description = éàïôñ\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 " + "in future versions. " + "Please use the underscore name 'author_email' instead" + ) + with pytest.warns(UserWarning, 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]\n' 'Name = foo\n' 'description = Some description\n' + ) + msg = ( + "Usage of uppercase key 'Name' in 'metadata' will be deprecated in " + "future versions. " + "Please use lowercase 'name' instead" + ) + with pytest.warns(UserWarning, 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' + 'tests_require = mock==0.7.2; pytest\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', + ) + with 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.tests_require == ['mock==0.7.2', 'pytest'] + 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' + 'tests_require = \n' + ' mock==0.7.2\n' + ' pytest\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', + ) + with 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'] + ) + assert dist.tests_require == ['mock==0.7.2', 'pytest'] + + def test_package_dir_fail(self, tmpdir): + fake_env(tmpdir, '[options]\n' 'package_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]\n' 'packages = 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]\n' 'packages = find:\n') + + dir_sub_one, _ = make_package_dir('sub_one', dir_package) + dir_sub_two, _ = 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]\n' 'packages = find_namespace:\n' + ) + + dir_sub_one, _ = make_package_dir('sub_one', dir_package) + dir_sub_two, _ = 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 dist.metadata.provides_extras == set(['pdf', 'rest']) + + def test_dash_preserved_extras_require(self, tmpdir): + fake_env(tmpdir, '[options.extras_require]\n' 'foo-a = foo\n' 'foo_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]\n' 'entry_points = file: entry_points\n') + + with get_dist(tmpdir) as dist: + assert dist.entry_points == expected + + def test_case_sensitive_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'], + } + + 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\n" + "class CustomCmd(Command): pass\n" + ) + + 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)) + + +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._DISTUTUILS_UNSUPPORTED_METADATA + setattr(dist.metadata, 'long_description_content_type', 'text/something') + # Test overwrite setup() args + setattr( + dist.metadata, + 'project_urls', + { + 'Link One': 'https://example.com/one/', + 'Link Two': 'https://example.com/two/', + }, + ) + return None + + @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/', + } diff --git a/setuptools/tests/contexts.py b/setuptools/tests/contexts.py index 535ae10..5894882 100644 --- a/setuptools/tests/contexts.py +++ b/setuptools/tests/contexts.py @@ -4,9 +4,10 @@ import shutil import sys import contextlib import site +import io -from setuptools.extern import six import pkg_resources +from filelock import FileLock @contextlib.contextmanager @@ -58,8 +59,8 @@ def quiet(): old_stdout = sys.stdout old_stderr = sys.stderr - new_stdout = sys.stdout = six.StringIO() - new_stderr = sys.stderr = six.StringIO() + new_stdout = sys.stdout = io.StringIO() + new_stderr = sys.stderr = io.StringIO() try: yield new_stdout, new_stderr finally: @@ -96,3 +97,29 @@ def suppress_exceptions(*excs): yield except excs: pass + + +def multiproc(request): + """ + Return True if running under xdist and multiple + workers are used. + """ + try: + worker_id = request.getfixturevalue('worker_id') + except Exception: + return False + return worker_id != 'master' + + +@contextlib.contextmanager +def session_locked_tmp_dir(request, tmp_path_factory, name): + """Uses a file lock to guarantee only one worker can access a temp dir""" + # get the temp directory shared by all workers + base = tmp_path_factory.getbasetemp() + shared_dir = base.parent if multiproc(request) else base + + locked_dir = shared_dir / name + with FileLock(locked_dir.with_suffix(".lock")): + # ^-- prevent multiple workers to access the directory at once + locked_dir.mkdir(exist_ok=True, parents=True) + yield locked_dir diff --git a/setuptools/tests/environment.py b/setuptools/tests/environment.py index c67898c..bcf2960 100644 --- a/setuptools/tests/environment.py +++ b/setuptools/tests/environment.py @@ -1,9 +1,38 @@ import os import sys +import subprocess import unicodedata - from subprocess import Popen as _Popen, PIPE as _PIPE +import jaraco.envs + + +class VirtualEnv(jaraco.envs.VirtualEnv): + name = '.env' + # Some version of PyPy will import distutils on startup, implicitly + # importing setuptools, and thus leading to BackendInvalid errors + # when upgrading Setuptools. Bypass this behavior by avoiding the + # early availability and need to upgrade. + create_opts = ['--no-setuptools'] + + def run(self, cmd, *args, **kwargs): + cmd = [self.exe(cmd[0])] + cmd[1:] + kwargs = {"cwd": self.root, **kwargs} # Allow overriding + # In some environments (eg. downstream distro packaging), where: + # - tox isn't used to run tests and + # - PYTHONPATH is set to point to a specific setuptools codebase and + # - no custom env is explicitly set by a test + # PYTHONPATH will leak into the spawned processes. + # In that case tests look for module in the wrong place (on PYTHONPATH). + # Unless the test sets its own special env, pass a copy of the existing + # environment with removed PYTHONPATH to the subprocesses. + if "env" not in kwargs: + env = dict(os.environ) + if "PYTHONPATH" in env: + del env["PYTHONPATH"] + kwargs["env"] = env + return subprocess.check_output(cmd, *args, **kwargs) + def _which_dirs(cmd): result = set() @@ -29,7 +58,7 @@ def run_setup_py(cmd, pypath=None, path=None, if pypath is not None: env["PYTHONPATH"] = pypath - # overide the execution path if needed + # override the execution path if needed if path is not None: env["PATH"] = path if not env.get("PATH", ""): @@ -46,6 +75,8 @@ def run_setup_py(cmd, pypath=None, path=None, cmd, stdout=_PIPE, stderr=_PIPE, shell=shell, env=env, ) + if isinstance(data_stream, tuple): + data_stream = slice(*data_stream) data = proc.communicate()[data_stream] except OSError: return 1, '' diff --git a/setuptools/tests/files.py b/setuptools/tests/files.py deleted file mode 100644 index f5f0e6b..0000000 --- a/setuptools/tests/files.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - - -from setuptools.extern.six import binary_type -import pkg_resources.py31compat - - -def build_files(file_defs, prefix=""): - """ - Build a set of files/directories, as described by the file_defs dictionary. - - Each key/value pair in the dictionary is interpreted as a filename/contents - pair. If the contents value is a dictionary, a directory is created, and the - dictionary interpreted as the files within it, recursively. - - For example: - - {"README.txt": "A README file", - "foo": { - "__init__.py": "", - "bar": { - "__init__.py": "", - }, - "baz.py": "# Some code", - } - } - """ - for name, contents in file_defs.items(): - full_name = os.path.join(prefix, name) - if isinstance(contents, dict): - pkg_resources.py31compat.makedirs(full_name, exist_ok=True) - build_files(contents, prefix=full_name) - else: - if isinstance(contents, binary_type): - with open(full_name, 'wb') as f: - f.write(contents) - else: - with open(full_name, 'w') as f: - f.write(contents) diff --git a/setuptools/tests/fixtures.py b/setuptools/tests/fixtures.py index 5204c8d..25ab49f 100644 --- a/setuptools/tests/fixtures.py +++ b/setuptools/tests/fixtures.py @@ -1,9 +1,16 @@ +import os +import contextlib +import sys +import subprocess +from pathlib import Path + import pytest +import path -from . import contexts +from . import contexts, environment -@pytest.yield_fixture +@pytest.fixture def user_override(monkeypatch): """ Override site.USER_BASE and site.USER_SITE with temporary directories in @@ -17,7 +24,117 @@ def user_override(monkeypatch): yield -@pytest.yield_fixture +@pytest.fixture def tmpdir_cwd(tmpdir): with tmpdir.as_cwd() as orig: yield orig + + +@pytest.fixture(autouse=True, scope="session") +def workaround_xdist_376(request): + """ + Workaround pytest-dev/pytest-xdist#376 + + ``pytest-xdist`` tends to inject '' into ``sys.path``, + which may break certain isolation expectations. + Remove the entry so the import + machinery behaves the same irrespective of xdist. + """ + if not request.config.pluginmanager.has_plugin('xdist'): + return + + with contextlib.suppress(ValueError): + sys.path.remove('') + + +@pytest.fixture +def sample_project(tmp_path): + """ + Clone the 'sampleproject' and return a path to it. + """ + cmd = ['git', 'clone', 'https://github.com/pypa/sampleproject'] + try: + subprocess.check_call(cmd, cwd=str(tmp_path)) + except Exception: + pytest.skip("Unable to clone sampleproject") + return tmp_path / 'sampleproject' + + +# sdist and wheel artifacts should be stable across a round of tests +# so we can build them once per session and use the files as "readonly" + + +@pytest.fixture(scope="session") +def setuptools_sdist(tmp_path_factory, request): + if os.getenv("PRE_BUILT_SETUPTOOLS_SDIST"): + return Path(os.getenv("PRE_BUILT_SETUPTOOLS_SDIST")).resolve() + + with contexts.session_locked_tmp_dir( + request, tmp_path_factory, "sdist_build") as tmp: + dist = next(tmp.glob("*.tar.gz"), None) + if dist: + return dist + + subprocess.check_call([ + sys.executable, "-m", "build", "--sdist", + "--outdir", str(tmp), str(request.config.rootdir) + ]) + return next(tmp.glob("*.tar.gz")) + + +@pytest.fixture(scope="session") +def setuptools_wheel(tmp_path_factory, request): + if os.getenv("PRE_BUILT_SETUPTOOLS_WHEEL"): + return Path(os.getenv("PRE_BUILT_SETUPTOOLS_WHEEL")).resolve() + + with contexts.session_locked_tmp_dir( + request, tmp_path_factory, "wheel_build") as tmp: + dist = next(tmp.glob("*.whl"), None) + if dist: + return dist + + subprocess.check_call([ + sys.executable, "-m", "build", "--wheel", + "--outdir", str(tmp) , str(request.config.rootdir) + ]) + return next(tmp.glob("*.whl")) + + +@pytest.fixture +def venv(tmp_path, setuptools_wheel): + """Virtual env with the version of setuptools under test installed""" + env = environment.VirtualEnv() + env.root = path.Path(tmp_path / 'venv') + env.req = str(setuptools_wheel) + # In some environments (eg. downstream distro packaging), + # where tox isn't used to run tests and PYTHONPATH is set to point to + # a specific setuptools codebase, PYTHONPATH will leak into the spawned + # processes. + # env.create() should install the just created setuptools + # wheel, but it doesn't if it finds another existing matching setuptools + # installation present on PYTHONPATH: + # `setuptools is already installed with the same version as the provided + # wheel. Use --force-reinstall to force an installation of the wheel.` + # This prevents leaking PYTHONPATH to the created environment. + with contexts.environment(PYTHONPATH=None): + return env.create() + + +@pytest.fixture +def venv_without_setuptools(tmp_path): + """Virtual env without any version of setuptools installed""" + env = environment.VirtualEnv() + env.root = path.Path(tmp_path / 'venv_without_setuptools') + env.create_opts = ['--no-setuptools'] + env.ensure_env() + return env + + +@pytest.fixture +def bare_venv(tmp_path): + """Virtual env without any common packages installed""" + env = environment.VirtualEnv() + env.root = path.Path(tmp_path / 'bare_venv') + env.create_opts = ['--no-setuptools', '--no-pip', '--no-wheel', '--no-seed'] + env.ensure_env() + return env diff --git a/setuptools/tests/integration/__init__.py b/setuptools/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/setuptools/tests/integration/__init__.py diff --git a/setuptools/tests/integration/helpers.py b/setuptools/tests/integration/helpers.py new file mode 100644 index 0000000..24c02be --- /dev/null +++ b/setuptools/tests/integration/helpers.py @@ -0,0 +1,75 @@ +"""Reusable functions and classes for different types of integration tests. + +For example ``Archive`` can be used to check the contents of distribution built +with setuptools, and ``run`` will always try to be as verbose as possible to +facilitate debugging. +""" +import os +import subprocess +import tarfile +from zipfile import ZipFile +from pathlib import Path + + +def run(cmd, env=None): + r = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env={**os.environ, **(env or {})} + # ^-- allow overwriting instead of discarding the current env + ) + + out = r.stdout + "\n" + r.stderr + # pytest omits stdout/err by default, if the test fails they help debugging + print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + print(f"Command: {cmd}\nreturn code: {r.returncode}\n\n{out}") + + if r.returncode == 0: + return out + raise subprocess.CalledProcessError(r.returncode, cmd, r.stdout, r.stderr) + + +class Archive: + """Compatibility layer for ZipFile/Info and TarFile/Info""" + def __init__(self, filename): + self._filename = filename + if filename.endswith("tar.gz"): + self._obj = tarfile.open(filename, "r:gz") + elif filename.endswith("zip"): + self._obj = ZipFile(filename) + else: + raise ValueError(f"{filename} doesn't seem to be a zip or tar.gz") + + def __iter__(self): + if hasattr(self._obj, "infolist"): + return iter(self._obj.infolist()) + return iter(self._obj) + + def get_name(self, zip_or_tar_info): + if hasattr(zip_or_tar_info, "filename"): + return zip_or_tar_info.filename + return zip_or_tar_info.name + + def get_content(self, zip_or_tar_info): + if hasattr(self._obj, "extractfile"): + content = self._obj.extractfile(zip_or_tar_info) + if content is None: + msg = f"Invalid {zip_or_tar_info.name} in {self._filename}" + raise ValueError(msg) + return str(content.read(), "utf-8") + return str(self._obj.read(zip_or_tar_info), "utf-8") + + +def get_sdist_members(sdist_path): + with tarfile.open(sdist_path, "r:gz") as tar: + files = [Path(f) for f in tar.getnames()] + # remove root folder + relative_files = ("/".join(f.parts[1:]) for f in files) + return {f for f in relative_files if f} + + +def get_wheel_members(wheel_path): + with ZipFile(wheel_path) as zipfile: + return set(zipfile.namelist()) diff --git a/setuptools/tests/integration/test_pip_install_sdist.py b/setuptools/tests/integration/test_pip_install_sdist.py new file mode 100644 index 0000000..9d11047 --- /dev/null +++ b/setuptools/tests/integration/test_pip_install_sdist.py @@ -0,0 +1,219 @@ +"""Integration tests for setuptools that focus on building packages via pip. + +The idea behind these tests is not to exhaustively check all the possible +combinations of packages, operating systems, supporting libraries, etc, but +rather check a limited number of popular packages and how they interact with +the exposed public API. This way if any change in API is introduced, we hope to +identify backward compatibility problems before publishing a release. + +The number of tested packages is purposefully kept small, to minimise duration +and the associated maintenance cost (changes in the way these packages define +their build process may require changes in the tests). +""" +import json +import os +import shutil +import sys +from enum import Enum +from glob import glob +from hashlib import md5 +from urllib.request import urlopen + +import pytest +from packaging.requirements import Requirement + +from .helpers import Archive, run + + +pytestmark = pytest.mark.integration + +LATEST, = list(Enum("v", "LATEST")) +"""Default version to be checked""" +# There are positive and negative aspects of checking the latest version of the +# packages. +# The main positive aspect is that the latest version might have already +# removed the use of APIs deprecated in previous releases of setuptools. + + +# Packages to be tested: +# (Please notice the test environment cannot support EVERY library required for +# compiling binary extensions. In Ubuntu/Debian nomenclature, we only assume +# that `build-essential`, `gfortran` and `libopenblas-dev` are installed, +# due to their relevance to the numerical/scientific programming ecosystem) +EXAMPLES = [ + ("pandas", LATEST), # cython + custom build_ext + ("sphinx", LATEST), # custom setup.py + ("pip", LATEST), # just in case... + ("pytest", LATEST), # uses setuptools_scm + ("mypy", LATEST), # custom build_py + ext_modules + + # --- Popular packages: https://hugovk.github.io/top-pypi-packages/ --- + ("botocore", LATEST), + ("kiwisolver", "1.3.2"), # build_ext, version pinned due to setup_requires + ("brotli", LATEST), # not in the list but used by urllib3 + + # When adding packages to this list, make sure they expose a `__version__` + # attribute, or modify the tests below +] + + +# Some packages have "optional" dependencies that modify their build behaviour +# and are not listed in pyproject.toml, others still use `setup_requires` +EXTRA_BUILD_DEPS = { + "sphinx": ("babel>=1.3",), + "kiwisolver": ("cppy>=1.1.0",) +} + + +VIRTUALENV = (sys.executable, "-m", "virtualenv") + + +# By default, pip will try to build packages in isolation (PEP 517), which +# means it will download the previous stable version of setuptools. +# `pip` flags can avoid that (the version of setuptools under test +# should be the one to be used) +SDIST_OPTIONS = ( + "--ignore-installed", + "--no-build-isolation", + # We don't need "--no-binary :all:" since we specify the path to the sdist. + # It also helps with performance, since dependencies can come from wheels. +) +# The downside of `--no-build-isolation` is that pip will not download build +# dependencies. The test script will have to also handle that. + + +@pytest.fixture +def venv_python(tmp_path): + run([*VIRTUALENV, str(tmp_path / ".venv")]) + possible_path = (str(p.parent) for p in tmp_path.glob(".venv/*/python*")) + return shutil.which("python", path=os.pathsep.join(possible_path)) + + +@pytest.fixture(autouse=True) +def _prepare(tmp_path, venv_python, monkeypatch, request): + download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path)) + os.makedirs(download_path, exist_ok=True) + + # Environment vars used for building some of the packages + monkeypatch.setenv("USE_MYPYC", "1") + + def _debug_info(): + # Let's provide the maximum amount of information possible in the case + # it is necessary to debug the tests directly from the CI logs. + print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + print("Temporary directory:") + map(print, tmp_path.glob("*")) + print("Virtual environment:") + run([venv_python, "-m", "pip", "freeze"]) + request.addfinalizer(_debug_info) + + +ALREADY_LOADED = ("pytest", "mypy") # loaded by pytest/pytest-enabler + + +@pytest.mark.parametrize('package, version', EXAMPLES) +@pytest.mark.uses_network +def test_install_sdist(package, version, tmp_path, venv_python, setuptools_wheel): + venv_pip = (venv_python, "-m", "pip") + sdist = retrieve_sdist(package, version, tmp_path) + deps = build_deps(package, sdist) + if deps: + print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + print("Dependencies:", deps) + run([*venv_pip, "install", *deps]) + + # Use a virtualenv to simulate PEP 517 isolation + # but install fresh setuptools wheel to ensure the version under development + run([*venv_pip, "install", "-I", setuptools_wheel]) + run([*venv_pip, "install", *SDIST_OPTIONS, sdist]) + + # Execute a simple script to make sure the package was installed correctly + script = f"import {package}; print(getattr({package}, '__version__', 0))" + run([venv_python, "-c", script]) + + +# ---- Helper Functions ---- + + +def retrieve_sdist(package, version, tmp_path): + """Either use cached sdist file or download it from PyPI""" + # `pip download` cannot be used due to + # https://github.com/pypa/pip/issues/1884 + # https://discuss.python.org/t/pep-625-file-name-of-a-source-distribution/4686 + # We have to find the correct distribution file and download it + download_path = os.getenv("DOWNLOAD_PATH", str(tmp_path)) + dist = retrieve_pypi_sdist_metadata(package, version) + + # Remove old files to prevent cache to grow indefinitely + for file in glob(os.path.join(download_path, f"{package}*")): + if dist["filename"] != file: + os.unlink(file) + + dist_file = os.path.join(download_path, dist["filename"]) + if not os.path.exists(dist_file): + download(dist["url"], dist_file, dist["md5_digest"]) + return dist_file + + +def retrieve_pypi_sdist_metadata(package, version): + # https://warehouse.pypa.io/api-reference/json.html + id_ = package if version is LATEST else f"{package}/{version}" + with urlopen(f"https://pypi.org/pypi/{id_}/json") as f: + metadata = json.load(f) + + if metadata["info"]["yanked"]: + raise ValueError(f"Release for {package} {version} was yanked") + + version = metadata["info"]["version"] + release = metadata["releases"][version] + dists = [d for d in release if d["packagetype"] == "sdist"] + if len(dists) == 0: + raise ValueError(f"No sdist found for {package} {version}") + + for dist in dists: + if dist["filename"].endswith(".tar.gz"): + return dist + + # Not all packages are publishing tar.gz + return dist + + +def download(url, dest, md5_digest): + with urlopen(url) as f: + data = f.read() + + assert md5(data).hexdigest() == md5_digest + + with open(dest, "wb") as f: + f.write(data) + + assert os.path.exists(dest) + + +def build_deps(package, sdist_file): + """Find out what are the build dependencies for a package. + + We need to "manually" install them, since pip will not install build + deps with `--no-build-isolation`. + """ + import tomli as toml + + # delay importing, since pytest discovery phase may hit this file from a + # testenv without tomli + + archive = Archive(sdist_file) + pyproject = _read_pyproject(archive) + + info = toml.loads(pyproject) + deps = info.get("build-system", {}).get("requires", []) + deps += EXTRA_BUILD_DEPS.get(package, []) + # Remove setuptools from requirements (and deduplicate) + requirements = {Requirement(d).name: d for d in deps} + return [v for k, v in requirements.items() if k != "setuptools"] + + +def _read_pyproject(archive): + for member in archive: + if os.path.basename(archive.get_name(member)) == "pyproject.toml": + return archive.get_content(member) + return "" diff --git a/setuptools/tests/namespaces.py b/setuptools/tests/namespaces.py index ef5ecda..245cf8e 100644 --- a/setuptools/tests/namespaces.py +++ b/setuptools/tests/namespaces.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, unicode_literals - import textwrap diff --git a/setuptools/tests/server.py b/setuptools/tests/server.py index 3531212..6717c05 100644 --- a/setuptools/tests/server.py +++ b/setuptools/tests/server.py @@ -1,13 +1,15 @@ """Basic http server for tests to simulate PyPI or custom indexes """ +import os import time import threading +import http.server +import urllib.parse +import urllib.request -from setuptools.extern.six.moves import BaseHTTPServer, SimpleHTTPServer - -class IndexServer(BaseHTTPServer.HTTPServer): +class IndexServer(http.server.HTTPServer): """Basic single-threaded http server simulating a package index You can use this server in unittest like this:: @@ -19,10 +21,11 @@ class IndexServer(BaseHTTPServer.HTTPServer): s.stop() """ - def __init__(self, server_address=('', 0), - RequestHandlerClass=SimpleHTTPServer.SimpleHTTPRequestHandler): - BaseHTTPServer.HTTPServer.__init__(self, server_address, - RequestHandlerClass) + def __init__( + self, server_address=('', 0), + RequestHandlerClass=http.server.SimpleHTTPRequestHandler): + http.server.HTTPServer.__init__( + self, server_address, RequestHandlerClass) self._run = True def start(self): @@ -44,29 +47,44 @@ class IndexServer(BaseHTTPServer.HTTPServer): return 'http://127.0.0.1:%s/setuptools/tests/indexes/' % port -class RequestRecorder(BaseHTTPServer.BaseHTTPRequestHandler): +class RequestRecorder(http.server.BaseHTTPRequestHandler): def do_GET(self): requests = vars(self.server).setdefault('requests', []) requests.append(self) self.send_response(200, 'OK') -class MockServer(BaseHTTPServer.HTTPServer, threading.Thread): +class MockServer(http.server.HTTPServer, threading.Thread): """ A simple HTTP Server that records the requests made to it. """ - def __init__(self, server_address=('', 0), + def __init__( + self, server_address=('', 0), RequestHandlerClass=RequestRecorder): - BaseHTTPServer.HTTPServer.__init__(self, server_address, - RequestHandlerClass) + http.server.HTTPServer.__init__( + self, server_address, RequestHandlerClass) threading.Thread.__init__(self) - self.setDaemon(True) + self.daemon = True self.requests = [] def run(self): self.serve_forever() @property + def netloc(self): + return 'localhost:%s' % self.server_port + + @property def url(self): - return 'http://localhost:%(server_port)s/' % vars(self) + return 'http://%s/' % self.netloc + + +def path_to_url(path, authority=None): + """ Convert a path to a file: URL. """ + path = os.path.normpath(os.path.abspath(path)) + base = 'file:' + if authority is not None: + base += '//' + authority + url = urllib.parse.urljoin(base, urllib.request.pathname2url(path)) + return url diff --git a/setuptools/tests/test_archive_util.py b/setuptools/tests/test_archive_util.py index b789e9a..7f99624 100644 --- a/setuptools/tests/test_archive_util.py +++ b/setuptools/tests/test_archive_util.py @@ -3,8 +3,6 @@ import tarfile import io -from setuptools.extern import six - import pytest from setuptools import archive_util @@ -22,8 +20,6 @@ def tarfile_with_unicode(tmpdir): data = b"" filename = "testimäge.png" - if six.PY2: - filename = filename.decode('utf-8') t = tarfile.TarInfo(filename) t.size = len(data) @@ -39,4 +35,4 @@ def tarfile_with_unicode(tmpdir): @pytest.mark.xfail(reason="#710 and #712") def test_unicode_files(tarfile_with_unicode, tmpdir): target = tmpdir / 'out' - archive_util.unpack_archive(tarfile_with_unicode, six.text_type(target)) + archive_util.unpack_archive(tarfile_with_unicode, str(target)) diff --git a/setuptools/tests/test_bdist_deprecations.py b/setuptools/tests/test_bdist_deprecations.py new file mode 100644 index 0000000..1a900c6 --- /dev/null +++ b/setuptools/tests/test_bdist_deprecations.py @@ -0,0 +1,27 @@ +"""develop tests +""" +import mock +import sys + +import pytest + +from setuptools.dist import Distribution +from setuptools import SetuptoolsDeprecationWarning + + +@pytest.mark.skipif(sys.platform == 'win32', reason='non-Windows only') +@mock.patch('distutils.command.bdist_rpm.bdist_rpm') +def test_bdist_rpm_warning(distutils_cmd, tmpdir_cwd): + dist = Distribution( + dict( + script_name='setup.py', + script_args=['bdist_rpm'], + name='foo', + py_modules=['hi'], + ) + ) + dist.parse_command_line() + with pytest.warns(SetuptoolsDeprecationWarning): + dist.run_commands() + + distutils_cmd.run.assert_called_once() diff --git a/setuptools/tests/test_bdist_egg.py b/setuptools/tests/test_bdist_egg.py index 54742aa..67f788c 100644 --- a/setuptools/tests/test_bdist_egg.py +++ b/setuptools/tests/test_bdist_egg.py @@ -13,7 +13,7 @@ from . import contexts SETUP_PY = """\ from setuptools import setup -setup(name='foo', py_modules=['hi']) +setup(py_modules=['hi']) """ @@ -42,7 +42,7 @@ class Test: # let's see if we got our egg link at the right place [content] = os.listdir('dist') - assert re.match(r'foo-0.0.0-py[23].\d.egg$', content) + assert re.match(r'foo-0.0.0-py[23].\d+.egg$', content) @pytest.mark.xfail( os.environ.get('PYTHONDONTWRITEBYTECODE'), @@ -52,7 +52,6 @@ class Test: dist = Distribution(dict( script_name='setup.py', script_args=['bdist_egg', '--exclude-source-files'], - name='foo', py_modules=['hi'], )) with contexts.quiet(): diff --git a/setuptools/tests/test_build_clib.py b/setuptools/tests/test_build_clib.py index aebcc35..48bea2b 100644 --- a/setuptools/tests/test_build_clib.py +++ b/setuptools/tests/test_build_clib.py @@ -1,6 +1,4 @@ import pytest -import os -import shutil import mock from distutils.errors import DistutilsSetupError @@ -10,8 +8,7 @@ from setuptools.dist import Distribution class TestBuildCLib: @mock.patch( - 'setuptools.command.build_clib.newer_pairwise_group' - ) + 'setuptools.command.build_clib.newer_pairwise_group') def test_build_libraries(self, mock_newer): dist = Distribution() cmd = build_clib(dist) @@ -40,13 +37,14 @@ class TestBuildCLib: # with that out of the way, let's see if the crude dependency # system works cmd.compiler = mock.MagicMock(spec=cmd.compiler) - mock_newer.return_value = ([],[]) + mock_newer.return_value = ([], []) obj_deps = {'': ('global.h',), 'example.c': ('example.h',)} - libs = [('example', {'sources': ['example.c'] ,'obj_deps': obj_deps})] + libs = [('example', {'sources': ['example.c'], 'obj_deps': obj_deps})] cmd.build_libraries(libs) - assert [['example.c', 'global.h', 'example.h']] in mock_newer.call_args[0] + assert [['example.c', 'global.h', 'example.h']] in \ + mock_newer.call_args[0] assert not cmd.compiler.compile.called assert cmd.compiler.create_static_lib.call_count == 1 diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index 6025715..3177a2c 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -1,13 +1,20 @@ +import os import sys import distutils.command.build_ext as orig from distutils.sysconfig import get_config_var -from setuptools.extern import six +from jaraco import path from setuptools.command.build_ext import build_ext, get_abi3_suffix from setuptools.dist import Distribution from setuptools.extension import Extension +from . import environment +from .textwrap import DALS + + +IS_PYPY = '__pypy__' in sys.builtin_module_names + class TestBuildExt: def test_get_ext_filename(self): @@ -37,9 +44,107 @@ class TestBuildExt: assert 'spam.eggs' in cmd.ext_map res = cmd.get_ext_filename('spam.eggs') - if six.PY2 or not get_abi3_suffix(): - assert res.endswith(get_config_var('SO')) + if not get_abi3_suffix(): + assert res.endswith(get_config_var('EXT_SUFFIX')) elif sys.platform == 'win32': assert res.endswith('eggs.pyd') else: assert 'abi3' in res + + def test_ext_suffix_override(self): + """ + SETUPTOOLS_EXT_SUFFIX variable always overrides + default extension options. + """ + dist = Distribution() + cmd = build_ext(dist) + cmd.ext_map['for_abi3'] = ext = Extension( + 'for_abi3', + ['s.c'], + # Override shouldn't affect abi3 modules + py_limited_api=True, + ) + # Mock value needed to pass tests + ext._links_to_dynamic = False + + if not IS_PYPY: + expect = cmd.get_ext_filename('for_abi3') + else: + # PyPy builds do not use ABI3 tag, so they will + # also get the overridden suffix. + expect = 'for_abi3.test-suffix' + + try: + os.environ['SETUPTOOLS_EXT_SUFFIX'] = '.test-suffix' + res = cmd.get_ext_filename('normal') + assert 'normal.test-suffix' == res + res = cmd.get_ext_filename('for_abi3') + assert expect == res + finally: + del os.environ['SETUPTOOLS_EXT_SUFFIX'] + + +def test_build_ext_config_handling(tmpdir_cwd): + files = { + 'setup.py': DALS( + """ + from setuptools import Extension, setup + setup( + name='foo', + version='0.0.0', + ext_modules=[Extension('foo', ['foo.c'])], + ) + """), + 'foo.c': DALS( + """ + #include "Python.h" + + #if PY_MAJOR_VERSION >= 3 + + static struct PyModuleDef moduledef = { + PyModuleDef_HEAD_INIT, + "foo", + NULL, + 0, + NULL, + NULL, + NULL, + NULL, + NULL + }; + + #define INITERROR return NULL + + PyMODINIT_FUNC PyInit_foo(void) + + #else + + #define INITERROR return + + void initfoo(void) + + #endif + { + #if PY_MAJOR_VERSION >= 3 + PyObject *module = PyModule_Create(&moduledef); + #else + PyObject *module = Py_InitModule("extension", NULL); + #endif + if (module == NULL) + INITERROR; + #if PY_MAJOR_VERSION >= 3 + return module; + #endif + } + """), + 'setup.cfg': DALS( + """ + [build] + build_base = foo_build + """), + } + path.build(files) + code, output = environment.run_setup_py( + cmd=['build'], data_stream=(0, 2), + ) + assert code == 0, '\nSTDOUT:\n%s\nSTDERR:\n%s' % output diff --git a/setuptools/tests/test_build_meta.py b/setuptools/tests/test_build_meta.py index 659c1a6..36940e7 100644 --- a/setuptools/tests/test_build_meta.py +++ b/setuptools/tests/test_build_meta.py @@ -1,17 +1,35 @@ import os +import sys +import shutil +import signal +import tarfile +import importlib +import contextlib +from concurrent import futures +import re +from zipfile import ZipFile import pytest +from jaraco import path -from .files import build_files from .textwrap import DALS +SETUP_SCRIPT_STUB = "__import__('setuptools').setup()" -futures = pytest.importorskip('concurrent.futures') -importlib = pytest.importorskip('importlib') +TIMEOUT = int(os.getenv("TIMEOUT_BACKEND_TEST", "180")) # in seconds +IS_PYPY = '__pypy__' in sys.builtin_module_names -class BuildBackendBase(object): - def __init__(self, cwd=None, env={}, backend_name='setuptools.build_meta'): + +pytestmark = pytest.mark.skipif( + sys.platform == "win32" and IS_PYPY, + reason="The combination of PyPy + Windows + pytest-xdist + ProcessPoolExecutor " + "is flaky and problematic" +) + + +class BuildBackendBase: + def __init__(self, cwd='.', env={}, backend_name='setuptools.build_meta'): self.cwd = cwd self.env = env self.backend_name = backend_name @@ -19,108 +37,730 @@ class BuildBackendBase(object): class BuildBackend(BuildBackendBase): """PEP 517 Build Backend""" + def __init__(self, *args, **kwargs): super(BuildBackend, self).__init__(*args, **kwargs) - self.pool = futures.ProcessPoolExecutor() + self.pool = futures.ProcessPoolExecutor(max_workers=1) def __getattr__(self, name): """Handles aribrary function invocations on the build backend.""" + def method(*args, **kw): root = os.path.abspath(self.cwd) caller = BuildBackendCaller(root, self.env, self.backend_name) - return self.pool.submit(caller, name, *args, **kw).result() + pid = None + try: + pid = self.pool.submit(os.getpid).result(TIMEOUT) + return self.pool.submit(caller, name, *args, **kw).result(TIMEOUT) + except futures.TimeoutError: + self.pool.shutdown(wait=False) # doesn't stop already running processes + self._kill(pid) + pytest.xfail(f"Backend did not respond before timeout ({TIMEOUT} s)") + except (futures.process.BrokenProcessPool, MemoryError, OSError): + if IS_PYPY: + pytest.xfail("PyPy frequently fails tests with ProcessPoolExector") + raise return method + def _kill(self, pid): + if pid is None: + return + with contextlib.suppress(ProcessLookupError, OSError): + os.kill(pid, signal.SIGTERM if os.name == "nt" else signal.SIGKILL) + class BuildBackendCaller(BuildBackendBase): + def __init__(self, *args, **kwargs): + super(BuildBackendCaller, self).__init__(*args, **kwargs) + + (self.backend_name, _, + self.backend_obj) = self.backend_name.partition(':') + def __call__(self, name, *args, **kw): """Handles aribrary function invocations on the build backend.""" os.chdir(self.cwd) os.environ.update(self.env) mod = importlib.import_module(self.backend_name) - return getattr(mod, name)(*args, **kw) + if self.backend_obj: + backend = getattr(mod, self.backend_obj) + else: + backend = mod -defns = [{ - 'setup.py': DALS(""" + return getattr(backend, name)(*args, **kw) + + +defns = [ + { # simple setup.py script + 'setup.py': DALS(""" + __import__('setuptools').setup( + name='foo', + version='0.0.0', + py_modules=['hello'], + setup_requires=['six'], + ) + """), + 'hello.py': DALS(""" + def run(): + print('hello') + """), + }, + { # setup.py that relies on __name__ + 'setup.py': DALS(""" + assert __name__ == '__main__' + __import__('setuptools').setup( + name='foo', + version='0.0.0', + py_modules=['hello'], + setup_requires=['six'], + ) + """), + 'hello.py': DALS(""" + def run(): + print('hello') + """), + }, + { # setup.py script that runs arbitrary code + 'setup.py': DALS(""" + variable = True + def function(): + return variable + assert variable + __import__('setuptools').setup( + name='foo', + version='0.0.0', + py_modules=['hello'], + setup_requires=['six'], + ) + """), + 'hello.py': DALS(""" + def run(): + print('hello') + """), + }, + { # setup.py script that constructs temp files to be included in the distribution + 'setup.py': DALS(""" + # Some packages construct files on the fly, include them in the package, + # and immediately remove them after `setup()` (e.g. pybind11==2.9.1). + # Therefore, we cannot use `distutils.core.run_setup(..., stop_after=...)` + # to obtain a distribution object first, and then run the distutils + # commands later, because these files will be removed in the meantime. + + with open('world.py', 'w') as f: + f.write('x = 42') + + try: __import__('setuptools').setup( name='foo', - py_modules=['hello'], + version='0.0.0', + py_modules=['world'], setup_requires=['six'], ) + finally: + # Some packages will clean temporary files + __import__('os').unlink('world.py') + """), + }, + { # setup.cfg only + 'setup.cfg': DALS(""" + [metadata] + name = foo + version = 0.0.0 + + [options] + py_modules=hello + setup_requires=six + """), + 'hello.py': DALS(""" + def run(): + print('hello') + """) + }, + { # setup.cfg and setup.py + 'setup.cfg': DALS(""" + [metadata] + name = foo + version = 0.0.0 + + [options] + py_modules=hello + setup_requires=six + """), + 'setup.py': "__import__('setuptools').setup()", + 'hello.py': DALS(""" + def run(): + print('hello') + """) + }, +] + + +class TestBuildMetaBackend: + backend_name = 'setuptools.build_meta' + + def get_build_backend(self): + return BuildBackend(backend_name=self.backend_name) + + @pytest.fixture(params=defns) + def build_backend(self, tmpdir, request): + path.build(request.param, prefix=str(tmpdir)) + with tmpdir.as_cwd(): + yield self.get_build_backend() + + def test_get_requires_for_build_wheel(self, build_backend): + actual = build_backend.get_requires_for_build_wheel() + expected = ['six', 'wheel'] + assert sorted(actual) == sorted(expected) + + def test_get_requires_for_build_sdist(self, build_backend): + actual = build_backend.get_requires_for_build_sdist() + expected = ['six'] + assert sorted(actual) == sorted(expected) + + def test_build_wheel(self, build_backend): + dist_dir = os.path.abspath('pip-wheel') + os.makedirs(dist_dir) + wheel_name = build_backend.build_wheel(dist_dir) + + wheel_file = os.path.join(dist_dir, wheel_name) + assert os.path.isfile(wheel_file) + + # Temporary files should be removed + assert not os.path.isfile('world.py') + + with ZipFile(wheel_file) as zipfile: + wheel_contents = set(zipfile.namelist()) + + # Each one of the examples have a single module + # that should be included in the distribution + python_scripts = (f for f in wheel_contents if f.endswith('.py')) + modules = [f for f in python_scripts if not f.endswith('setup.py')] + assert len(modules) == 1 + + @pytest.mark.parametrize('build_type', ('wheel', 'sdist')) + def test_build_with_existing_file_present(self, build_type, tmpdir_cwd): + # Building a sdist/wheel should still succeed if there's + # already a sdist/wheel in the destination directory. + files = { + 'setup.py': "from setuptools import setup\nsetup()", + 'VERSION': "0.0.1", + 'setup.cfg': DALS(""" + [metadata] + name = foo + version = file: VERSION + """), + 'pyproject.toml': DALS(""" + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + """), + } + + path.build(files) + + dist_dir = os.path.abspath('preexisting-' + build_type) + + build_backend = self.get_build_backend() + build_method = getattr(build_backend, 'build_' + build_type) + + # Build a first sdist/wheel. + # Note: this also check the destination directory is + # successfully created if it does not exist already. + first_result = build_method(dist_dir) + + # Change version. + with open("VERSION", "wt") as version_file: + version_file.write("0.0.2") + + # Build a *second* sdist/wheel. + second_result = build_method(dist_dir) + + assert os.path.isfile(os.path.join(dist_dir, first_result)) + assert first_result != second_result + + # And if rebuilding the exact same sdist/wheel? + open(os.path.join(dist_dir, second_result), 'w').close() + third_result = build_method(dist_dir) + assert third_result == second_result + assert os.path.getsize(os.path.join(dist_dir, third_result)) > 0 + + @pytest.mark.parametrize("setup_script", [None, SETUP_SCRIPT_STUB]) + def test_build_with_pyproject_config(self, tmpdir, setup_script): + files = { + 'pyproject.toml': DALS(""" + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + + [project] + name = "foo" + license = {text = "MIT"} + description = "This is a Python package" + dynamic = ["version", "readme"] + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers" + ] + urls = {Homepage = "http://github.com"} + dependencies = [ + "appdirs", + ] + + [project.optional-dependencies] + all = [ + "tomli>=1", + "pyscaffold>=4,<5", + 'importlib; python_version == "2.6"', + ] + + [project.scripts] + foo = "foo.cli:main" + + [tool.setuptools] + zip-safe = false + package-dir = {"" = "src"} + packages = {find = {where = ["src"]}} + license-files = ["LICENSE*"] + + [tool.setuptools.dynamic] + version = {attr = "foo.__version__"} + readme = {file = "README.rst"} + + [tool.distutils.sdist] + formats = "gztar" + + [tool.distutils.bdist_wheel] + universal = true + """), + "MANIFEST.in": DALS(""" + global-include *.py *.txt + global-exclude *.py[cod] + """), + "README.rst": "This is a ``README``", + "LICENSE.txt": "---- placeholder MIT license ----", + "src": { + "foo": { + "__init__.py": "__version__ = '0.1'", + "cli.py": "def main(): print('hello world')", + "data.txt": "def main(): print('hello world')", + } + } + } + if setup_script: + files["setup.py"] = setup_script + + build_backend = self.get_build_backend() + with tmpdir.as_cwd(): + path.build(files) + sdist_path = build_backend.build_sdist("temp") + wheel_file = build_backend.build_wheel("temp") + + with tarfile.open(os.path.join(tmpdir, "temp", sdist_path)) as tar: + sdist_contents = set(tar.getnames()) + + with ZipFile(os.path.join(tmpdir, "temp", wheel_file)) as zipfile: + wheel_contents = set(zipfile.namelist()) + metadata = str(zipfile.read("foo-0.1.dist-info/METADATA"), "utf-8") + license = str(zipfile.read("foo-0.1.dist-info/LICENSE.txt"), "utf-8") + epoints = str(zipfile.read("foo-0.1.dist-info/entry_points.txt"), "utf-8") + + assert sdist_contents - {"foo-0.1/setup.py"} == { + 'foo-0.1', + 'foo-0.1/LICENSE.txt', + 'foo-0.1/MANIFEST.in', + 'foo-0.1/PKG-INFO', + 'foo-0.1/README.rst', + 'foo-0.1/pyproject.toml', + 'foo-0.1/setup.cfg', + 'foo-0.1/src', + 'foo-0.1/src/foo', + 'foo-0.1/src/foo/__init__.py', + 'foo-0.1/src/foo/cli.py', + 'foo-0.1/src/foo/data.txt', + 'foo-0.1/src/foo.egg-info', + 'foo-0.1/src/foo.egg-info/PKG-INFO', + 'foo-0.1/src/foo.egg-info/SOURCES.txt', + 'foo-0.1/src/foo.egg-info/dependency_links.txt', + 'foo-0.1/src/foo.egg-info/entry_points.txt', + 'foo-0.1/src/foo.egg-info/requires.txt', + 'foo-0.1/src/foo.egg-info/top_level.txt', + 'foo-0.1/src/foo.egg-info/not-zip-safe', + } + assert wheel_contents == { + "foo/__init__.py", + "foo/cli.py", + "foo/data.txt", # include_package_data defaults to True + "foo-0.1.dist-info/LICENSE.txt", + "foo-0.1.dist-info/METADATA", + "foo-0.1.dist-info/WHEEL", + "foo-0.1.dist-info/entry_points.txt", + "foo-0.1.dist-info/top_level.txt", + "foo-0.1.dist-info/RECORD", + } + assert license == "---- placeholder MIT license ----" + for line in ( + "Summary: This is a Python package", + "License: MIT", + "Classifier: Intended Audience :: Developers", + "Requires-Dist: appdirs", + "Requires-Dist: tomli (>=1) ; extra == 'all'", + "Requires-Dist: importlib ; (python_version == \"2.6\") and extra == 'all'" + ): + assert line in metadata + + assert metadata.strip().endswith("This is a ``README``") + assert epoints.strip() == "[console_scripts]\nfoo = foo.cli:main" + + def test_static_metadata_in_pyproject_config(self, tmpdir): + # Make sure static metadata in pyproject.toml is not overwritten by setup.py + # as required by PEP 621 + files = { + 'pyproject.toml': DALS(""" + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + + [project] + name = "foo" + description = "This is a Python package" + version = "42" + dependencies = ["six"] """), 'hello.py': DALS(""" def run(): print('hello') """), - }, - { 'setup.py': DALS(""" - assert __name__ == '__main__' __import__('setuptools').setup( - name='foo', - py_modules=['hello'], - setup_requires=['six'], + name='bar', + version='13', ) """), - 'hello.py': DALS(""" - def run(): - print('hello') + } + build_backend = self.get_build_backend() + with tmpdir.as_cwd(): + path.build(files) + sdist_path = build_backend.build_sdist("temp") + wheel_file = build_backend.build_wheel("temp") + + assert (tmpdir / "temp/foo-42.tar.gz").exists() + assert (tmpdir / "temp/foo-42-py3-none-any.whl").exists() + assert not (tmpdir / "temp/bar-13.tar.gz").exists() + assert not (tmpdir / "temp/bar-42.tar.gz").exists() + assert not (tmpdir / "temp/foo-13.tar.gz").exists() + assert not (tmpdir / "temp/bar-13-py3-none-any.whl").exists() + assert not (tmpdir / "temp/bar-42-py3-none-any.whl").exists() + assert not (tmpdir / "temp/foo-13-py3-none-any.whl").exists() + + with tarfile.open(os.path.join(tmpdir, "temp", sdist_path)) as tar: + pkg_info = str(tar.extractfile('foo-42/PKG-INFO').read(), "utf-8") + members = tar.getnames() + assert "bar-13/PKG-INFO" not in members + + with ZipFile(os.path.join(tmpdir, "temp", wheel_file)) as zipfile: + metadata = str(zipfile.read("foo-42.dist-info/METADATA"), "utf-8") + members = zipfile.namelist() + assert "bar-13.dist-info/METADATA" not in members + + for file in pkg_info, metadata: + for line in ("Name: foo", "Version: 42"): + assert line in file + for line in ("Name: bar", "Version: 13"): + assert line not in file + + def test_build_sdist(self, build_backend): + dist_dir = os.path.abspath('pip-sdist') + os.makedirs(dist_dir) + sdist_name = build_backend.build_sdist(dist_dir) + + assert os.path.isfile(os.path.join(dist_dir, sdist_name)) + + def test_prepare_metadata_for_build_wheel(self, build_backend): + dist_dir = os.path.abspath('pip-dist-info') + os.makedirs(dist_dir) + + dist_info = build_backend.prepare_metadata_for_build_wheel(dist_dir) + + assert os.path.isfile(os.path.join(dist_dir, dist_info, 'METADATA')) + + def test_build_sdist_explicit_dist(self, build_backend): + # explicitly specifying the dist folder should work + # the folder sdist_directory and the ``--dist-dir`` can be the same + dist_dir = os.path.abspath('dist') + sdist_name = build_backend.build_sdist(dist_dir) + assert os.path.isfile(os.path.join(dist_dir, sdist_name)) + + def test_build_sdist_version_change(self, build_backend): + sdist_into_directory = os.path.abspath("out_sdist") + os.makedirs(sdist_into_directory) + + sdist_name = build_backend.build_sdist(sdist_into_directory) + assert os.path.isfile(os.path.join(sdist_into_directory, sdist_name)) + + # if the setup.py changes subsequent call of the build meta + # should still succeed, given the + # sdist_directory the frontend specifies is empty + setup_loc = os.path.abspath("setup.py") + if not os.path.exists(setup_loc): + setup_loc = os.path.abspath("setup.cfg") + + with open(setup_loc, 'rt') as file_handler: + content = file_handler.read() + with open(setup_loc, 'wt') as file_handler: + file_handler.write( + content.replace("version='0.0.0'", "version='0.0.1'")) + + shutil.rmtree(sdist_into_directory) + os.makedirs(sdist_into_directory) + + sdist_name = build_backend.build_sdist("out_sdist") + assert os.path.isfile( + os.path.join(os.path.abspath("out_sdist"), sdist_name)) + + def test_build_sdist_pyproject_toml_exists(self, tmpdir_cwd): + files = { + 'setup.py': DALS(""" + __import__('setuptools').setup( + name='foo', + version='0.0.0', + py_modules=['hello'] + )"""), + 'hello.py': '', + 'pyproject.toml': DALS(""" + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" """), - }, - { + } + path.build(files) + build_backend = self.get_build_backend() + targz_path = build_backend.build_sdist("temp") + with tarfile.open(os.path.join("temp", targz_path)) as tar: + assert any('pyproject.toml' in name for name in tar.getnames()) + + def test_build_sdist_setup_py_exists(self, tmpdir_cwd): + # If build_sdist is called from a script other than setup.py, + # ensure setup.py is included + path.build(defns[0]) + + build_backend = self.get_build_backend() + targz_path = build_backend.build_sdist("temp") + with tarfile.open(os.path.join("temp", targz_path)) as tar: + assert any('setup.py' in name for name in tar.getnames()) + + def test_build_sdist_setup_py_manifest_excluded(self, tmpdir_cwd): + # Ensure that MANIFEST.in can exclude setup.py + files = { + 'setup.py': DALS(""" + __import__('setuptools').setup( + name='foo', + version='0.0.0', + py_modules=['hello'] + )"""), + 'hello.py': '', + 'MANIFEST.in': DALS(""" + exclude setup.py + """) + } + + path.build(files) + + build_backend = self.get_build_backend() + targz_path = build_backend.build_sdist("temp") + with tarfile.open(os.path.join("temp", targz_path)) as tar: + assert not any('setup.py' in name for name in tar.getnames()) + + def test_build_sdist_builds_targz_even_if_zip_indicated(self, tmpdir_cwd): + files = { 'setup.py': DALS(""" - variable = True - def function(): - return variable - assert variable __import__('setuptools').setup( name='foo', - py_modules=['hello'], - setup_requires=['six'], + version='0.0.0', + py_modules=['hello'] + )"""), + 'hello.py': '', + 'setup.cfg': DALS(""" + [sdist] + formats=zip + """) + } + + path.build(files) + + build_backend = self.get_build_backend() + build_backend.build_sdist("temp") + + _relative_path_import_files = { + 'setup.py': DALS(""" + __import__('setuptools').setup( + name='foo', + version=__import__('hello').__version__, + py_modules=['hello'] + )"""), + 'hello.py': '__version__ = "0.0.0"', + 'setup.cfg': DALS(""" + [sdist] + formats=zip + """) + } + + def test_build_sdist_relative_path_import(self, tmpdir_cwd): + path.build(self._relative_path_import_files) + build_backend = self.get_build_backend() + with pytest.raises(ImportError, match="^No module named 'hello'$"): + build_backend.build_sdist("temp") + + @pytest.mark.parametrize('setup_literal, requirements', [ + ("'foo'", ['foo']), + ("['foo']", ['foo']), + (r"'foo\n'", ['foo']), + (r"'foo\n\n'", ['foo']), + ("['foo', 'bar']", ['foo', 'bar']), + (r"'# Has a comment line\nfoo'", ['foo']), + (r"'foo # Has an inline comment'", ['foo']), + (r"'foo \\\n >=3.0'", ['foo>=3.0']), + (r"'foo\nbar'", ['foo', 'bar']), + (r"'foo\nbar\n'", ['foo', 'bar']), + (r"['foo\n', 'bar\n']", ['foo', 'bar']), + ]) + @pytest.mark.parametrize('use_wheel', [True, False]) + def test_setup_requires(self, setup_literal, requirements, use_wheel, + tmpdir_cwd): + + files = { + 'setup.py': DALS(""" + from setuptools import setup + + setup( + name="qux", + version="0.0.0", + py_modules=["hello"], + setup_requires={setup_literal}, ) - """), + """).format(setup_literal=setup_literal), 'hello.py': DALS(""" - def run(): - print('hello') - """), - }] + def run(): + print('hello') + """), + } + + path.build(files) + + build_backend = self.get_build_backend() + + if use_wheel: + base_requirements = ['wheel'] + get_requires = build_backend.get_requires_for_build_wheel + else: + base_requirements = [] + get_requires = build_backend.get_requires_for_build_sdist + + # Ensure that the build requirements are properly parsed + expected = sorted(base_requirements + requirements) + actual = get_requires() + + assert expected == sorted(actual) + + def test_setup_requires_with_auto_discovery(self, tmpdir_cwd): + # Make sure patches introduced to retrieve setup_requires don't accidentally + # activate auto-discovery and cause problems due to the incomplete set of + # attributes passed to MinimalDistribution + files = { + 'pyproject.toml': DALS(""" + [project] + name = "proj" + version = "42" + """), + "setup.py": DALS(""" + __import__('setuptools').setup( + setup_requires=["foo"], + py_modules = ["hello", "world"] + ) + """), + 'hello.py': "'hello'", + 'world.py': "'world'", + } + path.build(files) + build_backend = self.get_build_backend() + setup_requires = build_backend.get_requires_for_build_wheel() + assert setup_requires == ["wheel", "foo"] + + def test_dont_install_setup_requires(self, tmpdir_cwd): + files = { + 'setup.py': DALS(""" + from setuptools import setup + + setup( + name="qux", + version="0.0.0", + py_modules=["hello"], + setup_requires=["does-not-exist >99"], + ) + """), + 'hello.py': DALS(""" + def run(): + print('hello') + """), + } + + path.build(files) + + build_backend = self.get_build_backend() + dist_dir = os.path.abspath('pip-dist-info') + os.makedirs(dist_dir) -@pytest.fixture(params=defns) -def build_backend(tmpdir, request): - build_files(request.param, prefix=str(tmpdir)) - with tmpdir.as_cwd(): - yield BuildBackend(cwd='.') + # does-not-exist can't be satisfied, so if it attempts to install + # setup_requires, it will fail. + build_backend.prepare_metadata_for_build_wheel(dist_dir) + _sys_argv_0_passthrough = { + 'setup.py': DALS(""" + import os + import sys -def test_get_requires_for_build_wheel(build_backend): - actual = build_backend.get_requires_for_build_wheel() - expected = ['six', 'setuptools', 'wheel'] - assert sorted(actual) == sorted(expected) + __import__('setuptools').setup( + name='foo', + version='0.0.0', + ) + sys_argv = os.path.abspath(sys.argv[0]) + file_path = os.path.abspath('setup.py') + assert sys_argv == file_path + """) + } -def test_build_wheel(build_backend): - dist_dir = os.path.abspath('pip-wheel') - os.makedirs(dist_dir) - wheel_name = build_backend.build_wheel(dist_dir) + def test_sys_argv_passthrough(self, tmpdir_cwd): + path.build(self._sys_argv_0_passthrough) + build_backend = self.get_build_backend() + with pytest.raises(AssertionError): + build_backend.build_sdist("temp") - assert os.path.isfile(os.path.join(dist_dir, wheel_name)) + @pytest.mark.parametrize('build_hook', ('build_sdist', 'build_wheel')) + def test_build_with_empty_setuppy(self, build_backend, build_hook): + files = {'setup.py': ''} + path.build(files) + with pytest.raises( + ValueError, + match=re.escape('No distribution was found.')): + getattr(build_backend, build_hook)("temp") -def test_build_sdist(build_backend): - dist_dir = os.path.abspath('pip-sdist') - os.makedirs(dist_dir) - sdist_name = build_backend.build_sdist(dist_dir) - assert os.path.isfile(os.path.join(dist_dir, sdist_name)) +class TestBuildMetaLegacyBackend(TestBuildMetaBackend): + backend_name = 'setuptools.build_meta:__legacy__' + # build_meta_legacy-specific tests + def test_build_sdist_relative_path_import(self, tmpdir_cwd): + # This must fail in build_meta, but must pass in build_meta_legacy + path.build(self._relative_path_import_files) -def test_prepare_metadata_for_build_wheel(build_backend): - dist_dir = os.path.abspath('pip-dist-info') - os.makedirs(dist_dir) + build_backend = self.get_build_backend() + build_backend.build_sdist("temp") - dist_info = build_backend.prepare_metadata_for_build_wheel(dist_dir) + def test_sys_argv_passthrough(self, tmpdir_cwd): + path.build(self._sys_argv_0_passthrough) - assert os.path.isfile(os.path.join(dist_dir, dist_info, 'METADATA')) + build_backend = self.get_build_backend() + build_backend.build_sdist("temp") diff --git a/setuptools/tests/test_build_py.py b/setuptools/tests/test_build_py.py index cc701ae..19c8b78 100644 --- a/setuptools/tests/test_build_py.py +++ b/setuptools/tests/test_build_py.py @@ -1,17 +1,13 @@ import os +import stat +import shutil import pytest from setuptools.dist import Distribution -@pytest.yield_fixture -def tmpdir_as_cwd(tmpdir): - with tmpdir.as_cwd(): - yield tmpdir - - -def test_directories_in_package_data_glob(tmpdir_as_cwd): +def test_directories_in_package_data_glob(tmpdir_cwd): """ Directories matching the glob in package_data should not be included in the package data. @@ -22,9 +18,64 @@ def test_directories_in_package_data_glob(tmpdir_as_cwd): script_name='setup.py', script_args=['build_py'], packages=[''], - name='foo', package_data={'': ['path/*']}, )) os.makedirs('path/subpath') dist.parse_command_line() dist.run_commands() + + +def test_read_only(tmpdir_cwd): + """ + Ensure read-only flag is not preserved in copy + for package modules and package data, as that + causes problems with deleting read-only files on + Windows. + + #1451 + """ + dist = Distribution(dict( + script_name='setup.py', + script_args=['build_py'], + packages=['pkg'], + package_data={'pkg': ['data.dat']}, + )) + os.makedirs('pkg') + open('pkg/__init__.py', 'w').close() + open('pkg/data.dat', 'w').close() + os.chmod('pkg/__init__.py', stat.S_IREAD) + os.chmod('pkg/data.dat', stat.S_IREAD) + dist.parse_command_line() + dist.run_commands() + shutil.rmtree('build') + + +@pytest.mark.xfail( + 'platform.system() == "Windows"', + reason="On Windows, files do not have executable bits", + raises=AssertionError, + strict=True, +) +def test_executable_data(tmpdir_cwd): + """ + Ensure executable bit is preserved in copy for + package data, as users rely on it for scripts. + + #2041 + """ + dist = Distribution(dict( + script_name='setup.py', + script_args=['build_py'], + packages=['pkg'], + package_data={'pkg': ['run-me']}, + )) + os.makedirs('pkg') + open('pkg/__init__.py', 'w').close() + open('pkg/run-me', 'w').close() + os.chmod('pkg/run-me', 0o700) + + dist.parse_command_line() + dist.run_commands() + + assert os.stat('build/lib/pkg/run-me').st_mode & stat.S_IEXEC, \ + "Script is not executable" diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py deleted file mode 100644 index abb953a..0000000 --- a/setuptools/tests/test_config.py +++ /dev/null @@ -1,583 +0,0 @@ -import contextlib -import pytest -from distutils.errors import DistutilsOptionError, DistutilsFileError -from setuptools.dist import Distribution -from setuptools.config import ConfigHandler, read_configuration - - -class ErrConfigHandler(ConfigHandler): - """Erroneous handler. Fails to implement required methods.""" - - -def make_package_dir(name, base_dir): - dir_package = base_dir.mkdir(name) - init_file = dir_package.join('__init__.py') - init_file.write('') - return dir_package, init_file - - -def fake_env(tmpdir, setup_cfg, setup_py=None): - - if setup_py is None: - setup_py = ( - 'from setuptools import setup\n' - 'setup()\n' - ) - - tmpdir.join('setup.py').write(setup_py) - config = tmpdir.join('setup.cfg') - config.write(setup_cfg) - - package_dir, init_file = make_package_dir('fake_package', 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, {}) - 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]\n' - 'version = attr: none.VERSION\n' - 'keywords = 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_file_mixed(self, tmpdir): - - fake_env( - tmpdir, - '[metadata]\n' - 'long_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\n' - 'changelog contents\nand stuff' - ) - - def test_file_sandboxed(self, tmpdir): - - fake_env( - tmpdir, - '[metadata]\n' - 'long_description = file: ../../README\n' - ) - - with get_dist(tmpdir, 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): - - _, config = fake_env( - tmpdir, - '[metadata]\n' - 'version = attr: fake_package.VERSION\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1.2.3' - - config.write( - '[metadata]\n' - 'version = attr: fake_package.get_version\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '3.4.5.dev' - - config.write( - '[metadata]\n' - 'version = attr: fake_package.VERSION_MAJOR\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1' - - subpack = tmpdir.join('fake_package').mkdir('subpackage') - subpack.join('__init__.py').write('') - subpack.join('submodule.py').write('VERSION = (2016, 11, 26)') - - config.write( - '[metadata]\n' - 'version = attr: fake_package.subpackage.submodule.VERSION\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '2016.11.26' - - def test_unknown_meta_item(self, tmpdir): - - fake_env( - tmpdir, - '[metadata]\n' - 'name = fake_name\n' - 'unknown = 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]\n' - 'key = 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]\n' - 'classifiers = 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 - - -class TestOptions: - - def test_basic(self, tmpdir): - - fake_env( - tmpdir, - '[options]\n' - 'zip_safe = True\n' - 'use_2to3 = 1\n' - 'include_package_data = yes\n' - 'package_dir = b=c, =src\n' - 'packages = pack_a, pack_b.subpack\n' - 'namespace_packages = pack1, pack2\n' - 'use_2to3_fixers = your.fixers, or.here\n' - 'use_2to3_exclude_fixers = one.here, two.there\n' - 'convert_2to3_doctests = src/tests/one.txt, src/two.txt\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' - 'tests_require = mock==0.7.2; pytest\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' - ) - with get_dist(tmpdir) as dist: - assert dist.zip_safe - assert dist.use_2to3 - 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.use_2to3_fixers == ['your.fixers', 'or.here'] - assert dist.use_2to3_exclude_fixers == ['one.here', 'two.there'] - assert dist.convert_2to3_doctests == ([ - 'src/tests/one.txt', 'src/two.txt']) - 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.tests_require == ['mock==0.7.2', 'pytest'] - 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' - 'use_2to3_fixers = \n' - ' your.fixers\n' - ' or.here\n' - 'use_2to3_exclude_fixers = \n' - ' one.here\n' - ' two.there\n' - 'convert_2to3_doctests = \n' - ' src/tests/one.txt\n' - ' src/two.txt\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' - 'tests_require = \n' - ' mock==0.7.2\n' - ' pytest\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' - ) - with 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.use_2to3_fixers == ['your.fixers', 'or.here'] - assert dist.use_2to3_exclude_fixers == ['one.here', 'two.there'] - assert dist.convert_2to3_doctests == ( - ['src/tests/one.txt', 'src/two.txt']) - 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.tests_require == ['mock==0.7.2', 'pytest'] - - def test_package_dir_fail(self, tmpdir): - fake_env( - tmpdir, - '[options]\n' - 'package_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]\n' - 'packages = 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]\n' - 'packages = find:\n' - ) - - dir_sub_one, _ = make_package_dir('sub_one', dir_package) - dir_sub_two, _ = 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_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 dist.metadata.provides_extras == set(['pdf', 'rest']) - - 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]\n' - 'entry_points = file: entry_points\n' - ) - - with get_dist(tmpdir) as dist: - assert dist.entry_points == expected diff --git a/setuptools/tests/test_config_discovery.py b/setuptools/tests/test_config_discovery.py new file mode 100644 index 0000000..fac365f --- /dev/null +++ b/setuptools/tests/test_config_discovery.py @@ -0,0 +1,581 @@ +import os +import sys +from configparser import ConfigParser +from itertools import product + +from setuptools.command.sdist import sdist +from setuptools.dist import Distribution +from setuptools.discovery import find_package_path, find_parent_package +from setuptools.errors import PackageDiscoveryError + +import setuptools # noqa -- force distutils.core to be patched +import distutils.core + +import pytest +import jaraco.path +from path import Path as _Path + +from .contexts import quiet +from .integration.helpers import get_sdist_members, get_wheel_members, run +from .textwrap import DALS + + +class TestFindParentPackage: + def test_single_package(self, tmp_path): + # find_parent_package should find a non-namespace parent package + (tmp_path / "src/namespace/pkg/nested").mkdir(exist_ok=True, parents=True) + (tmp_path / "src/namespace/pkg/nested/__init__.py").touch() + (tmp_path / "src/namespace/pkg/__init__.py").touch() + packages = ["namespace", "namespace.pkg", "namespace.pkg.nested"] + assert find_parent_package(packages, {"": "src"}, tmp_path) == "namespace.pkg" + + def test_multiple_toplevel(self, tmp_path): + # find_parent_package should return null if the given list of packages does not + # have a single parent package + multiple = ["pkg", "pkg1", "pkg2"] + for name in multiple: + (tmp_path / f"src/{name}").mkdir(exist_ok=True, parents=True) + (tmp_path / f"src/{name}/__init__.py").touch() + assert find_parent_package(multiple, {"": "src"}, tmp_path) is None + + +class TestDiscoverPackagesAndPyModules: + """Make sure discovered values for ``packages`` and ``py_modules`` work + similarly to explicit configuration for the simple scenarios. + """ + OPTIONS = { + # Different options according to the circumstance being tested + "explicit-src": { + "package_dir": {"": "src"}, + "packages": ["pkg"] + }, + "variation-lib": { + "package_dir": {"": "lib"}, # variation of the source-layout + }, + "explicit-flat": { + "packages": ["pkg"] + }, + "explicit-single_module": { + "py_modules": ["pkg"] + }, + "explicit-namespace": { + "packages": ["ns", "ns.pkg"] + }, + "automatic-src": {}, + "automatic-flat": {}, + "automatic-single_module": {}, + "automatic-namespace": {} + } + FILES = { + "src": ["src/pkg/__init__.py", "src/pkg/main.py"], + "lib": ["lib/pkg/__init__.py", "lib/pkg/main.py"], + "flat": ["pkg/__init__.py", "pkg/main.py"], + "single_module": ["pkg.py"], + "namespace": ["ns/pkg/__init__.py"] + } + + def _get_info(self, circumstance): + _, _, layout = circumstance.partition("-") + files = self.FILES[layout] + options = self.OPTIONS[circumstance] + return files, options + + @pytest.mark.parametrize("circumstance", OPTIONS.keys()) + def test_sdist_filelist(self, tmp_path, circumstance): + files, options = self._get_info(circumstance) + _populate_project_dir(tmp_path, files, options) + + _, cmd = _run_sdist_programatically(tmp_path, options) + + manifest = [f.replace(os.sep, "/") for f in cmd.filelist.files] + for file in files: + assert any(f.endswith(file) for f in manifest) + + @pytest.mark.parametrize("circumstance", OPTIONS.keys()) + def test_project(self, tmp_path, circumstance): + files, options = self._get_info(circumstance) + _populate_project_dir(tmp_path, files, options) + + # Simulate a pre-existing `build` directory + (tmp_path / "build").mkdir() + (tmp_path / "build/lib").mkdir() + (tmp_path / "build/bdist.linux-x86_64").mkdir() + (tmp_path / "build/bdist.linux-x86_64/file.py").touch() + (tmp_path / "build/lib/__init__.py").touch() + (tmp_path / "build/lib/file.py").touch() + (tmp_path / "dist").mkdir() + (tmp_path / "dist/file.py").touch() + + _run_build(tmp_path) + + sdist_files = get_sdist_members(next(tmp_path.glob("dist/*.tar.gz"))) + print("~~~~~ sdist_members ~~~~~") + print('\n'.join(sdist_files)) + assert sdist_files >= set(files) + + wheel_files = get_wheel_members(next(tmp_path.glob("dist/*.whl"))) + print("~~~~~ wheel_members ~~~~~") + print('\n'.join(wheel_files)) + orig_files = {f.replace("src/", "").replace("lib/", "") for f in files} + assert wheel_files >= orig_files + + # Make sure build files are not included by mistake + for file in wheel_files: + assert "build" not in files + assert "dist" not in files + + PURPOSEFULLY_EMPY = { + "setup.cfg": DALS( + """ + [metadata] + name = myproj + version = 0.0.0 + + [options] + {param} = + """ + ), + "setup.py": DALS( + """ + __import__('setuptools').setup( + name="myproj", + version="0.0.0", + {param}=[] + ) + """ + ), + "pyproject.toml": DALS( + """ + [build-system] + requires = [] + build-backend = 'setuptools.build_meta' + + [project] + name = "myproj" + version = "0.0.0" + + [tool.setuptools] + {param} = [] + """ + ), + "template-pyproject.toml": DALS( + """ + [build-system] + requires = [] + build-backend = 'setuptools.build_meta' + """ + ) + } + + @pytest.mark.parametrize( + "config_file, param, circumstance", + product( + ["setup.cfg", "setup.py", "pyproject.toml"], + ["packages", "py_modules"], + FILES.keys() + ) + ) + def test_purposefully_empty(self, tmp_path, config_file, param, circumstance): + files = self.FILES[circumstance] + ["mod.py", "other.py", "src/pkg/__init__.py"] + _populate_project_dir(tmp_path, files, {}) + + if config_file == "pyproject.toml": + template_param = param.replace("_", "-") + else: + # Make sure build works with or without setup.cfg + pyproject = self.PURPOSEFULLY_EMPY["template-pyproject.toml"] + (tmp_path / "pyproject.toml").write_text(pyproject) + template_param = param + + config = self.PURPOSEFULLY_EMPY[config_file].format(param=template_param) + (tmp_path / config_file).write_text(config) + + dist = _get_dist(tmp_path, {}) + # When either parameter package or py_modules is an empty list, + # then there should be no discovery + assert getattr(dist, param) == [] + other = {"py_modules": "packages", "packages": "py_modules"}[param] + assert getattr(dist, other) is None + + @pytest.mark.parametrize( + "extra_files, pkgs", + [ + (["venv/bin/simulate_venv"], {"pkg"}), + (["pkg-stubs/__init__.pyi"], {"pkg", "pkg-stubs"}), + (["other-stubs/__init__.pyi"], {"pkg", "other-stubs"}), + ( + # Type stubs can also be namespaced + ["namespace-stubs/pkg/__init__.pyi"], + {"pkg", "namespace-stubs", "namespace-stubs.pkg"}, + ), + ( + # Just the top-level package can have `-stubs`, ignore nested ones + ["namespace-stubs/pkg-stubs/__init__.pyi"], + {"pkg", "namespace-stubs"} + ), + (["_hidden/file.py"], {"pkg"}), + (["news/finalize.py"], {"pkg"}), + ] + ) + def test_flat_layout_with_extra_files(self, tmp_path, extra_files, pkgs): + files = self.FILES["flat"] + extra_files + _populate_project_dir(tmp_path, files, {}) + dist = _get_dist(tmp_path, {}) + assert set(dist.packages) == pkgs + + @pytest.mark.parametrize( + "extra_files", + [ + ["other/__init__.py"], + ["other/finalize.py"], + ] + ) + def test_flat_layout_with_dangerous_extra_files(self, tmp_path, extra_files): + files = self.FILES["flat"] + extra_files + _populate_project_dir(tmp_path, files, {}) + with pytest.raises(PackageDiscoveryError, match="multiple (packages|modules)"): + _get_dist(tmp_path, {}) + + def test_flat_layout_with_single_module(self, tmp_path): + files = self.FILES["single_module"] + ["invalid-module-name.py"] + _populate_project_dir(tmp_path, files, {}) + dist = _get_dist(tmp_path, {}) + assert set(dist.py_modules) == {"pkg"} + + def test_flat_layout_with_multiple_modules(self, tmp_path): + files = self.FILES["single_module"] + ["valid_module_name.py"] + _populate_project_dir(tmp_path, files, {}) + with pytest.raises(PackageDiscoveryError, match="multiple (packages|modules)"): + _get_dist(tmp_path, {}) + + +class TestNoConfig: + DEFAULT_VERSION = "0.0.0" # Default version given by setuptools + + EXAMPLES = { + "pkg1": ["src/pkg1.py"], + "pkg2": ["src/pkg2/__init__.py"], + "pkg3": ["src/pkg3/__init__.py", "src/pkg3-stubs/__init__.py"], + "pkg4": ["pkg4/__init__.py", "pkg4-stubs/__init__.py"], + "ns.nested.pkg1": ["src/ns/nested/pkg1/__init__.py"], + "ns.nested.pkg2": ["ns/nested/pkg2/__init__.py"], + } + + @pytest.mark.parametrize("example", EXAMPLES.keys()) + def test_discover_name(self, tmp_path, example): + _populate_project_dir(tmp_path, self.EXAMPLES[example], {}) + dist = _get_dist(tmp_path, {}) + assert dist.get_name() == example + + def test_build_with_discovered_name(self, tmp_path): + files = ["src/ns/nested/pkg/__init__.py"] + _populate_project_dir(tmp_path, files, {}) + _run_build(tmp_path, "--sdist") + # Expected distribution file + dist_file = tmp_path / f"dist/ns.nested.pkg-{self.DEFAULT_VERSION}.tar.gz" + assert dist_file.is_file() + + +class TestWithAttrDirective: + @pytest.mark.parametrize( + "folder, opts", + [ + ("src", {}), + ("lib", {"packages": "find:", "packages.find": {"where": "lib"}}), + ] + ) + def test_setupcfg_metadata(self, tmp_path, folder, opts): + files = [f"{folder}/pkg/__init__.py", "setup.cfg"] + _populate_project_dir(tmp_path, files, opts) + (tmp_path / folder / "pkg/__init__.py").write_text("version = 42") + (tmp_path / "setup.cfg").write_text( + "[metadata]\nversion = attr: pkg.version\n" + + (tmp_path / "setup.cfg").read_text() + ) + + dist = _get_dist(tmp_path, {}) + assert dist.get_name() == "pkg" + assert dist.get_version() == "42" + assert dist.package_dir + package_path = find_package_path("pkg", dist.package_dir, tmp_path) + assert os.path.exists(package_path) + assert folder in _Path(package_path).parts() + + _run_build(tmp_path, "--sdist") + dist_file = tmp_path / "dist/pkg-42.tar.gz" + assert dist_file.is_file() + + def test_pyproject_metadata(self, tmp_path): + _populate_project_dir(tmp_path, ["src/pkg/__init__.py"], {}) + (tmp_path / "src/pkg/__init__.py").write_text("version = 42") + (tmp_path / "pyproject.toml").write_text( + "[project]\nname = 'pkg'\ndynamic = ['version']\n" + "[tool.setuptools.dynamic]\nversion = {attr = 'pkg.version'}\n" + ) + dist = _get_dist(tmp_path, {}) + assert dist.get_version() == "42" + assert dist.package_dir == {"": "src"} + + +class TestWithCExtension: + def _simulate_package_with_extension(self, tmp_path): + # This example is based on: https://github.com/nucleic/kiwi/tree/1.4.0 + files = [ + "benchmarks/file.py", + "docs/Makefile", + "docs/requirements.txt", + "docs/source/conf.py", + "proj/header.h", + "proj/file.py", + "py/proj.cpp", + "py/other.cpp", + "py/file.py", + "py/py.typed", + "py/tests/test_proj.py", + "README.rst", + ] + _populate_project_dir(tmp_path, files, {}) + + setup_script = """ + from setuptools import Extension, setup + + ext_modules = [ + Extension( + "proj", + ["py/proj.cpp", "py/other.cpp"], + include_dirs=["."], + language="c++", + ), + ] + setup(ext_modules=ext_modules) + """ + (tmp_path / "setup.py").write_text(DALS(setup_script)) + + def test_skip_discovery_with_setupcfg_metadata(self, tmp_path): + """Ensure that auto-discovery is not triggered when the project is based on + C-extensions only, for backward compatibility. + """ + self._simulate_package_with_extension(tmp_path) + + pyproject = """ + [build-system] + requires = [] + build-backend = 'setuptools.build_meta' + """ + (tmp_path / "pyproject.toml").write_text(DALS(pyproject)) + + setupcfg = """ + [metadata] + name = proj + version = 42 + """ + (tmp_path / "setup.cfg").write_text(DALS(setupcfg)) + + dist = _get_dist(tmp_path, {}) + assert dist.get_name() == "proj" + assert dist.get_version() == "42" + assert dist.py_modules is None + assert dist.packages is None + assert len(dist.ext_modules) == 1 + assert dist.ext_modules[0].name == "proj" + + def test_dont_skip_discovery_with_pyproject_metadata(self, tmp_path): + """When opting-in to pyproject.toml metadata, auto-discovery will be active if + the package lists C-extensions, but does not configure py-modules or packages. + + This way we ensure users with complex package layouts that would lead to the + discovery of multiple top-level modules/packages see errors and are forced to + explicitly set ``packages`` or ``py-modules``. + """ + self._simulate_package_with_extension(tmp_path) + + pyproject = """ + [project] + name = 'proj' + version = '42' + """ + (tmp_path / "pyproject.toml").write_text(DALS(pyproject)) + with pytest.raises(PackageDiscoveryError, match="multiple (packages|modules)"): + _get_dist(tmp_path, {}) + + +class TestWithPackageData: + def _simulate_package_with_data_files(self, tmp_path, src_root): + files = [ + f"{src_root}/proj/__init__.py", + f"{src_root}/proj/file1.txt", + f"{src_root}/proj/nested/file2.txt", + ] + _populate_project_dir(tmp_path, files, {}) + + manifest = """ + global-include *.py *.txt + """ + (tmp_path / "MANIFEST.in").write_text(DALS(manifest)) + + EXAMPLE_SETUPCFG = """ + [metadata] + name = proj + version = 42 + + [options] + include_package_data = True + """ + EXAMPLE_PYPROJECT = """ + [project] + name = "proj" + version = "42" + """ + + PYPROJECT_PACKAGE_DIR = """ + [tool.setuptools] + package-dir = {"" = "src"} + """ + + @pytest.mark.parametrize( + "src_root, files", + [ + (".", {"setup.cfg": DALS(EXAMPLE_SETUPCFG)}), + (".", {"pyproject.toml": DALS(EXAMPLE_PYPROJECT)}), + ("src", {"setup.cfg": DALS(EXAMPLE_SETUPCFG)}), + ("src", {"pyproject.toml": DALS(EXAMPLE_PYPROJECT)}), + ( + "src", + { + "setup.cfg": DALS(EXAMPLE_SETUPCFG) + DALS( + """ + packages = find: + package_dir = + =src + + [options.packages.find] + where = src + """ + ) + } + ), + ( + "src", + { + "pyproject.toml": DALS(EXAMPLE_PYPROJECT) + DALS( + """ + [tool.setuptools] + package-dir = {"" = "src"} + """ + ) + }, + ), + ] + ) + def test_include_package_data(self, tmp_path, src_root, files): + """ + Make sure auto-discovery does not affect package include_package_data. + See issue #3196. + """ + jaraco.path.build(files, prefix=str(tmp_path)) + self._simulate_package_with_data_files(tmp_path, src_root) + + expected = { + os.path.normpath(f"{src_root}/proj/file1.txt").replace(os.sep, "/"), + os.path.normpath(f"{src_root}/proj/nested/file2.txt").replace(os.sep, "/"), + } + + _run_build(tmp_path) + + sdist_files = get_sdist_members(next(tmp_path.glob("dist/*.tar.gz"))) + print("~~~~~ sdist_members ~~~~~") + print('\n'.join(sdist_files)) + assert sdist_files >= expected + + wheel_files = get_wheel_members(next(tmp_path.glob("dist/*.whl"))) + print("~~~~~ wheel_members ~~~~~") + print('\n'.join(wheel_files)) + orig_files = {f.replace("src/", "").replace("lib/", "") for f in expected} + assert wheel_files >= orig_files + + +def test_compatible_with_numpy_configuration(tmp_path): + files = [ + "dir1/__init__.py", + "dir2/__init__.py", + "file.py", + ] + _populate_project_dir(tmp_path, files, {}) + dist = Distribution({}) + dist.configuration = object() + dist.set_defaults() + assert dist.py_modules is None + assert dist.packages is None + + +def _populate_project_dir(root, files, options): + # NOTE: Currently pypa/build will refuse to build the project if no + # `pyproject.toml` or `setup.py` is found. So it is impossible to do + # completely "config-less" projects. + (root / "setup.py").write_text("import setuptools\nsetuptools.setup()") + (root / "README.md").write_text("# Example Package") + (root / "LICENSE").write_text("Copyright (c) 2018") + _write_setupcfg(root, options) + paths = (root / f for f in files) + for path in paths: + path.parent.mkdir(exist_ok=True, parents=True) + path.touch() + + +def _write_setupcfg(root, options): + if not options: + print("~~~~~ **NO** setup.cfg ~~~~~") + return + setupcfg = ConfigParser() + setupcfg.add_section("options") + for key, value in options.items(): + if key == "packages.find": + setupcfg.add_section(f"options.{key}") + setupcfg[f"options.{key}"].update(value) + elif isinstance(value, list): + setupcfg["options"][key] = ", ".join(value) + elif isinstance(value, dict): + str_value = "\n".join(f"\t{k} = {v}" for k, v in value.items()) + setupcfg["options"][key] = "\n" + str_value + else: + setupcfg["options"][key] = str(value) + with open(root / "setup.cfg", "w") as f: + setupcfg.write(f) + print("~~~~~ setup.cfg ~~~~~") + print((root / "setup.cfg").read_text()) + + +def _run_build(path, *flags): + cmd = [sys.executable, "-m", "build", "--no-isolation", *flags, str(path)] + return run(cmd, env={'DISTUTILS_DEBUG': ''}) + + +def _get_dist(dist_path, attrs): + root = "/".join(os.path.split(dist_path)) # POSIX-style + + script = dist_path / 'setup.py' + if script.exists(): + with _Path(dist_path): + dist = distutils.core.run_setup("setup.py", {}, stop_after="init") + else: + dist = Distribution(attrs) + + dist.src_root = root + dist.script_name = "setup.py" + with _Path(dist_path): + dist.parse_config_files() + + dist.set_defaults() + return dist + + +def _run_sdist_programatically(dist_path, attrs): + dist = _get_dist(dist_path, attrs) + cmd = sdist(dist) + cmd.ensure_finalized() + assert cmd.distribution.packages or cmd.distribution.py_modules + + with quiet(), _Path(dist_path): + cmd.run() + + return dist, cmd diff --git a/setuptools/tests/test_depends.py b/setuptools/tests/test_depends.py index e0cfa88..bff1dfb 100644 --- a/setuptools/tests/test_depends.py +++ b/setuptools/tests/test_depends.py @@ -5,12 +5,12 @@ from setuptools import depends class TestGetModuleConstant: - def test_basic(self): - """ - Invoke get_module_constant on a module in - the test package. - """ - mod_name = 'setuptools.tests.mod_with_constant' - val = depends.get_module_constant(mod_name, 'value') - assert val == 'three, sir!' - assert 'setuptools.tests.mod_with_constant' not in sys.modules + def test_basic(self): + """ + Invoke get_module_constant on a module in + the test package. + """ + mod_name = 'setuptools.tests.mod_with_constant' + val = depends.get_module_constant(mod_name, 'value') + assert val == 'three, sir!' + assert 'setuptools.tests.mod_with_constant' not in sys.modules diff --git a/setuptools/tests/test_develop.py b/setuptools/tests/test_develop.py index 00d4bd9..c52072a 100644 --- a/setuptools/tests/test_develop.py +++ b/setuptools/tests/test_develop.py @@ -1,19 +1,16 @@ """develop tests """ -from __future__ import absolute_import, unicode_literals - import os -import site import sys -import io import subprocess import platform +import pathlib -from setuptools.extern import six from setuptools.command import test import pytest +import pip_run.launch from setuptools.command.develop import develop from setuptools.dist import Distribution @@ -25,7 +22,6 @@ from setuptools import setup setup(name='foo', packages=['foo'], - use_2to3=True, ) """ @@ -33,7 +29,7 @@ INIT_PY = """print "foo" """ -@pytest.yield_fixture +@pytest.fixture def temp_user(monkeypatch): with contexts.tempdir() as user_base: with contexts.tempdir() as user_site: @@ -42,7 +38,7 @@ def temp_user(monkeypatch): yield -@pytest.yield_fixture +@pytest.fixture def test_env(tmpdir, temp_user): target = tmpdir foo = target.mkdir('foo') @@ -62,42 +58,6 @@ class TestDevelop: in_virtualenv = hasattr(sys, 'real_prefix') in_venv = hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix - @pytest.mark.skipif( - in_virtualenv or in_venv, - reason="Cannot run when invoked in a virtualenv or venv") - def test_2to3_user_mode(self, test_env): - settings = dict( - name='foo', - packages=['foo'], - use_2to3=True, - version='0.0', - ) - dist = Distribution(settings) - dist.script_name = 'setup.py' - cmd = develop(dist) - cmd.user = 1 - cmd.ensure_finalized() - cmd.install_dir = site.USER_SITE - cmd.user = 1 - with contexts.quiet(): - cmd.run() - - # let's see if we got our egg link at the right place - content = os.listdir(site.USER_SITE) - content.sort() - assert content == ['easy-install.pth', 'foo.egg-link'] - - # Check that we are using the right code. - fn = os.path.join(site.USER_SITE, 'foo.egg-link') - with io.open(fn) as egg_link_file: - path = egg_link_file.read().split()[0].strip() - fn = os.path.join(path, 'foo', '__init__.py') - with io.open(fn) as init_file: - init = init_file.read().strip() - - expected = 'print("foo")' if six.PY3 else 'print "foo"' - assert init == expected - def test_console_scripts(self, tmpdir): """ Test that console scripts are installed and that they reference @@ -105,7 +65,8 @@ class TestDevelop: """ pytest.skip( "TODO: needs a fixture to cause 'develop' " - "to be invoked without mutating environment.") + "to be invoked without mutating environment." + ) settings = dict( name='foo', packages=['foo'], @@ -131,6 +92,7 @@ class TestResolver: of what _resolve_setup_path is intending to do. Come up with more meaningful cases that look like real-world scenarios. """ + def test_resolve_setup_path_cwd(self): assert develop._resolve_setup_path('.', '.', '.') == '.' @@ -142,7 +104,6 @@ class TestResolver: class TestNamespaces: - @staticmethod def install_develop(src_dir, target): @@ -150,7 +111,8 @@ class TestNamespaces: sys.executable, 'setup.py', 'develop', - '--install-dir', str(target), + '--install-dir', + str(target), ] with src_dir.as_cwd(): with test.test.paths_on_pythonpath([str(target)]): @@ -161,7 +123,7 @@ class TestNamespaces: reason="https://github.com/pypa/setuptools/issues/851", ) @pytest.mark.skipif( - platform.python_implementation() == 'PyPy' and six.PY3, + platform.python_implementation() == 'PyPy', reason="https://github.com/pypa/setuptools/issues/1202", ) def test_namespace_package_importable(self, tmpdir): @@ -181,14 +143,16 @@ class TestNamespaces: 'pip', 'install', str(pkg_A), - '-t', str(target), + '-t', + str(target), ] subprocess.check_call(install_cmd) self.install_develop(pkg_B, target) namespaces.make_site_dir(target) try_import = [ sys.executable, - '-c', 'import myns.pkgA; import myns.pkgB', + '-c', + 'import myns.pkgA; import myns.pkgB', ] with test.test.paths_on_pythonpath([str(target)]): subprocess.check_call(try_import) @@ -196,7 +160,50 @@ class TestNamespaces: # additionally ensure that pkg_resources import works pkg_resources_imp = [ sys.executable, - '-c', 'import pkg_resources', + '-c', + 'import pkg_resources', ] with test.test.paths_on_pythonpath([str(target)]): subprocess.check_call(pkg_resources_imp) + + @pytest.mark.xfail( + platform.python_implementation() == 'PyPy', + reason="Workaround fails on PyPy (why?)", + ) + def test_editable_prefix(self, tmp_path, sample_project): + """ + Editable install to a prefix should be discoverable. + """ + prefix = tmp_path / 'prefix' + + # figure out where pip will likely install the package + site_packages = prefix / next( + pathlib.Path(path).relative_to(sys.prefix) + for path in sys.path + if 'site-packages' in path and path.startswith(sys.prefix) + ) + site_packages.mkdir(parents=True) + + # install workaround + pip_run.launch.inject_sitecustomize(str(site_packages)) + + env = dict(os.environ, PYTHONPATH=str(site_packages)) + cmd = [ + sys.executable, + '-m', + 'pip', + 'install', + '--editable', + str(sample_project), + '--prefix', + str(prefix), + '--no-build-isolation', + ] + subprocess.check_call(cmd, env=env) + + # now run 'sample' with the prefix on the PYTHONPATH + bin = 'Scripts' if platform.system() == 'Windows' else 'bin' + exe = prefix / bin / 'sample' + if sys.version_info < (3, 8) and platform.system() == 'Windows': + exe = str(exe) + subprocess.check_call([exe], env=env) diff --git a/setuptools/tests/test_dist.py b/setuptools/tests/test_dist.py index 5162e1c..e7d2f5c 100644 --- a/setuptools/tests/test_dist.py +++ b/setuptools/tests/test_dist.py @@ -1,15 +1,25 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals - import io - +import collections +import re +import functools +import os +import urllib.request +import urllib.parse +from distutils.errors import DistutilsSetupError +from setuptools.dist import ( + _get_unpatched, + check_package_data, + DistDeprecationWarning, + check_specifier, + rfc822_escape, + rfc822_unescape, +) +from setuptools import sic from setuptools import Distribution -from setuptools.extern.six.moves.urllib.request import pathname2url -from setuptools.extern.six.moves.urllib_parse import urljoin from .textwrap import DALS from .test_easy_install import make_nspkg_sdist +from .test_find_packages import ensure_files import pytest @@ -19,7 +29,8 @@ def test_dist_fetch_build_egg(tmpdir): Check multiple calls to `Distribution.fetch_build_egg` work as expected. """ index = tmpdir.mkdir('index') - index_url = urljoin('file://', pathname2url(str(index))) + index_url = urllib.parse.urljoin( + 'file://', urllib.request.pathname2url(str(index))) def sdist_with_index(distname, version): dist_dir = index.mkdir(distname) @@ -56,6 +67,124 @@ def test_dist_fetch_build_egg(tmpdir): assert [dist.key for dist in resolved_dists if dist] == reqs +def test_dist__get_unpatched_deprecated(): + pytest.warns(DistDeprecationWarning, _get_unpatched, [""]) + + +EXAMPLE_BASE_INFO = dict( + name="package", + version="0.0.1", + author="Foo Bar", + author_email="foo@bar.net", + long_description="Long\ndescription", + description="Short description", + keywords=["one", "two"], +) + + +def __read_test_cases(): + base = EXAMPLE_BASE_INFO + + params = functools.partial(dict, base) + + test_cases = [ + ('Metadata version 1.0', params()), + ('Metadata Version 1.0: Short long description', params( + long_description='Short long description', + )), + ('Metadata version 1.1: Classifiers', params( + classifiers=[ + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'License :: OSI Approved :: MIT License', + ], + )), + ('Metadata version 1.1: Download URL', params( + download_url='https://example.com', + )), + ('Metadata Version 1.2: Requires-Python', params( + python_requires='>=3.7', + )), + pytest.param( + 'Metadata Version 1.2: Project-Url', + params(project_urls=dict(Foo='https://example.bar')), + marks=pytest.mark.xfail( + reason="Issue #1578: project_urls not read", + ), + ), + ('Metadata Version 2.1: Long Description Content Type', params( + long_description_content_type='text/x-rst; charset=UTF-8', + )), + ('License', params(license='MIT', )), + ('License multiline', params( + license='This is a long license \nover multiple lines', + )), + pytest.param( + 'Metadata Version 2.1: Provides Extra', + params(provides_extras=['foo', 'bar']), + marks=pytest.mark.xfail(reason="provides_extras not read"), + ), + ('Missing author', dict( + name='foo', + version='1.0.0', + author_email='snorri@sturluson.name', + )), + ('Missing author e-mail', dict( + name='foo', + version='1.0.0', + author='Snorri Sturluson', + )), + ('Missing author and e-mail', dict( + name='foo', + version='1.0.0', + )), + ('Bypass normalized version', dict( + name='foo', + version=sic('1.0.0a'), + )), + ] + + return test_cases + + +@pytest.mark.parametrize('name,attrs', __read_test_cases()) +def test_read_metadata(name, attrs): + dist = Distribution(attrs) + metadata_out = dist.metadata + dist_class = metadata_out.__class__ + + # Write to PKG_INFO and then load into a new metadata object + PKG_INFO = io.StringIO() + + metadata_out.write_pkg_file(PKG_INFO) + + PKG_INFO.seek(0) + metadata_in = dist_class() + metadata_in.read_pkg_file(PKG_INFO) + + tested_attrs = [ + ('name', dist_class.get_name), + ('version', dist_class.get_version), + ('author', dist_class.get_contact), + ('author_email', dist_class.get_contact_email), + ('metadata_version', dist_class.get_metadata_version), + ('provides', dist_class.get_provides), + ('description', dist_class.get_description), + ('long_description', dist_class.get_long_description), + ('download_url', dist_class.get_download_url), + ('keywords', dist_class.get_keywords), + ('platforms', dist_class.get_platforms), + ('obsoletes', dist_class.get_obsoletes), + ('requires', dist_class.get_requires), + ('classifiers', dist_class.get_classifiers), + ('project_urls', lambda s: getattr(s, 'project_urls', {})), + ('provides_extras', lambda s: getattr(s, 'provides_extras', set())), + ] + + for attr, getter in tested_attrs: + assert getter(metadata_in) == getter(metadata_out) + + def __maintainer_test_cases(): attrs = {"name": "package", "version": "1.0", @@ -127,8 +256,8 @@ def test_maintainer_author(name, attrs, tmpdir): with io.open(str(fn.join('PKG-INFO')), 'r', encoding='utf-8') as f: raw_pkg_lines = f.readlines() - # Drop blank lines - pkg_lines = list(filter(None, raw_pkg_lines)) + # Drop blank lines and strip lines from default description + pkg_lines = list(filter(None, raw_pkg_lines[:-2])) pkg_lines_set = set(pkg_lines) @@ -143,3 +272,238 @@ def test_maintainer_author(name, attrs, tmpdir): else: line = '%s: %s' % (fkey, val) assert line in pkg_lines_set + + +def test_provides_extras_deterministic_order(): + extras = collections.OrderedDict() + extras['a'] = ['foo'] + extras['b'] = ['bar'] + attrs = dict(extras_require=extras) + dist = Distribution(attrs) + assert dist.metadata.provides_extras == ['a', 'b'] + attrs['extras_require'] = collections.OrderedDict( + reversed(list(attrs['extras_require'].items()))) + dist = Distribution(attrs) + assert dist.metadata.provides_extras == ['b', 'a'] + + +CHECK_PACKAGE_DATA_TESTS = ( + # Valid. + ({ + '': ['*.txt', '*.rst'], + 'hello': ['*.msg'], + }, None), + # Not a dictionary. + (( + ('', ['*.txt', '*.rst']), + ('hello', ['*.msg']), + ), ( + "'package_data' must be a dictionary mapping package" + " names to lists of string wildcard patterns" + )), + # Invalid key type. + ({ + 400: ['*.txt', '*.rst'], + }, ( + "keys of 'package_data' dict must be strings (got 400)" + )), + # Invalid value type. + ({ + 'hello': str('*.msg'), + }, ( + "\"values of 'package_data' dict\" " + "must be a list of strings (got '*.msg')" + )), + # Invalid value type (generators are single use) + ({ + 'hello': (x for x in "generator"), + }, ( + "\"values of 'package_data' dict\" must be a list of strings " + "(got <generator object" + )), +) + + +@pytest.mark.parametrize( + 'package_data, expected_message', CHECK_PACKAGE_DATA_TESTS) +def test_check_package_data(package_data, expected_message): + if expected_message is None: + assert check_package_data(None, 'package_data', package_data) is None + else: + with pytest.raises( + DistutilsSetupError, match=re.escape(expected_message)): + check_package_data(None, str('package_data'), package_data) + + +def test_check_specifier(): + # valid specifier value + attrs = {'name': 'foo', 'python_requires': '>=3.0, !=3.1'} + dist = Distribution(attrs) + check_specifier(dist, attrs, attrs['python_requires']) + + # invalid specifier value + attrs = {'name': 'foo', 'python_requires': ['>=3.0', '!=3.1']} + with pytest.raises(DistutilsSetupError): + dist = Distribution(attrs) + + +@pytest.mark.parametrize( + 'content, result', + ( + pytest.param( + "Just a single line", + None, + id="single_line", + ), + pytest.param( + "Multiline\nText\nwithout\nextra indents\n", + None, + id="multiline", + ), + pytest.param( + "Multiline\n With\n\nadditional\n indentation", + None, + id="multiline_with_indentation", + ), + pytest.param( + " Leading whitespace", + "Leading whitespace", + id="remove_leading_whitespace", + ), + pytest.param( + " Leading whitespace\nIn\n Multiline comment", + "Leading whitespace\nIn\n Multiline comment", + id="remove_leading_whitespace_multiline", + ), + ) +) +def test_rfc822_unescape(content, result): + assert (result or content) == rfc822_unescape(rfc822_escape(content)) + + +def test_metadata_name(): + with pytest.raises(DistutilsSetupError, match='missing.*name'): + Distribution()._validate_metadata() + + +@pytest.mark.parametrize( + "dist_name, py_module", + [ + ("my.pkg", "my_pkg"), + ("my-pkg", "my_pkg"), + ("my_pkg", "my_pkg"), + ("pkg", "pkg"), + ] +) +def test_dist_default_py_modules(tmp_path, dist_name, py_module): + (tmp_path / f"{py_module}.py").touch() + + (tmp_path / "setup.py").touch() + (tmp_path / "noxfile.py").touch() + # ^-- make sure common tool files are ignored + + attrs = { + **EXAMPLE_BASE_INFO, + "name": dist_name, + "src_root": str(tmp_path) + } + # Find `py_modules` corresponding to dist_name if not given + dist = Distribution(attrs) + dist.set_defaults() + assert dist.py_modules == [py_module] + # When `py_modules` is given, don't do anything + dist = Distribution({**attrs, "py_modules": ["explicity_py_module"]}) + dist.set_defaults() + assert dist.py_modules == ["explicity_py_module"] + # When `packages` is given, don't do anything + dist = Distribution({**attrs, "packages": ["explicity_package"]}) + dist.set_defaults() + assert not dist.py_modules + + +@pytest.mark.parametrize( + "dist_name, package_dir, package_files, packages", + [ + ("my.pkg", None, ["my_pkg/__init__.py", "my_pkg/mod.py"], ["my_pkg"]), + ("my-pkg", None, ["my_pkg/__init__.py", "my_pkg/mod.py"], ["my_pkg"]), + ("my_pkg", None, ["my_pkg/__init__.py", "my_pkg/mod.py"], ["my_pkg"]), + ("my.pkg", None, ["my/pkg/__init__.py"], ["my", "my.pkg"]), + ( + "my_pkg", + None, + ["src/my_pkg/__init__.py", "src/my_pkg2/__init__.py"], + ["my_pkg", "my_pkg2"] + ), + ( + "my_pkg", + {"pkg": "lib", "pkg2": "lib2"}, + ["lib/__init__.py", "lib/nested/__init__.pyt", "lib2/__init__.py"], + ["pkg", "pkg.nested", "pkg2"] + ), + ] +) +def test_dist_default_packages( + tmp_path, dist_name, package_dir, package_files, packages +): + ensure_files(tmp_path, package_files) + + (tmp_path / "setup.py").touch() + (tmp_path / "noxfile.py").touch() + # ^-- should not be included by default + + attrs = { + **EXAMPLE_BASE_INFO, + "name": dist_name, + "src_root": str(tmp_path), + "package_dir": package_dir + } + # Find `packages` either corresponding to dist_name or inside src + dist = Distribution(attrs) + dist.set_defaults() + assert not dist.py_modules + assert not dist.py_modules + assert set(dist.packages) == set(packages) + # When `py_modules` is given, don't do anything + dist = Distribution({**attrs, "py_modules": ["explicit_py_module"]}) + dist.set_defaults() + assert not dist.packages + assert set(dist.py_modules) == {"explicit_py_module"} + # When `packages` is given, don't do anything + dist = Distribution({**attrs, "packages": ["explicit_package"]}) + dist.set_defaults() + assert not dist.py_modules + assert set(dist.packages) == {"explicit_package"} + + +@pytest.mark.parametrize( + "dist_name, package_dir, package_files", + [ + ("my.pkg.nested", None, ["my/pkg/nested/__init__.py"]), + ("my.pkg", None, ["my/pkg/__init__.py", "my/pkg/file.py"]), + ("my_pkg", None, ["my_pkg.py"]), + ("my_pkg", None, ["my_pkg/__init__.py", "my_pkg/nested/__init__.py"]), + ("my_pkg", None, ["src/my_pkg/__init__.py", "src/my_pkg/nested/__init__.py"]), + ( + "my_pkg", + {"my_pkg": "lib", "my_pkg.lib2": "lib2"}, + ["lib/__init__.py", "lib/nested/__init__.pyt", "lib2/__init__.py"], + ), + # Should not try to guess a name from multiple py_modules/packages + ("UNKNOWN", None, ["src/mod1.py", "src/mod2.py"]), + ("UNKNOWN", None, ["src/pkg1/__ini__.py", "src/pkg2/__init__.py"]), + ] +) +def test_dist_default_name(tmp_path, dist_name, package_dir, package_files): + """Make sure dist.name is discovered from packages/py_modules""" + ensure_files(tmp_path, package_files) + attrs = { + **EXAMPLE_BASE_INFO, + "src_root": "/".join(os.path.split(tmp_path)), # POSIX-style + "package_dir": package_dir + } + del attrs["name"] + + dist = Distribution(attrs) + dist.set_defaults() + assert dist.py_modules or dist.packages + assert dist.get_name() == dist_name diff --git a/setuptools/tests/test_dist_info.py b/setuptools/tests/test_dist_info.py index f7e7d2b..29fbd09 100644 --- a/setuptools/tests/test_dist_info.py +++ b/setuptools/tests/test_dist_info.py @@ -1,10 +1,6 @@ """Test .dist-info style distributions. """ -from __future__ import unicode_literals - -from setuptools.extern.six.moves import map - import pytest import pkg_resources diff --git a/setuptools/tests/test_distutils_adoption.py b/setuptools/tests/test_distutils_adoption.py new file mode 100644 index 0000000..df8f354 --- /dev/null +++ b/setuptools/tests/test_distutils_adoption.py @@ -0,0 +1,158 @@ +import os +import sys +import functools +import platform +import textwrap + +import pytest + + +IS_PYPY = '__pypy__' in sys.builtin_module_names + + +def popen_text(call): + """ + Augment the Popen call with the parameters to ensure unicode text. + """ + return functools.partial(call, universal_newlines=True) \ + if sys.version_info < (3, 7) else functools.partial(call, text=True) + + +def win_sr(env): + """ + On Windows, SYSTEMROOT must be present to avoid + + > Fatal Python error: _Py_HashRandomization_Init: failed to + > get random numbers to initialize Python + """ + if env is None: + return + if platform.system() == 'Windows': + env['SYSTEMROOT'] = os.environ['SYSTEMROOT'] + return env + + +def find_distutils(venv, imports='distutils', env=None, **kwargs): + py_cmd = 'import {imports}; print(distutils.__file__)'.format(**locals()) + cmd = ['python', '-c', py_cmd] + return popen_text(venv.run)(cmd, env=win_sr(env), **kwargs) + + +def count_meta_path(venv, env=None): + py_cmd = textwrap.dedent( + """ + import sys + is_distutils = lambda finder: finder.__class__.__name__ == "DistutilsMetaFinder" + print(len(list(filter(is_distutils, sys.meta_path)))) + """) + cmd = ['python', '-c', py_cmd] + return int(popen_text(venv.run)(cmd, env=win_sr(env))) + + +def test_distutils_stdlib(venv): + """ + Ensure stdlib distutils is used when appropriate. + """ + env = dict(SETUPTOOLS_USE_DISTUTILS='stdlib') + assert venv.name not in find_distutils(venv, env=env).split(os.sep) + assert count_meta_path(venv, env=env) == 0 + + +def test_distutils_local_with_setuptools(venv): + """ + Ensure local distutils is used when appropriate. + """ + env = dict(SETUPTOOLS_USE_DISTUTILS='local') + loc = find_distutils(venv, imports='setuptools, distutils', env=env) + assert venv.name in loc.split(os.sep) + assert count_meta_path(venv, env=env) <= 1 + + +@pytest.mark.xfail('IS_PYPY', reason='pypy imports distutils on startup') +def test_distutils_local(venv): + """ + Even without importing, the setuptools-local copy of distutils is + preferred. + """ + env = dict(SETUPTOOLS_USE_DISTUTILS='local') + assert venv.name in find_distutils(venv, env=env).split(os.sep) + assert count_meta_path(venv, env=env) <= 1 + + +def test_pip_import(venv): + """ + Ensure pip can be imported. + Regression test for #3002. + """ + cmd = ['python', '-c', 'import pip'] + popen_text(venv.run)(cmd) + + +def test_distutils_has_origin(): + """ + Distutils module spec should have an origin. #2990. + """ + assert __import__('distutils').__spec__.origin + + +ENSURE_IMPORTS_ARE_NOT_DUPLICATED = r""" +# Depending on the importlib machinery and _distutils_hack, some imports are +# duplicated resulting in different module objects being loaded, which prevents +# patches as shown in #3042. +# This script provides a way of verifying if this duplication is happening. + +from distutils import cmd +import distutils.command.sdist as sdist + +# import last to prevent caching +from distutils import {imported_module} + +for mod in (cmd, sdist): + assert mod.{imported_module} == {imported_module}, ( + f"\n{{mod.dir_util}}\n!=\n{{{imported_module}}}" + ) + +print("success") +""" + + +@pytest.mark.parametrize( + "distutils_version, imported_module", + [ + ("stdlib", "dir_util"), + ("stdlib", "file_util"), + ("stdlib", "archive_util"), + ("local", "dir_util"), + ("local", "file_util"), + ("local", "archive_util"), + ] +) +def test_modules_are_not_duplicated_on_import( + distutils_version, imported_module, tmpdir_cwd, venv +): + env = dict(SETUPTOOLS_USE_DISTUTILS=distutils_version) + script = ENSURE_IMPORTS_ARE_NOT_DUPLICATED.format(imported_module=imported_module) + cmd = ['python', '-c', script] + output = popen_text(venv.run)(cmd, env=win_sr(env)).strip() + assert output == "success" + + +ENSURE_LOG_IMPORT_IS_NOT_DUPLICATED = r""" +# Similar to ENSURE_IMPORTS_ARE_NOT_DUPLICATED +import distutils.dist as dist +from distutils import log + +assert dist.log == log, ( + f"\n{dist.log}\n!=\n{log}" +) + +print("success") +""" + + +@pytest.mark.parametrize("distutils_version", "local stdlib".split()) +def test_log_module_is_not_duplicated_on_import(distutils_version, tmpdir_cwd, venv): + env = dict(SETUPTOOLS_USE_DISTUTILS=distutils_version) + cmd = ['python', '-c', ENSURE_LOG_IMPORT_IS_NOT_DUPLICATED] + output = popen_text(venv.run)(cmd, env=win_sr(env)).strip() + assert output == "success" diff --git a/setuptools/tests/test_easy_install.py b/setuptools/tests/test_easy_install.py index 57339c8..5831b26 100644 --- a/setuptools/tests/test_easy_install.py +++ b/setuptools/tests/test_easy_install.py @@ -1,7 +1,5 @@ -# -*- coding: utf-8 -*- """Easy install Tests """ -from __future__ import absolute_import import sys import os @@ -15,21 +13,27 @@ import distutils.errors import io import zipfile import mock - import time -from setuptools.extern.six.moves import urllib +import re +import subprocess +import pathlib +import warnings +from collections import namedtuple import pytest +from jaraco import path from setuptools import sandbox from setuptools.sandbox import run_setup import setuptools.command.easy_install as ei -from setuptools.command.easy_install import PthDistributions -from setuptools.command import easy_install as easy_install_pkg +from setuptools.command.easy_install import ( + EasyInstallDeprecationWarning, ScriptWriter, PthDistributions, + WindowsScriptWriter, +) from setuptools.dist import Distribution from pkg_resources import normalize_path, working_set from pkg_resources import Distribution as PRDistribution -import setuptools.tests.server +from setuptools.tests.server import MockServer, path_to_url from setuptools.tests import fail_on_ascii import pkg_resources @@ -37,11 +41,21 @@ from . import contexts from .textwrap import DALS -class FakeDist(object): +@pytest.fixture(autouse=True) +def pip_disable_index(monkeypatch): + """ + Important: Disable the default index for pip to avoid + querying packages in the index and potentially resolving + and installing packages there. + """ + monkeypatch.setenv('PIP_NO_INDEX', 'true') + + +class FakeDist: def get_entry_map(self, group): if group != 'console_scripts': return {} - return {'name': 'ep'} + return {str('name'): 'ep'} def as_requirement(self): return 'spec' @@ -50,40 +64,22 @@ class FakeDist(object): SETUP_PY = DALS(""" from setuptools import setup - setup(name='foo') + setup() """) class TestEasyInstallTest: - def test_install_site_py(self, tmpdir): - dist = Distribution() - cmd = ei.easy_install(dist) - cmd.sitepy_installed = False - cmd.install_dir = str(tmpdir) - cmd.install_site_py() - assert (tmpdir / 'site.py').exists() - def test_get_script_args(self): header = ei.CommandSpec.best().from_environment().as_header() - expected = header + DALS(r""" - # EASY-INSTALL-ENTRY-SCRIPT: 'spec','console_scripts','name' - __requires__ = 'spec' - import re - import sys - from pkg_resources import load_entry_point - - if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit( - load_entry_point('spec', 'console_scripts', 'name')() - ) - """) dist = FakeDist() - args = next(ei.ScriptWriter.get_args(dist)) name, script = itertools.islice(args, 2) - - assert script == expected + assert script.startswith(header) + assert "'spec'" in script + assert "'console_scripts'" in script + assert "'name'" in script + assert re.search( + '^# EASY-INSTALL-ENTRY-SCRIPT', script, flags=re.MULTILINE) def test_no_find_links(self): # new option '--no-find-links', that blocks find-links added at @@ -124,7 +120,9 @@ class TestEasyInstallTest: site.getsitepackages. """ path = normalize_path('/setuptools/test/site-packages') - mock_gsp = lambda: [path] + + def mock_gsp(): + return [path] monkeypatch.setattr(site, 'getsitepackages', mock_gsp, raising=False) assert path in ei.get_site_dirs() @@ -152,7 +150,7 @@ class TestEasyInstallTest: "", ), ( - u'mypkg/\u2603.txt', + 'mypkg/☃.txt', "", ), ] @@ -167,7 +165,8 @@ class TestEasyInstallTest: return str(sdist) @fail_on_ascii - def test_unicode_filename_in_sdist(self, sdist_unicode, tmpdir, monkeypatch): + def test_unicode_filename_in_sdist( + self, sdist_unicode, tmpdir, monkeypatch): """ The install command should execute correctly even if the package has unicode filenames. @@ -184,6 +183,59 @@ class TestEasyInstallTest: cmd.easy_install(sdist_unicode) @pytest.fixture + def sdist_unicode_in_script(self, tmpdir): + files = [ + ( + "setup.py", + DALS(""" + import setuptools + setuptools.setup( + name="setuptools-test-unicode", + version="1.0", + packages=["mypkg"], + include_package_data=True, + scripts=['mypkg/unicode_in_script'], + ) + """), + ), + ("mypkg/__init__.py", ""), + ( + "mypkg/unicode_in_script", + DALS( + """ + #!/bin/sh + # á + + non_python_fn() { + } + """), + ), + ] + sdist_name = "setuptools-test-unicode-script-1.0.zip" + sdist = tmpdir / sdist_name + # can't use make_sdist, because the issue only occurs + # with zip sdists. + sdist_zip = zipfile.ZipFile(str(sdist), "w") + for filename, content in files: + sdist_zip.writestr(filename, content.encode('utf-8')) + sdist_zip.close() + return str(sdist) + + @fail_on_ascii + def test_unicode_content_in_sdist( + self, sdist_unicode_in_script, tmpdir, monkeypatch): + """ + The install command should execute correctly even if + the package has unicode in scripts. + """ + dist = Distribution({"script_args": ["easy_install"]}) + target = (tmpdir / "target").ensure_dir() + cmd = ei.easy_install(dist, install_dir=str(target), args=["x"]) + monkeypatch.setitem(os.environ, "PYTHONPATH", str(target)) + cmd.ensure_finalized() + cmd.easy_install(sdist_unicode_in_script) + + @pytest.fixture def sdist_script(self, tmpdir): files = [ ( @@ -198,7 +250,7 @@ class TestEasyInstallTest: """), ), ( - u'mypkg_script', + 'mypkg_script', DALS(""" #/usr/bin/python print('mypkg_script') @@ -228,7 +280,24 @@ class TestEasyInstallTest: cmd.easy_install(sdist_script) assert (target / 'mypkg_script').exists() + def test_dist_get_script_args_deprecated(self): + with pytest.warns(EasyInstallDeprecationWarning): + ScriptWriter.get_script_args(None, None) + + def test_dist_get_script_header_deprecated(self): + with pytest.warns(EasyInstallDeprecationWarning): + ScriptWriter.get_script_header("") + def test_dist_get_writer_deprecated(self): + with pytest.warns(EasyInstallDeprecationWarning): + ScriptWriter.get_writer(None) + + def test_dist_WindowsScriptWriter_get_writer_deprecated(self): + with pytest.warns(EasyInstallDeprecationWarning): + WindowsScriptWriter.get_writer() + + +@pytest.mark.filterwarnings('ignore:Unbuilt egg') class TestPTHFileWriter: def test_add_from_cwd_site_sets_dirty(self): '''a pth file manager should set dirty @@ -249,7 +318,7 @@ class TestPTHFileWriter: assert not pth.dirty -@pytest.yield_fixture +@pytest.fixture def setup_context(tmpdir): with (tmpdir / 'setup.py').open('w') as f: f.write(SETUP_PY) @@ -305,7 +374,7 @@ class TestUserInstallTest: f.write('Name: foo\n') return str(tmpdir) - @pytest.yield_fixture() + @pytest.fixture() def install_target(self, tmpdir): target = str(tmpdir) with mock.patch('sys.path', sys.path + [target]): @@ -350,7 +419,7 @@ class TestUserInstallTest: ) -@pytest.yield_fixture +@pytest.fixture def distutils_package(): distutils_setup_py = SETUP_PY.replace( 'from setuptools import setup', @@ -362,48 +431,51 @@ def distutils_package(): yield +@pytest.fixture +def mock_index(): + # set up a server which will simulate an alternate package index. + p_index = MockServer() + if p_index.server_port == 0: + # Some platforms (Jython) don't find a port to which to bind, + # so skip test for them. + pytest.skip("could not find a valid port") + p_index.start() + return p_index + + class TestDistutilsPackage: def test_bdist_egg_available_on_distutils_pkg(self, distutils_package): run_setup('setup.py', ['bdist_egg']) class TestSetupRequires: - def test_setup_requires_honors_fetch_params(self): + + def test_setup_requires_honors_fetch_params(self, mock_index, monkeypatch): """ When easy_install installs a source distribution which specifies setup_requires, it should honor the fetch parameters (such as - allow-hosts, index-url, and find-links). + index-url, and find-links). """ - # set up a server which will simulate an alternate package index. - p_index = setuptools.tests.server.MockServer() - p_index.start() - netloc = 1 - p_index_loc = urllib.parse.urlparse(p_index.url)[netloc] - if p_index_loc.endswith(':0'): - # Some platforms (Jython) don't find a port to which to bind, - # so skip this test for them. - return + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv('PIP_NO_INDEX', 'false') with contexts.quiet(): # create an sdist that has a build-time dependency. with TestSetupRequires.create_sdist() as dist_file: with contexts.tempdir() as temp_install_dir: with contexts.environment(PYTHONPATH=temp_install_dir): - ei_params = [ - '--index-url', p_index.url, - '--allow-hosts', p_index_loc, + cmd = [ + sys.executable, + '-m', 'setup', + 'easy_install', + '--index-url', mock_index.url, '--exclude-scripts', '--install-dir', temp_install_dir, dist_file, ] - with sandbox.save_argv(['easy_install']): - # attempt to install the dist. It should fail because - # it doesn't exist. - with pytest.raises(SystemExit): - easy_install_pkg.main(ei_params) - # there should have been two or three requests to the server - # (three happens on Python 3.3a) - assert 2 <= len(p_index.requests) <= 3 - assert p_index.requests[0].path == '/does-not-exist/' + subprocess.Popen(cmd).wait() + # there should have been one requests to the server + assert [r.path for r in mock_index.requests] == ['/does-not-exist/'] @staticmethod @contextlib.contextmanager @@ -422,7 +494,9 @@ class TestSetupRequires: version="1.0", setup_requires = ['does-not-exist'], ) - """))]) + """)), + ('setup.cfg', ''), + ]) yield dist_path use_setup_cfg = ( @@ -449,12 +523,13 @@ class TestSetupRequires: with contexts.save_pkg_resources_state(): with contexts.tempdir() as temp_dir: - test_pkg = create_setup_requires_package(temp_dir, use_setup_cfg=use_setup_cfg) + test_pkg = create_setup_requires_package( + temp_dir, use_setup_cfg=use_setup_cfg) test_setup_py = os.path.join(test_pkg, 'setup.py') with contexts.quiet() as (stdout, stderr): # Don't even need to install the package, just # running the setup.py at all is sufficient - run_setup(test_setup_py, ['--name']) + run_setup(test_setup_py, [str('--name')]) lines = stdout.readlines() assert len(lines) > 0 @@ -508,9 +583,10 @@ class TestSetupRequires: try: # Don't even need to install the package, just # running the setup.py at all is sufficient - run_setup(test_setup_py, ['--name']) + run_setup(test_setup_py, [str('--name')]) except pkg_resources.VersionConflict: - self.fail('Installing setup.py requirements ' + self.fail( + 'Installing setup.py requirements ' 'caused a VersionConflict') assert 'FAIL' not in stdout.getvalue() @@ -521,35 +597,236 @@ class TestSetupRequires: @pytest.mark.parametrize('use_setup_cfg', use_setup_cfg) def test_setup_requires_with_attr_version(self, use_setup_cfg): def make_dependency_sdist(dist_path, distname, version): - make_sdist(dist_path, [ - ('setup.py', - DALS(""" - import setuptools - setuptools.setup( - name={name!r}, - version={version!r}, - py_modules=[{name!r}], - ) - """.format(name=distname, version=version))), - (distname + '.py', - DALS(""" - version = 42 - """ - ))]) + files = [( + 'setup.py', + DALS(""" + import setuptools + setuptools.setup( + name={name!r}, + version={version!r}, + py_modules=[{name!r}], + ) + """.format(name=distname, version=version)), + ), ( + distname + '.py', + DALS(""" + version = 42 + """), + )] + make_sdist(dist_path, files) with contexts.save_pkg_resources_state(): with contexts.tempdir() as temp_dir: test_pkg = create_setup_requires_package( temp_dir, setup_attrs=dict(version='attr: foobar.version'), make_package=make_dependency_sdist, - use_setup_cfg=use_setup_cfg+('version',), + use_setup_cfg=use_setup_cfg + ('version',), ) test_setup_py = os.path.join(test_pkg, 'setup.py') with contexts.quiet() as (stdout, stderr): - run_setup(test_setup_py, ['--version']) + run_setup(test_setup_py, [str('--version')]) lines = stdout.readlines() assert len(lines) > 0 assert lines[-1].strip() == '42' + def test_setup_requires_honors_pip_env(self, mock_index, monkeypatch): + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv('PIP_NO_INDEX', 'false') + monkeypatch.setenv(str('PIP_INDEX_URL'), mock_index.url) + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + test_pkg = create_setup_requires_package( + temp_dir, 'python-xlib', '0.19', + setup_attrs=dict(dependency_links=[])) + test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') + with open(test_setup_cfg, 'w') as fp: + fp.write(DALS( + ''' + [easy_install] + index_url = https://pypi.org/legacy/ + ''')) + test_setup_py = os.path.join(test_pkg, 'setup.py') + with pytest.raises(distutils.errors.DistutilsError): + run_setup(test_setup_py, [str('--version')]) + assert len(mock_index.requests) == 1 + assert mock_index.requests[0].path == '/python-xlib/' + + def test_setup_requires_with_pep508_url(self, mock_index, monkeypatch): + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv(str('PIP_INDEX_URL'), mock_index.url) + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + dep_sdist = os.path.join(temp_dir, 'dep.tar.gz') + make_trivial_sdist(dep_sdist, 'dependency', '42') + dep_url = path_to_url(dep_sdist, authority='localhost') + test_pkg = create_setup_requires_package( + temp_dir, + # Ignored (overridden by setup_attrs) + 'python-xlib', '0.19', + setup_attrs=dict( + setup_requires='dependency @ %s' % dep_url)) + test_setup_py = os.path.join(test_pkg, 'setup.py') + run_setup(test_setup_py, [str('--version')]) + assert len(mock_index.requests) == 0 + + def test_setup_requires_with_allow_hosts(self, mock_index): + ''' The `allow-hosts` option in not supported anymore. ''' + files = { + 'test_pkg': { + 'setup.py': DALS(''' + from setuptools import setup + setup(setup_requires='python-xlib') + '''), + 'setup.cfg': DALS(''' + [easy_install] + allow_hosts = * + '''), + } + } + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + path.build(files, prefix=temp_dir) + setup_py = str(pathlib.Path(temp_dir, 'test_pkg', 'setup.py')) + with pytest.raises(distutils.errors.DistutilsError): + run_setup(setup_py, [str('--version')]) + assert len(mock_index.requests) == 0 + + def test_setup_requires_with_python_requires(self, monkeypatch, tmpdir): + ''' Check `python_requires` is honored. ''' + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + monkeypatch.setenv(str('PIP_NO_INDEX'), str('1')) + monkeypatch.setenv(str('PIP_VERBOSE'), str('1')) + dep_1_0_sdist = 'dep-1.0.tar.gz' + dep_1_0_url = path_to_url(str(tmpdir / dep_1_0_sdist)) + dep_1_0_python_requires = '>=2.7' + make_python_requires_sdist( + str(tmpdir / dep_1_0_sdist), 'dep', '1.0', dep_1_0_python_requires) + dep_2_0_sdist = 'dep-2.0.tar.gz' + dep_2_0_url = path_to_url(str(tmpdir / dep_2_0_sdist)) + dep_2_0_python_requires = '!=' + '.'.join( + map(str, sys.version_info[:2])) + '.*' + make_python_requires_sdist( + str(tmpdir / dep_2_0_sdist), 'dep', '2.0', dep_2_0_python_requires) + index = tmpdir / 'index.html' + index.write_text(DALS( + ''' + <!DOCTYPE html> + <html><head><title>Links for dep</title></head> + <body> + <h1>Links for dep</h1> + <a href="{dep_1_0_url}" data-requires-python="{dep_1_0_python_requires}">{dep_1_0_sdist}</a><br/> + <a href="{dep_2_0_url}" data-requires-python="{dep_2_0_python_requires}">{dep_2_0_sdist}</a><br/> + </body> + </html> + ''').format( # noqa + dep_1_0_url=dep_1_0_url, + dep_1_0_sdist=dep_1_0_sdist, + dep_1_0_python_requires=dep_1_0_python_requires, + dep_2_0_url=dep_2_0_url, + dep_2_0_sdist=dep_2_0_sdist, + dep_2_0_python_requires=dep_2_0_python_requires, + ), 'utf-8') + index_url = path_to_url(str(index)) + with contexts.save_pkg_resources_state(): + test_pkg = create_setup_requires_package( + str(tmpdir), + 'python-xlib', '0.19', # Ignored (overridden by setup_attrs). + setup_attrs=dict( + setup_requires='dep', dependency_links=[index_url])) + test_setup_py = os.path.join(test_pkg, 'setup.py') + run_setup(test_setup_py, [str('--version')]) + eggs = list(map(str, pkg_resources.find_distributions( + os.path.join(test_pkg, '.eggs')))) + assert eggs == ['dep 1.0'] + + @pytest.mark.parametrize( + 'with_dependency_links_in_setup_py', + (False, True)) + def test_setup_requires_with_find_links_in_setup_cfg( + self, monkeypatch, + with_dependency_links_in_setup_py): + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + make_trivial_sdist( + os.path.join(temp_dir, 'python-xlib-42.tar.gz'), + 'python-xlib', + '42') + test_pkg = os.path.join(temp_dir, 'test_pkg') + test_setup_py = os.path.join(test_pkg, 'setup.py') + test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') + os.mkdir(test_pkg) + with open(test_setup_py, 'w') as fp: + if with_dependency_links_in_setup_py: + dependency_links = [os.path.join(temp_dir, 'links')] + else: + dependency_links = [] + fp.write(DALS( + ''' + from setuptools import installer, setup + setup(setup_requires='python-xlib==42', + dependency_links={dependency_links!r}) + ''').format( + dependency_links=dependency_links)) + with open(test_setup_cfg, 'w') as fp: + fp.write(DALS( + ''' + [easy_install] + index_url = {index_url} + find_links = {find_links} + ''').format(index_url=os.path.join(temp_dir, 'index'), + find_links=temp_dir)) + run_setup(test_setup_py, [str('--version')]) + + def test_setup_requires_with_transitive_extra_dependency( + self, monkeypatch): + # Use case: installing a package with a build dependency on + # an already installed `dep[extra]`, which in turn depends + # on `extra_dep` (whose is not already installed). + with contexts.save_pkg_resources_state(): + with contexts.tempdir() as temp_dir: + # Create source distribution for `extra_dep`. + make_trivial_sdist( + os.path.join(temp_dir, 'extra_dep-1.0.tar.gz'), + 'extra_dep', '1.0') + # Create source tree for `dep`. + dep_pkg = os.path.join(temp_dir, 'dep') + os.mkdir(dep_pkg) + path.build({ + 'setup.py': + DALS(""" + import setuptools + setuptools.setup( + name='dep', version='2.0', + extras_require={'extra': ['extra_dep']}, + ) + """), + 'setup.cfg': '', + }, prefix=dep_pkg) + # "Install" dep. + run_setup( + os.path.join(dep_pkg, 'setup.py'), [str('dist_info')]) + working_set.add_entry(dep_pkg) + # Create source tree for test package. + test_pkg = os.path.join(temp_dir, 'test_pkg') + test_setup_py = os.path.join(test_pkg, 'setup.py') + os.mkdir(test_pkg) + with open(test_setup_py, 'w') as fp: + fp.write(DALS( + ''' + from setuptools import installer, setup + setup(setup_requires='dep[extra]') + ''')) + # Check... + monkeypatch.setenv(str('PIP_FIND_LINKS'), str(temp_dir)) + monkeypatch.setenv(str('PIP_NO_INDEX'), str('1')) + monkeypatch.setenv(str('PIP_RETRIES'), str('0')) + monkeypatch.setenv(str('PIP_TIMEOUT'), str('0')) + run_setup(test_setup_py, [str('--version')]) + def make_trivial_sdist(dist_path, distname, version): """ @@ -565,7 +842,9 @@ def make_trivial_sdist(dist_path, distname, version): name=%r, version=%r ) - """ % (distname, version)))]) + """ % (distname, version))), + ('setup.cfg', ''), + ]) def make_nspkg_sdist(dist_path, distname, version): @@ -601,12 +880,32 @@ def make_nspkg_sdist(dist_path, distname, version): make_sdist(dist_path, files) +def make_python_requires_sdist(dist_path, distname, version, python_requires): + make_sdist(dist_path, [ + ( + 'setup.py', + DALS("""\ + import setuptools + setuptools.setup( + name={name!r}, + version={version!r}, + python_requires={python_requires!r}, + ) + """).format( + name=distname, version=version, + python_requires=python_requires)), + ('setup.cfg', ''), + ]) + + def make_sdist(dist_path, files): """ Create a simple sdist tarball at dist_path, containing the files listed in ``files`` as ``(filename, content)`` tuples. """ + # Distributions with only one file don't play well with pip. + assert len(files) > 1 with tarfile.open(dist_path, 'w:gz') as dist: for filename, content in files: file_bytes = io.BytesIO(content.encode('utf-8')) @@ -639,8 +938,8 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', test_pkg = os.path.join(path, 'test_pkg') os.mkdir(test_pkg) + # setup.cfg if use_setup_cfg: - test_setup_cfg = os.path.join(test_pkg, 'setup.cfg') options = [] metadata = [] for name in use_setup_cfg: @@ -652,28 +951,29 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', if isinstance(value, (tuple, list)): value = ';'.join(value) section.append('%s: %s' % (name, value)) - with open(test_setup_cfg, 'w') as f: - f.write(DALS( - """ - [metadata] - {metadata} - [options] - {options} - """ - ).format( - options='\n'.join(options), - metadata='\n'.join(metadata), - )) - - test_setup_py = os.path.join(test_pkg, 'setup.py') + test_setup_cfg_contents = DALS( + """ + [metadata] + {metadata} + [options] + {options} + """ + ).format( + options='\n'.join(options), + metadata='\n'.join(metadata), + ) + else: + test_setup_cfg_contents = '' + with open(os.path.join(test_pkg, 'setup.cfg'), 'w') as f: + f.write(test_setup_cfg_contents) + # setup.py if setup_py_template is None: setup_py_template = DALS("""\ import setuptools setuptools.setup(**%r) """) - - with open(test_setup_py, 'w') as f: + with open(os.path.join(test_pkg, 'setup.py'), 'w') as f: f.write(setup_py_template % test_setup_attrs) foobar_path = os.path.join(path, '%s-%s.tar.gz' % (distname, version)) @@ -688,27 +988,29 @@ def create_setup_requires_package(path, distname='foobar', version='0.1', ) class TestScriptHeader: non_ascii_exe = '/Users/José/bin/python' - exe_with_spaces = r'C:\Program Files\Python33\python.exe' + exe_with_spaces = r'C:\Program Files\Python36\python.exe' def test_get_script_header(self): expected = '#!%s\n' % ei.nt_quote_arg(os.path.normpath(sys.executable)) - actual = ei.ScriptWriter.get_script_header('#!/usr/local/bin/python') + actual = ei.ScriptWriter.get_header('#!/usr/local/bin/python') assert actual == expected def test_get_script_header_args(self): - expected = '#!%s -x\n' % ei.nt_quote_arg(os.path.normpath - (sys.executable)) - actual = ei.ScriptWriter.get_script_header('#!/usr/bin/python -x') + expected = '#!%s -x\n' % ei.nt_quote_arg( + os.path.normpath(sys.executable)) + actual = ei.ScriptWriter.get_header('#!/usr/bin/python -x') assert actual == expected def test_get_script_header_non_ascii_exe(self): - actual = ei.ScriptWriter.get_script_header('#!/usr/bin/python', + actual = ei.ScriptWriter.get_header( + '#!/usr/bin/python', executable=self.non_ascii_exe) - expected = '#!%s -x\n' % self.non_ascii_exe + expected = str('#!%s -x\n') % self.non_ascii_exe assert actual == expected def test_get_script_header_exe_with_spaces(self): - actual = ei.ScriptWriter.get_script_header('#!/usr/bin/python', + actual = ei.ScriptWriter.get_header( + '#!/usr/bin/python', executable='"' + self.exe_with_spaces + '"') expected = '#!"%s"\n' % self.exe_with_spaces assert actual == expected @@ -751,10 +1053,59 @@ class TestCommandSpec: class TestWindowsScriptWriter: def test_header(self): - hdr = ei.WindowsScriptWriter.get_script_header('') + hdr = ei.WindowsScriptWriter.get_header('') assert hdr.startswith('#!') assert hdr.endswith('\n') hdr = hdr.lstrip('#!') hdr = hdr.rstrip('\n') # header should not start with an escaped quote assert not hdr.startswith('\\"') + + +VersionStub = namedtuple("VersionStub", "major, minor, micro, releaselevel, serial") + + +def test_use_correct_python_version_string(tmpdir, tmpdir_cwd, monkeypatch): + # In issue #3001, easy_install wrongly uses the `python3.1` directory + # when the interpreter is `python3.10` and the `--user` option is given. + # See pypa/setuptools#3001. + dist = Distribution() + cmd = dist.get_command_obj('easy_install') + cmd.args = ['ok'] + cmd.optimize = 0 + cmd.user = True + cmd.install_userbase = str(tmpdir) + cmd.install_usersite = None + install_cmd = dist.get_command_obj('install') + install_cmd.install_userbase = str(tmpdir) + install_cmd.install_usersite = None + + with monkeypatch.context() as patch, warnings.catch_warnings(): + warnings.simplefilter("ignore") + version = '3.10.1 (main, Dec 21 2021, 09:17:12) [GCC 10.2.1 20210110]' + info = VersionStub(3, 10, 1, "final", 0) + patch.setattr('site.ENABLE_USER_SITE', True) + patch.setattr('sys.version', version) + patch.setattr('sys.version_info', info) + patch.setattr(cmd, 'create_home_path', mock.Mock()) + cmd.finalize_options() + + name = "pypy" if hasattr(sys, 'pypy_version_info') else "python" + install_dir = cmd.install_dir.lower() + + # In some platforms (e.g. Windows), install_dir is mostly determined + # via `sysconfig`, which define constants eagerly at module creation. + # This means that monkeypatching `sys.version` to emulate 3.10 for testing + # may have no effect. + # The safest test here is to rely on the fact that 3.1 is no longer + # supported/tested, and make sure that if 'python3.1' ever appears in the string + # it is followed by another digit (e.g. 'python3.10'). + if re.search(name + r'3\.?1', install_dir): + assert re.search(name + r'3\.?1\d', install_dir) + + # The following "variables" are used for interpolation in distutils + # installation schemes, so it should be fair to treat them as "semi-public", + # or at least public enough so we can have a test to make sure they are correct + assert cmd.config_vars['py_version'] == '3.10.1' + assert cmd.config_vars['py_version_short'] == '3.10' + assert cmd.config_vars['py_version_nodot'] == '310' diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py new file mode 100644 index 0000000..aac4f5e --- /dev/null +++ b/setuptools/tests/test_editable_install.py @@ -0,0 +1,113 @@ +import subprocess +from textwrap import dedent + +import pytest +import jaraco.envs +import path + + +@pytest.fixture +def venv(tmp_path, setuptools_wheel): + env = jaraco.envs.VirtualEnv() + vars(env).update( + root=path.Path(tmp_path), # workaround for error on windows + name=".venv", + create_opts=["--no-setuptools"], + req=str(setuptools_wheel), + ) + return env.create() + + +EXAMPLE = { + 'pyproject.toml': dedent("""\ + [build-system] + requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + + [project] + name = "mypkg" + version = "3.14159" + license = {text = "MIT"} + description = "This is a Python package" + dynamic = ["readme"] + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers" + ] + urls = {Homepage = "http://github.com"} + dependencies = ['importlib-metadata; python_version<"3.8"'] + + [tool.setuptools] + package-dir = {"" = "src"} + packages = {find = {where = ["src"]}} + license-files = ["LICENSE*"] + + [tool.setuptools.dynamic] + readme = {file = "README.rst"} + + [tool.distutils.egg_info] + tag-build = ".post0" + """), + "MANIFEST.in": dedent("""\ + global-include *.py *.txt + global-exclude *.py[cod] + """).strip(), + "README.rst": "This is a ``README``", + "LICENSE.txt": "---- placeholder MIT license ----", + "src": { + "mypkg": { + "__init__.py": dedent("""\ + import sys + + if sys.version_info[:2] >= (3, 8): + from importlib.metadata import PackageNotFoundError, version + else: + from importlib_metadata import PackageNotFoundError, version + + try: + __version__ = version(__name__) + except PackageNotFoundError: + __version__ = "unknown" + """), + "__main__.py": dedent("""\ + from importlib.resources import read_text + from . import __version__, __name__ as parent + from .mod import x + + data = read_text(parent, "data.txt") + print(__version__, data, x) + """), + "mod.py": "x = ''", + "data.txt": "Hello World", + } + } +} + + +SETUP_SCRIPT_STUB = "__import__('setuptools').setup()" +MISSING_SETUP_SCRIPT = pytest.param( + None, + marks=pytest.mark.xfail( + reason="Editable install is currently only supported with `setup.py`" + ) +) + + +@pytest.mark.parametrize("setup_script", [SETUP_SCRIPT_STUB, MISSING_SETUP_SCRIPT]) +def test_editable_with_pyproject(tmp_path, venv, setup_script): + project = tmp_path / "mypkg" + files = {**EXAMPLE, "setup.py": setup_script} + project.mkdir() + jaraco.path.build(files, prefix=project) + + cmd = [venv.exe(), "-m", "pip", "install", + "--no-build-isolation", # required to force current version of setuptools + "-e", str(project)] + print(str(subprocess.check_output(cmd), "utf-8")) + + cmd = [venv.exe(), "-m", "mypkg"] + assert subprocess.check_output(cmd).strip() == b"3.14159.post0 Hello World" + + (project / "src/mypkg/data.txt").write_text("foobar") + (project / "src/mypkg/mod.py").write_text("x = 42") + assert subprocess.check_output(cmd).strip() == b"3.14159.post0 foobar 42" diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py index 2a070de..ee07b5a 100644 --- a/setuptools/tests/test_egg_info.py +++ b/setuptools/tests/test_egg_info.py @@ -4,15 +4,18 @@ import os import glob import re import stat - -from setuptools.command.egg_info import egg_info, manifest_maker -from setuptools.dist import Distribution -from setuptools.extern.six.moves import map +import time +from typing import List, Tuple import pytest +from jaraco import path + +from setuptools.command.egg_info import ( + egg_info, manifest_maker, EggInfoDeprecationWarning, get_pkg_info_revision, +) +from setuptools.dist import Distribution from . import environment -from .files import build_files from .textwrap import DALS from . import contexts @@ -21,7 +24,7 @@ class Environment(str): pass -class TestEggInfo(object): +class TestEggInfo: setup_script = DALS(""" from setuptools import setup @@ -35,7 +38,7 @@ class TestEggInfo(object): """) def _create_project(self): - build_files({ + path.build({ 'setup.py': self.setup_script, 'hello.py': DALS(""" def run(): @@ -43,7 +46,12 @@ class TestEggInfo(object): """) }) - @pytest.yield_fixture + @staticmethod + def _extract_mv_version(pkg_info_lines: List[str]) -> Tuple[int, int]: + version_str = pkg_info_lines[0].split(' ')[1] + return tuple(map(int, version_str.split('.')[:2])) + + @pytest.fixture def env(self): with contexts.tempdir(prefix='setuptools-test.') as env_dir: env = Environment(env_dir) @@ -54,7 +62,7 @@ class TestEggInfo(object): for dirname in subs ) list(map(os.mkdir, env.paths.values())) - build_files({ + path.build({ env.paths['home']: { '.pydistutils.cfg': DALS(""" [egg_info] @@ -68,8 +76,7 @@ class TestEggInfo(object): """ When the egg_info section is empty or not present, running save_version_info should add the settings to the setup.cfg - in a deterministic order, consistent with the ordering found - on Python 2.7 with PYTHONHASHSEED=0. + in a deterministic order. """ setup_cfg = os.path.join(env.paths['home'], 'setup.cfg') dist = Distribution() @@ -105,7 +112,7 @@ class TestEggInfo(object): the file should remain unchanged. """ setup_cfg = os.path.join(env.paths['home'], 'setup.cfg') - build_files({ + path.build({ setup_cfg: DALS(""" [egg_info] tag_build = @@ -128,11 +135,11 @@ class TestEggInfo(object): self._validate_content_order(content, expected_order) - def test_egg_base_installed_egg_info(self, tmpdir_cwd, env): + def test_expected_files_produced(self, tmpdir_cwd, env): self._create_project() - self._run_install_command(tmpdir_cwd, env) - actual = self._find_egg_info_files(env.paths['lib']) + self._run_egg_info_command(tmpdir_cwd, env) + actual = os.listdir('foo.egg-info') expected = [ 'PKG-INFO', @@ -144,9 +151,57 @@ class TestEggInfo(object): ] assert sorted(actual) == expected + def test_license_is_a_string(self, tmpdir_cwd, env): + setup_config = DALS(""" + [metadata] + name=foo + version=0.0.1 + license=file:MIT + """) + + setup_script = DALS(""" + from setuptools import setup + + setup() + """) + + path.build({ + 'setup.py': setup_script, + 'setup.cfg': setup_config, + }) + + # This command should fail with a ValueError, but because it's + # currently configured to use a subprocess, the actual traceback + # object is lost and we need to parse it from stderr + with pytest.raises(AssertionError) as exc: + self._run_egg_info_command(tmpdir_cwd, env) + + # Hopefully this is not too fragile: the only argument to the + # assertion error should be a traceback, ending with: + # ValueError: .... + # + # assert not 1 + tb = exc.value.args[0].split('\n') + assert tb[-3].lstrip().startswith('ValueError') + + def test_rebuilt(self, tmpdir_cwd, env): + """Ensure timestamps are updated when the command is re-run.""" + self._create_project() + + self._run_egg_info_command(tmpdir_cwd, env) + timestamp_a = os.path.getmtime('foo.egg-info') + + # arbitrary sleep just to handle *really* fast systems + time.sleep(.001) + + self._run_egg_info_command(tmpdir_cwd, env) + timestamp_b = os.path.getmtime('foo.egg-info') + + assert timestamp_a != timestamp_b + def test_manifest_template_is_read(self, tmpdir_cwd, env): self._create_project() - build_files({ + path.build({ 'MANIFEST.in': DALS(""" recursive-include docs *.rst """), @@ -154,8 +209,8 @@ class TestEggInfo(object): 'usage.rst': "Run 'hi'", } }) - self._run_install_command(tmpdir_cwd, env) - egg_info_dir = self._find_egg_info_files(env.paths['lib']).base + self._run_egg_info_command(tmpdir_cwd, env) + egg_info_dir = os.path.join('.', 'foo.egg-info') sources_txt = os.path.join(egg_info_dir, 'SOURCES.txt') with open(sources_txt) as f: assert 'docs/usage.rst' in f.read().split('\n') @@ -169,8 +224,10 @@ class TestEggInfo(object): ''' ) % ('' if use_setup_cfg else requires) setup_config = requires if use_setup_cfg else '' - build_files({'setup.py': setup_script, - 'setup.cfg': setup_config}) + path.build({ + 'setup.py': setup_script, + 'setup.cfg': setup_config, + }) mismatch_marker = "python_version<'{this_ver}'".format( this_ver=sys.version_info[0], @@ -181,7 +238,7 @@ class TestEggInfo(object): ) invalid_marker = "<=>++" - class RequiresTestHelper(object): + class RequiresTestHelper: @staticmethod def parametrize(*test_list, **format_dict): @@ -233,27 +290,27 @@ class TestEggInfo(object): ''' install_requires_deterministic - install_requires=["fake-factory==0.5.2", "pytz"] + install_requires=["wheel>=0.5", "pytest"] [options] install_requires = - fake-factory==0.5.2 - pytz + wheel>=0.5 + pytest - fake-factory==0.5.2 - pytz + wheel>=0.5 + pytest ''', ''' install_requires_ordered - install_requires=["fake-factory>=1.12.3,!=2.0"] + install_requires=["pytest>=3.0.2,!=10.9999"] [options] install_requires = - fake-factory>=1.12.3,!=2.0 + pytest>=3.0.2,!=10.9999 - fake-factory!=2.0,>=1.12.3 + pytest!=10.9999,>=3.0.2 ''', ''' @@ -394,7 +451,7 @@ class TestEggInfo(object): self, tmpdir_cwd, env, requires, use_setup_cfg, expected_requires, install_cmd_kwargs): self._setup_script_with_requires(requires, use_setup_cfg) - self._run_install_command(tmpdir_cwd, env, **install_cmd_kwargs) + self._run_egg_info_command(tmpdir_cwd, env, **install_cmd_kwargs) egg_info_dir = os.path.join('.', 'foo.egg-info') requires_txt = os.path.join(egg_info_dir, 'requires.txt') if os.path.exists(requires_txt): @@ -414,14 +471,14 @@ class TestEggInfo(object): req = 'install_requires={"fake-factory==0.5.2", "pytz"}' self._setup_script_with_requires(req) with pytest.raises(AssertionError): - self._run_install_command(tmpdir_cwd, env) + self._run_egg_info_command(tmpdir_cwd, env) def test_extras_require_with_invalid_marker(self, tmpdir_cwd, env): tmpl = 'extras_require={{":{marker}": ["barbazquux"]}},' req = tmpl.format(marker=self.invalid_marker) self._setup_script_with_requires(req) with pytest.raises(AssertionError): - self._run_install_command(tmpdir_cwd, env) + self._run_egg_info_command(tmpdir_cwd, env) assert glob.glob(os.path.join(env.paths['lib'], 'barbazquux*')) == [] def test_extras_require_with_invalid_marker_in_req(self, tmpdir_cwd, env): @@ -429,7 +486,7 @@ class TestEggInfo(object): req = tmpl.format(marker=self.invalid_marker) self._setup_script_with_requires(req) with pytest.raises(AssertionError): - self._run_install_command(tmpdir_cwd, env) + self._run_egg_info_command(tmpdir_cwd, env) assert glob.glob(os.path.join(env.paths['lib'], 'barbazquux*')) == [] def test_provides_extra(self, tmpdir_cwd, env): @@ -452,7 +509,7 @@ class TestEggInfo(object): def test_doesnt_provides_extra(self, tmpdir_cwd, env): self._setup_script_with_requires( - '''install_requires=["spam ; python_version<'3.3'"]''') + '''install_requires=["spam ; python_version<'3.6'"]''') environ = os.environ.copy().update( HOME=env.paths['home'], ) @@ -467,6 +524,375 @@ class TestEggInfo(object): pkg_info_text = pkginfo_file.read() assert 'Provides-Extra:' not in pkg_info_text + @pytest.mark.parametrize("files, license_in_sources", [ + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE + """), + 'LICENSE': "Test license" + }, True), # with license + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = INVALID_LICENSE + """), + 'LICENSE': "Test license" + }, False), # with an invalid license + ({ + 'setup.cfg': DALS(""" + """), + 'LICENSE': "Test license" + }, True), # no license_file attribute, LICENSE auto-included + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE + """), + 'MANIFEST.in': "exclude LICENSE", + 'LICENSE': "Test license" + }, True), # manifest is overwritten by license_file + pytest.param({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICEN[CS]E* + """), + 'LICENSE': "Test license", + }, True, + id="glob_pattern"), + ]) + def test_setup_cfg_license_file( + self, tmpdir_cwd, env, files, license_in_sources): + self._create_project() + path.build(files) + + environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]) + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + + with open(os.path.join(egg_info_dir, 'SOURCES.txt')) as sources_file: + sources_text = sources_file.read() + + if license_in_sources: + assert 'LICENSE' in sources_text + else: + assert 'LICENSE' not in sources_text + # for invalid license test + assert 'INVALID_LICENSE' not in sources_text + + @pytest.mark.parametrize("files, incl_licenses, excl_licenses", [ + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE-ABC + LICENSE-XYZ + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, ['LICENSE-ABC', 'LICENSE-XYZ'], []), # with licenses + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = LICENSE-ABC, LICENSE-XYZ + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, ['LICENSE-ABC', 'LICENSE-XYZ'], []), # with commas + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE-ABC + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, ['LICENSE-ABC'], ['LICENSE-XYZ']), # with one license + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, [], ['LICENSE-ABC', 'LICENSE-XYZ']), # empty + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = LICENSE-XYZ + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, ['LICENSE-XYZ'], ['LICENSE-ABC']), # on same line + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE-ABC + INVALID_LICENSE + """), + 'LICENSE-ABC': "Test license" + }, ['LICENSE-ABC'], ['INVALID_LICENSE']), # with an invalid license + ({ + 'setup.cfg': DALS(""" + """), + 'LICENSE': "Test license" + }, ['LICENSE'], []), # no license_files attribute, LICENSE auto-included + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = LICENSE + """), + 'MANIFEST.in': "exclude LICENSE", + 'LICENSE': "Test license" + }, ['LICENSE'], []), # manifest is overwritten by license_files + ({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE-ABC + LICENSE-XYZ + """), + 'MANIFEST.in': "exclude LICENSE-XYZ", + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + # manifest is overwritten by license_files + }, ['LICENSE-ABC', 'LICENSE-XYZ'], []), + pytest.param({ + 'setup.cfg': "", + 'LICENSE-ABC': "ABC license", + 'COPYING-ABC': "ABC copying", + 'NOTICE-ABC': "ABC notice", + 'AUTHORS-ABC': "ABC authors", + 'LICENCE-XYZ': "XYZ license", + 'LICENSE': "License", + 'INVALID-LICENSE': "Invalid license", + }, [ + 'LICENSE-ABC', + 'COPYING-ABC', + 'NOTICE-ABC', + 'AUTHORS-ABC', + 'LICENCE-XYZ', + 'LICENSE', + ], ['INVALID-LICENSE'], + # ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*') + id="default_glob_patterns"), + pytest.param({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE* + """), + 'LICENSE-ABC': "ABC license", + 'NOTICE-XYZ': "XYZ notice", + }, ['LICENSE-ABC'], ['NOTICE-XYZ'], + id="no_default_glob_patterns"), + pytest.param({ + 'setup.cfg': DALS(""" + [metadata] + license_files = + LICENSE-ABC + LICENSE* + """), + 'LICENSE-ABC': "ABC license", + }, ['LICENSE-ABC'], [], + id="files_only_added_once", + ), + ]) + def test_setup_cfg_license_files( + self, tmpdir_cwd, env, files, incl_licenses, excl_licenses): + self._create_project() + path.build(files) + + environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]) + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + + with open(os.path.join(egg_info_dir, 'SOURCES.txt')) as sources_file: + sources_lines = list(line.strip() for line in sources_file) + + for lf in incl_licenses: + assert sources_lines.count(lf) == 1 + + for lf in excl_licenses: + assert sources_lines.count(lf) == 0 + + @pytest.mark.parametrize("files, incl_licenses, excl_licenses", [ + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = + license_files = + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + }, [], ['LICENSE-ABC', 'LICENSE-XYZ']), # both empty + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = + LICENSE-ABC + LICENSE-XYZ + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-XYZ': "XYZ license" + # license_file is still singular + }, [], ['LICENSE-ABC', 'LICENSE-XYZ']), + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE-ABC + license_files = + LICENSE-XYZ + LICENSE-PQR + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-PQR': "PQR license", + 'LICENSE-XYZ': "XYZ license" + }, ['LICENSE-ABC', 'LICENSE-PQR', 'LICENSE-XYZ'], []), # combined + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE-ABC + license_files = + LICENSE-ABC + LICENSE-XYZ + LICENSE-PQR + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-PQR': "PQR license", + 'LICENSE-XYZ': "XYZ license" + # duplicate license + }, ['LICENSE-ABC', 'LICENSE-PQR', 'LICENSE-XYZ'], []), + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE-ABC + license_files = + LICENSE-XYZ + """), + 'LICENSE-ABC': "ABC license", + 'LICENSE-PQR': "PQR license", + 'LICENSE-XYZ': "XYZ license" + # combined subset + }, ['LICENSE-ABC', 'LICENSE-XYZ'], ['LICENSE-PQR']), + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE-ABC + license_files = + LICENSE-XYZ + LICENSE-PQR + """), + 'LICENSE-PQR': "Test license" + # with invalid licenses + }, ['LICENSE-PQR'], ['LICENSE-ABC', 'LICENSE-XYZ']), + ({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE-ABC + license_files = + LICENSE-PQR + LICENSE-XYZ + """), + 'MANIFEST.in': "exclude LICENSE-ABC\nexclude LICENSE-PQR", + 'LICENSE-ABC': "ABC license", + 'LICENSE-PQR': "PQR license", + 'LICENSE-XYZ': "XYZ license" + # manifest is overwritten + }, ['LICENSE-ABC', 'LICENSE-PQR', 'LICENSE-XYZ'], []), + pytest.param({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE* + """), + 'LICENSE-ABC': "ABC license", + 'NOTICE-XYZ': "XYZ notice", + }, ['LICENSE-ABC'], ['NOTICE-XYZ'], + id="no_default_glob_patterns"), + pytest.param({ + 'setup.cfg': DALS(""" + [metadata] + license_file = LICENSE* + license_files = + NOTICE* + """), + 'LICENSE-ABC': "ABC license", + 'NOTICE-ABC': "ABC notice", + 'AUTHORS-ABC': "ABC authors", + }, ['LICENSE-ABC', 'NOTICE-ABC'], ['AUTHORS-ABC'], + id="combined_glob_patterrns"), + ]) + def test_setup_cfg_license_file_license_files( + self, tmpdir_cwd, env, files, incl_licenses, excl_licenses): + self._create_project() + path.build(files) + + environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]) + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + + with open(os.path.join(egg_info_dir, 'SOURCES.txt')) as sources_file: + sources_lines = list(line.strip() for line in sources_file) + + for lf in incl_licenses: + assert sources_lines.count(lf) == 1 + + for lf in excl_licenses: + assert sources_lines.count(lf) == 0 + + def test_license_file_attr_pkg_info(self, tmpdir_cwd, env): + """All matched license files should have a corresponding License-File.""" + self._create_project() + path.build({ + "setup.cfg": DALS(""" + [metadata] + license_files = + NOTICE* + LICENSE* + """), + "LICENSE-ABC": "ABC license", + "LICENSE-XYZ": "XYZ license", + "NOTICE": "included", + "IGNORE": "not include", + }) + + environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]) + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + license_file_lines = [ + line for line in pkg_info_lines if line.startswith('License-File:')] + + # Only 'NOTICE', LICENSE-ABC', and 'LICENSE-XYZ' should have been matched + # Also assert that order from license_files is keeped + assert "License-File: NOTICE" == license_file_lines[0] + assert "License-File: LICENSE-ABC" in license_file_lines[1:] + assert "License-File: LICENSE-XYZ" in license_file_lines[1:] + + def test_metadata_version(self, tmpdir_cwd, env): + """Make sure latest metadata version is used by default.""" + self._setup_script_with_requires("") + code, data = environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]), + data_stream=1, + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + # Update metadata version if changed + assert self._extract_mv_version(pkg_info_lines) == (2, 1) + def test_long_description_content_type(self, tmpdir_cwd, env): # Test that specifying a `long_description_content_type` keyword arg to # the `setup` function results in writing a `Description-Content-Type` @@ -493,6 +919,29 @@ class TestEggInfo(object): assert expected_line in pkg_info_lines assert 'Metadata-Version: 2.1' in pkg_info_lines + def test_long_description(self, tmpdir_cwd, env): + # Test that specifying `long_description` and `long_description_content_type` + # keyword args to the `setup` function results in writing + # the description in the message payload of the `PKG-INFO` file + # in the `<distribution>.egg-info` directory. + self._setup_script_with_requires( + "long_description='This is a long description\\nover multiple lines'," + "long_description_content_type='text/markdown'," + ) + code, data = environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]), + data_stream=1, + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + assert 'Metadata-Version: 2.1' in pkg_info_lines + assert '' == pkg_info_lines[-1] # last line should be empty + long_desc_lines = pkg_info_lines[pkg_info_lines.index(''):] + assert 'This is a long description' in long_desc_lines + assert 'over multiple lines' in long_desc_lines + def test_project_urls(self, tmpdir_cwd, env): # Test that specifying a `project_urls` dict to the `setup` # function results in writing multiple `Project-URL` lines to @@ -522,6 +971,40 @@ class TestEggInfo(object): assert expected_line in pkg_info_lines expected_line = 'Project-URL: Link Two, https://example.com/two/' assert expected_line in pkg_info_lines + assert self._extract_mv_version(pkg_info_lines) >= (1, 2) + + def test_license(self, tmpdir_cwd, env): + """Test single line license.""" + self._setup_script_with_requires( + "license='MIT'," + ) + code, data = environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]), + data_stream=1, + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + assert 'License: MIT' in pkg_info_lines + + def test_license_escape(self, tmpdir_cwd, env): + """Test license is escaped correctly if longer than one line.""" + self._setup_script_with_requires( + "license='This is a long license text \\nover multiple lines'," + ) + code, data = environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]), + data_stream=1, + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + + assert 'License: This is a long license text ' in pkg_info_lines + assert ' over multiple lines' in pkg_info_lines + assert 'text \n over multiple' in '\n'.join(pkg_info_lines) def test_python_requires_egg_info(self, tmpdir_cwd, env): self._setup_script_with_requires( @@ -539,16 +1022,7 @@ class TestEggInfo(object): with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: pkg_info_lines = pkginfo_file.read().split('\n') assert 'Requires-Python: >=2.7.12' in pkg_info_lines - assert 'Metadata-Version: 1.2' in pkg_info_lines - - def test_python_requires_install(self, tmpdir_cwd, env): - self._setup_script_with_requires( - """python_requires='>=1.2.3',""") - self._run_install_command(tmpdir_cwd, env) - egg_info_dir = self._find_egg_info_files(env.paths['lib']).base - pkginfo = os.path.join(egg_info_dir, 'PKG-INFO') - with open(pkginfo) as f: - assert 'Requires-Python: >=1.2.3' in f.read().split('\n') + assert self._extract_mv_version(pkg_info_lines) >= (1, 2) def test_manifest_maker_warning_suppression(self): fixtures = [ @@ -559,17 +1033,27 @@ class TestEggInfo(object): for msg in fixtures: assert manifest_maker._should_suppress_warning(msg) - def _run_install_command(self, tmpdir_cwd, env, cmd=None, output=None): + def test_egg_info_includes_setup_py(self, tmpdir_cwd): + self._create_project() + dist = Distribution({"name": "foo", "version": "0.0.1"}) + dist.script_name = "non_setup.py" + egg_info_instance = egg_info(dist) + egg_info_instance.finalize_options() + egg_info_instance.run() + + assert 'setup.py' in egg_info_instance.filelist.files + + with open(egg_info_instance.egg_info + "/SOURCES.txt") as f: + sources = f.read().split('\n') + assert 'setup.py' in sources + + def _run_egg_info_command(self, tmpdir_cwd, env, cmd=None, output=None): environ = os.environ.copy().update( HOME=env.paths['home'], ) if cmd is None: cmd = [ - 'install', - '--home', env.paths['home'], - '--install-lib', env.paths['lib'], - '--install-scripts', env.paths['scripts'], - '--install-data', env.paths['data'], + 'egg_info', ] code, data = environment.run_setup_py( cmd=cmd, @@ -577,22 +1061,26 @@ class TestEggInfo(object): data_stream=1, env=environ, ) - if code: - raise AssertionError(data) + assert not code, data + if output: assert output in data - def _find_egg_info_files(self, root): - class DirList(list): - def __init__(self, files, base): - super(DirList, self).__init__(files) - self.base = base + def test_egg_info_tag_only_once(self, tmpdir_cwd, env): + self._create_project() + path.build({ + 'setup.cfg': DALS(""" + [egg_info] + tag_build = dev + tag_date = 0 + tag_svn_revision = 0 + """), + }) + self._run_egg_info_command(tmpdir_cwd, env) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + assert 'Version: 0.0.0.dev0' in pkg_info_lines - results = ( - DirList(filenames, dirpath) - for dirpath, dirnames, filenames in os.walk(root) - if os.path.basename(dirpath) == 'EGG-INFO' - ) - # expect exactly one result - result, = results - return result + def test_get_pkg_info_revision_deprecated(self): + pytest.warns(EggInfoDeprecationWarning, get_pkg_info_revision) diff --git a/setuptools/tests/test_extern.py b/setuptools/tests/test_extern.py new file mode 100644 index 0000000..0d6b164 --- /dev/null +++ b/setuptools/tests/test_extern.py @@ -0,0 +1,20 @@ +import importlib +import pickle + +from setuptools import Distribution +from setuptools.extern import ordered_set + + +def test_reimport_extern(): + ordered_set2 = importlib.import_module(ordered_set.__name__) + assert ordered_set is ordered_set2 + + +def test_orderedset_pickle_roundtrip(): + o1 = ordered_set.OrderedSet([1, 2, 5]) + o2 = pickle.loads(pickle.dumps(o1)) + assert o1 == o2 + + +def test_distribution_picklable(): + pickle.loads(pickle.dumps(Distribution())) diff --git a/setuptools/tests/test_find_packages.py b/setuptools/tests/test_find_packages.py index a6023de..efcce92 100644 --- a/setuptools/tests/test_find_packages.py +++ b/setuptools/tests/test_find_packages.py @@ -1,4 +1,4 @@ -"""Tests for setuptools.find_packages().""" +"""Tests for automatic package discovery""" import os import sys import shutil @@ -7,14 +7,12 @@ import platform import pytest -import setuptools from setuptools import find_packages +from setuptools import find_namespace_packages +from setuptools.discovery import FlatLayoutPackageFinder -find_420_packages = setuptools.PEP420PackageFinder.find # modeled after CPython's test.support.can_symlink - - def can_symlink(): TESTFN = tempfile.mktemp() symlink_path = TESTFN + "can_symlink" @@ -154,29 +152,94 @@ class TestFindPackages: assert set(actual) == set(expected) def test_pep420_ns_package(self): - packages = find_420_packages( + packages = find_namespace_packages( self.dist_dir, include=['pkg*'], exclude=['pkg.subpkg.assets']) self._assert_packages(packages, ['pkg', 'pkg.nspkg', 'pkg.subpkg']) def test_pep420_ns_package_no_includes(self): - packages = find_420_packages( + packages = find_namespace_packages( self.dist_dir, exclude=['pkg.subpkg.assets']) - self._assert_packages(packages, ['docs', 'pkg', 'pkg.nspkg', 'pkg.subpkg']) + self._assert_packages( + packages, ['docs', 'pkg', 'pkg.nspkg', 'pkg.subpkg']) def test_pep420_ns_package_no_includes_or_excludes(self): - packages = find_420_packages(self.dist_dir) + packages = find_namespace_packages(self.dist_dir) expected = [ 'docs', 'pkg', 'pkg.nspkg', 'pkg.subpkg', 'pkg.subpkg.assets'] self._assert_packages(packages, expected) def test_regular_package_with_nested_pep420_ns_packages(self): self._touch('__init__.py', self.pkg_dir) - packages = find_420_packages( + packages = find_namespace_packages( self.dist_dir, exclude=['docs', 'pkg.subpkg.assets']) self._assert_packages(packages, ['pkg', 'pkg.nspkg', 'pkg.subpkg']) def test_pep420_ns_package_no_non_package_dirs(self): shutil.rmtree(self.docs_dir) shutil.rmtree(os.path.join(self.dist_dir, 'pkg/subpkg/assets')) - packages = find_420_packages(self.dist_dir) + packages = find_namespace_packages(self.dist_dir) self._assert_packages(packages, ['pkg', 'pkg.nspkg', 'pkg.subpkg']) + + +class TestFlatLayoutPackageFinder: + EXAMPLES = { + "hidden-folders": ( + [".pkg/__init__.py", "pkg/__init__.py", "pkg/nested/file.txt"], + ["pkg", "pkg.nested"] + ), + "private-packages": ( + ["_pkg/__init__.py", "pkg/_private/__init__.py"], + ["pkg", "pkg._private"] + ), + "invalid-name": ( + ["invalid-pkg/__init__.py", "other.pkg/__init__.py", "yet,another/file.py"], + [] + ), + "docs": ( + ["pkg/__init__.py", "docs/conf.py", "docs/readme.rst"], + ["pkg"] + ), + "tests": ( + ["pkg/__init__.py", "tests/test_pkg.py", "tests/__init__.py"], + ["pkg"] + ), + "examples": ( + [ + "pkg/__init__.py", + "examples/__init__.py", + "examples/file.py" + "example/other_file.py", + # Sub-packages should always be fine + "pkg/example/__init__.py", + "pkg/examples/__init__.py", + ], + ["pkg", "pkg.examples", "pkg.example"] + ), + "tool-specific": ( + [ + "pkg/__init__.py", + "tasks/__init__.py", + "tasks/subpackage/__init__.py", + "fabfile/__init__.py", + "fabfile/subpackage/__init__.py", + # Sub-packages should always be fine + "pkg/tasks/__init__.py", + "pkg/fabfile/__init__.py", + ], + ["pkg", "pkg.tasks", "pkg.fabfile"] + ) + } + + @pytest.mark.parametrize("example", EXAMPLES.keys()) + def test_unwanted_directories_not_included(self, tmp_path, example): + files, expected_packages = self.EXAMPLES[example] + ensure_files(tmp_path, files) + found_packages = FlatLayoutPackageFinder.find(str(tmp_path)) + assert set(found_packages) == set(expected_packages) + + +def ensure_files(root_path, files): + for file in files: + path = root_path / file + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() diff --git a/setuptools/tests/test_find_py_modules.py b/setuptools/tests/test_find_py_modules.py new file mode 100644 index 0000000..4ef6880 --- /dev/null +++ b/setuptools/tests/test_find_py_modules.py @@ -0,0 +1,81 @@ +"""Tests for automatic discovery of modules""" +import os + +import pytest + +from setuptools.discovery import FlatLayoutModuleFinder, ModuleFinder + +from .test_find_packages import ensure_files, has_symlink + + +class TestModuleFinder: + def find(self, path, *args, **kwargs): + return set(ModuleFinder.find(str(path), *args, **kwargs)) + + EXAMPLES = { + # circumstance: (files, kwargs, expected_modules) + "simple_folder": ( + ["file.py", "other.py"], + {}, # kwargs + ["file", "other"], + ), + "exclude": ( + ["file.py", "other.py"], + {"exclude": ["f*"]}, + ["other"], + ), + "include": ( + ["file.py", "fole.py", "other.py"], + {"include": ["f*"], "exclude": ["fo*"]}, + ["file"], + ), + "invalid-name": ( + ["my-file.py", "other.file.py"], + {}, + [] + ) + } + + @pytest.mark.parametrize("example", EXAMPLES.keys()) + def test_finder(self, tmp_path, example): + files, kwargs, expected_modules = self.EXAMPLES[example] + ensure_files(tmp_path, files) + assert self.find(tmp_path, **kwargs) == set(expected_modules) + + @pytest.mark.skipif(not has_symlink(), reason='Symlink support required') + def test_symlinked_packages_are_included(self, tmp_path): + src = "_myfiles/file.py" + ensure_files(tmp_path, [src]) + os.symlink(tmp_path / src, tmp_path / "link.py") + assert self.find(tmp_path) == {"link"} + + +class TestFlatLayoutModuleFinder: + def find(self, path, *args, **kwargs): + return set(FlatLayoutModuleFinder.find(str(path))) + + EXAMPLES = { + # circumstance: (files, expected_modules) + "hidden-files": ( + [".module.py"], + [] + ), + "private-modules": ( + ["_module.py"], + [] + ), + "common-names": ( + ["setup.py", "conftest.py", "test.py", "tests.py", "example.py", "mod.py"], + ["mod"] + ), + "tool-specific": ( + ["tasks.py", "fabfile.py", "noxfile.py", "dodo.py", "manage.py", "mod.py"], + ["mod"] + ) + } + + @pytest.mark.parametrize("example", EXAMPLES.keys()) + def test_unwanted_files_not_included(self, tmp_path, example): + files, expected_modules = self.EXAMPLES[example] + ensure_files(tmp_path, files) + assert self.find(tmp_path) == set(expected_modules) diff --git a/setuptools/tests/test_glob.py b/setuptools/tests/test_glob.py new file mode 100644 index 0000000..e99587f --- /dev/null +++ b/setuptools/tests/test_glob.py @@ -0,0 +1,34 @@ +import pytest +from jaraco import path + +from setuptools.glob import glob + + +@pytest.mark.parametrize('tree, pattern, matches', ( + ('', b'', []), + ('', '', []), + (''' + appveyor.yml + CHANGES.rst + LICENSE + MANIFEST.in + pyproject.toml + README.rst + setup.cfg + setup.py + ''', '*.rst', ('CHANGES.rst', 'README.rst')), + (''' + appveyor.yml + CHANGES.rst + LICENSE + MANIFEST.in + pyproject.toml + README.rst + setup.cfg + setup.py + ''', b'*.rst', (b'CHANGES.rst', b'README.rst')), +)) +def test_glob(monkeypatch, tmpdir, tree, pattern, matches): + monkeypatch.chdir(tmpdir) + path.build({name: '' for name in tree.split()}) + assert list(sorted(glob(pattern))) == list(sorted(matches)) diff --git a/setuptools/tests/test_install_scripts.py b/setuptools/tests/test_install_scripts.py index 7393241..4338c79 100644 --- a/setuptools/tests/test_install_scripts.py +++ b/setuptools/tests/test_install_scripts.py @@ -19,7 +19,7 @@ class TestInstallScripts: ) unix_exe = '/usr/dummy-test-path/local/bin/python' unix_spaces_exe = '/usr/bin/env dummy-test-python' - win32_exe = 'C:\\Dummy Test Path\\Program Files\\Python 3.3\\python.exe' + win32_exe = 'C:\\Dummy Test Path\\Program Files\\Python 3.6\\python.exe' def _run_install_scripts(self, install_dir, executable=None): dist = Distribution(self.settings) @@ -64,7 +64,8 @@ class TestInstallScripts: @pytest.mark.skipif(sys.platform == 'win32', reason='non-Windows only') def test_executable_with_spaces_escaping_unix(self, tmpdir): """ - Ensure that shebang on Unix is not quoted, even when a value with spaces + Ensure that shebang on Unix is not quoted, even when + a value with spaces is specified using --executable. """ expected = '#!%s\n' % self.unix_spaces_exe @@ -77,7 +78,8 @@ class TestInstallScripts: @pytest.mark.skipif(sys.platform != 'win32', reason='Windows only') def test_executable_arg_escaping_win32(self, tmpdir): """ - Ensure that shebang on Windows is quoted when getting a path with spaces + Ensure that shebang on Windows is quoted when + getting a path with spaces from --executable, that is itself properly quoted. """ expected = '#!"%s"\n' % self.win32_exe diff --git a/setuptools/tests/test_integration.py b/setuptools/tests/test_integration.py index 3a9a6c5..b557831 100644 --- a/setuptools/tests/test_integration.py +++ b/setuptools/tests/test_integration.py @@ -6,8 +6,8 @@ Try to install a few packages. import glob import os import sys +import urllib.request -from setuptools.extern.six.moves import urllib import pytest from setuptools.command.easy_install import easy_install @@ -15,6 +15,13 @@ from setuptools.command import easy_install as easy_install_pkg from setuptools.dist import Distribution +pytestmark = pytest.mark.skipif( + 'platform.python_implementation() == "PyPy" and ' + 'platform.system() == "Windows"', + reason="pypa/setuptools#2496", +) + + def setup_module(module): packages = 'stevedore', 'virtualenvwrapper', 'pbr', 'novaclient' for pkg in packages: @@ -59,7 +66,7 @@ def install_context(request, tmpdir, monkeypatch): monkeypatch.setattr('site.USER_BASE', user_base.strpath) monkeypatch.setattr('site.USER_SITE', user_site.strpath) monkeypatch.setattr('sys.path', sys.path + [install_dir.strpath]) - monkeypatch.setenv('PYTHONPATH', os.path.pathsep.join(sys.path)) + monkeypatch.setenv(str('PYTHONPATH'), str(os.path.pathsep.join(sys.path))) # Set up the command for performing the installation. dist = Distribution() @@ -112,54 +119,3 @@ def test_pyuri(install_context): # The package data should be installed. assert os.path.exists(os.path.join(pyuri.location, 'pyuri', 'uri.regex')) - - -import re -import subprocess -import functools -import tarfile, zipfile - - -build_deps = ['appdirs', 'packaging', 'pyparsing', 'six'] -@pytest.mark.parametrize("build_dep", build_deps) -@pytest.mark.skipif(sys.version_info < (3, 6), reason='run only on late versions') -def test_build_deps_on_distutils(request, tmpdir_factory, build_dep): - """ - All setuptools build dependencies must build without - setuptools. - """ - if 'pyparsing' in build_dep: - pytest.xfail(reason="Project imports setuptools unconditionally") - build_target = tmpdir_factory.mktemp('source') - build_dir = download_and_extract(request, build_dep, build_target) - install_target = tmpdir_factory.mktemp('target') - output = install(build_dir, install_target) - for line in output.splitlines(): - match = re.search('Unknown distribution option: (.*)', line) - allowed_unknowns = [ - 'test_suite', - 'tests_require', - 'install_requires', - ] - assert not match or match.group(1).strip('"\'') in allowed_unknowns - - -def install(pkg_dir, install_dir): - with open(os.path.join(pkg_dir, 'setuptools.py'), 'w') as breaker: - breaker.write('raise ImportError()') - cmd = [sys.executable, 'setup.py', 'install', '--prefix', install_dir] - env = dict(os.environ, PYTHONPATH=pkg_dir) - output = subprocess.check_output(cmd, cwd=pkg_dir, env=env, stderr=subprocess.STDOUT) - return output.decode('utf-8') - - -def download_and_extract(request, req, target): - cmd = [sys.executable, '-m', 'pip', 'download', '--no-deps', - '--no-binary', ':all:', req] - output = subprocess.check_output(cmd, encoding='utf-8') - filename = re.search('Saved (.*)', output).group(1) - request.addfinalizer(functools.partial(os.remove, filename)) - opener = zipfile.ZipFile if filename.endswith('.zip') else tarfile.open - with opener(filename) as archive: - archive.extractall(target) - return os.path.join(target, os.listdir(target)[0]) diff --git a/setuptools/tests/test_logging.py b/setuptools/tests/test_logging.py new file mode 100644 index 0000000..a5ddd56 --- /dev/null +++ b/setuptools/tests/test_logging.py @@ -0,0 +1,36 @@ +import logging + +import pytest + + +setup_py = """\ +from setuptools import setup + +setup( + name="test_logging", + version="0.0" +) +""" + + +@pytest.mark.parametrize( + "flag, expected_level", [("--dry-run", "INFO"), ("--verbose", "DEBUG")] +) +def test_verbosity_level(tmp_path, monkeypatch, flag, expected_level): + """Make sure the correct verbosity level is set (issue #3038)""" + import setuptools # noqa: Import setuptools to monkeypatch distutils + import distutils # <- load distutils after all the patches take place + + logger = logging.Logger(__name__) + monkeypatch.setattr(logging, "root", logger) + unset_log_level = logger.getEffectiveLevel() + assert logging.getLevelName(unset_log_level) == "NOTSET" + + setup_script = tmp_path / "setup.py" + setup_script.write_text(setup_py) + dist = distutils.core.run_setup(setup_script, stop_after="init") + dist.script_args = [flag, "sdist"] + dist.parse_command_line() # <- where the log level is set + log_level = logger.getEffectiveLevel() + log_level_name = logging.getLevelName(log_level) + assert log_level_name == expected_level diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index 65eec7d..82bdb9c 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -7,19 +7,16 @@ import shutil import sys import tempfile import itertools +import io from distutils import log from distutils.errors import DistutilsTemplateError -import pkg_resources.py31compat from setuptools.command.egg_info import FileList, egg_info, translate_pattern from setuptools.dist import Distribution -from setuptools.extern import six from setuptools.tests.textwrap import DALS import pytest -py3_only = pytest.mark.xfail(six.PY2, reason="Test runs on Python 3 only") - def make_local_path(s): """Converts '/' in a string to os.sep""" @@ -42,7 +39,7 @@ setup(**%r) @contextlib.contextmanager def quiet(): old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout, sys.stderr = six.StringIO(), six.StringIO() + sys.stdout, sys.stderr = io.StringIO(), io.StringIO() try: yield finally: @@ -73,7 +70,9 @@ translate_specs = [ # Glob matching ('*.txt', ['foo.txt', 'bar.txt'], ['foo/foo.txt']), - ('dir/*.txt', ['dir/foo.txt', 'dir/bar.txt', 'dir/.txt'], ['notdir/foo.txt']), + ( + 'dir/*.txt', + ['dir/foo.txt', 'dir/bar.txt', 'dir/.txt'], ['notdir/foo.txt']), ('*/*.py', ['bin/start.py'], []), ('docs/page-?.txt', ['docs/page-9.txt'], ['docs/page-10.txt']), @@ -157,7 +156,7 @@ def test_translated_pattern_mismatch(pattern_mismatch): assert not translate_pattern(pattern).match(target) -class TempDirTestCase(object): +class TempDirTestCase: def setup_method(self, method): self.temp_dir = tempfile.mkdtemp() self.old_cwd = os.getcwd() @@ -242,77 +241,77 @@ class TestManifestTest(TempDirTestCase): def test_exclude(self): """Include everything in app/ except the text files""" - l = make_local_path + ml = make_local_path self.make_manifest( """ include app/* exclude app/*.txt """) - files = default_files | set([l('app/c.rst')]) + files = default_files | set([ml('app/c.rst')]) assert files == self.get_files() def test_include_multiple(self): """Include with multiple patterns.""" - l = make_local_path + ml = make_local_path self.make_manifest("include app/*.txt app/static/*") files = default_files | set([ - l('app/a.txt'), l('app/b.txt'), - l('app/static/app.js'), l('app/static/app.js.map'), - l('app/static/app.css'), l('app/static/app.css.map')]) + ml('app/a.txt'), ml('app/b.txt'), + ml('app/static/app.js'), ml('app/static/app.js.map'), + ml('app/static/app.css'), ml('app/static/app.css.map')]) assert files == self.get_files() def test_graft(self): """Include the whole app/static/ directory.""" - l = make_local_path + ml = make_local_path self.make_manifest("graft app/static") files = default_files | set([ - l('app/static/app.js'), l('app/static/app.js.map'), - l('app/static/app.css'), l('app/static/app.css.map')]) + ml('app/static/app.js'), ml('app/static/app.js.map'), + ml('app/static/app.css'), ml('app/static/app.css.map')]) assert files == self.get_files() def test_graft_glob_syntax(self): """Include the whole app/static/ directory.""" - l = make_local_path + ml = make_local_path self.make_manifest("graft */static") files = default_files | set([ - l('app/static/app.js'), l('app/static/app.js.map'), - l('app/static/app.css'), l('app/static/app.css.map')]) + ml('app/static/app.js'), ml('app/static/app.js.map'), + ml('app/static/app.css'), ml('app/static/app.css.map')]) assert files == self.get_files() def test_graft_global_exclude(self): """Exclude all *.map files in the project.""" - l = make_local_path + ml = make_local_path self.make_manifest( """ graft app/static global-exclude *.map """) files = default_files | set([ - l('app/static/app.js'), l('app/static/app.css')]) + ml('app/static/app.js'), ml('app/static/app.css')]) assert files == self.get_files() def test_global_include(self): """Include all *.rst, *.js, and *.css files in the whole tree.""" - l = make_local_path + ml = make_local_path self.make_manifest( """ global-include *.rst *.js *.css """) files = default_files | set([ - '.hidden.rst', 'testing.rst', l('app/c.rst'), - l('app/static/app.js'), l('app/static/app.css')]) + '.hidden.rst', 'testing.rst', ml('app/c.rst'), + ml('app/static/app.js'), ml('app/static/app.css')]) assert files == self.get_files() def test_graft_prune(self): """Include all files in app/, except for the whole app/static/ dir.""" - l = make_local_path + ml = make_local_path self.make_manifest( """ graft app prune app/static """) files = default_files | set([ - l('app/a.txt'), l('app/b.txt'), l('app/c.rst')]) + ml('app/a.txt'), ml('app/b.txt'), ml('app/c.rst')]) assert files == self.get_files() @@ -362,13 +361,13 @@ class TestFileListTest(TempDirTestCase): for file in files: file = os.path.join(self.temp_dir, file) dirname, basename = os.path.split(file) - pkg_resources.py31compat.makedirs(dirname, exist_ok=True) + os.makedirs(dirname, exist_ok=True) open(file, 'w').close() def test_process_template_line(self): # testing all MANIFEST.in template patterns file_list = FileList() - l = make_local_path + ml = make_local_path # simulated file list self.make_files([ @@ -376,16 +375,16 @@ class TestFileListTest(TempDirTestCase): 'buildout.cfg', # filelist does not filter out VCS directories, # it's sdist that does - l('.hg/last-message.txt'), - l('global/one.txt'), - l('global/two.txt'), - l('global/files.x'), - l('global/here.tmp'), - l('f/o/f.oo'), - l('dir/graft-one'), - l('dir/dir2/graft2'), - l('dir3/ok'), - l('dir3/sub/ok.txt'), + ml('.hg/last-message.txt'), + ml('global/one.txt'), + ml('global/two.txt'), + ml('global/files.x'), + ml('global/here.tmp'), + ml('f/o/f.oo'), + ml('dir/graft-one'), + ml('dir/dir2/graft2'), + ml('dir3/ok'), + ml('dir3/sub/ok.txt'), ]) MANIFEST_IN = DALS("""\ @@ -412,12 +411,12 @@ class TestFileListTest(TempDirTestCase): 'buildout.cfg', 'four.txt', 'ok', - l('.hg/last-message.txt'), - l('dir/graft-one'), - l('dir/dir2/graft2'), - l('f/o/f.oo'), - l('global/one.txt'), - l('global/two.txt'), + ml('.hg/last-message.txt'), + ml('dir/graft-one'), + ml('dir/dir2/graft2'), + ml('f/o/f.oo'), + ml('global/one.txt'), + ml('global/two.txt'), ] file_list.sort() @@ -474,10 +473,10 @@ class TestFileListTest(TempDirTestCase): assert False, "Should have thrown an error" def test_include(self): - l = make_local_path + ml = make_local_path # include file_list = FileList() - self.make_files(['a.py', 'b.txt', l('d/c.py')]) + self.make_files(['a.py', 'b.txt', ml('d/c.py')]) file_list.process_template_line('include *.py') file_list.sort() @@ -490,42 +489,42 @@ class TestFileListTest(TempDirTestCase): self.assertWarnings() def test_exclude(self): - l = make_local_path + ml = make_local_path # exclude file_list = FileList() - file_list.files = ['a.py', 'b.txt', l('d/c.py')] + file_list.files = ['a.py', 'b.txt', ml('d/c.py')] file_list.process_template_line('exclude *.py') file_list.sort() - assert file_list.files == ['b.txt', l('d/c.py')] + assert file_list.files == ['b.txt', ml('d/c.py')] self.assertNoWarnings() file_list.process_template_line('exclude *.rb') file_list.sort() - assert file_list.files == ['b.txt', l('d/c.py')] + assert file_list.files == ['b.txt', ml('d/c.py')] self.assertWarnings() def test_global_include(self): - l = make_local_path + ml = make_local_path # global-include file_list = FileList() - self.make_files(['a.py', 'b.txt', l('d/c.py')]) + self.make_files(['a.py', 'b.txt', ml('d/c.py')]) file_list.process_template_line('global-include *.py') file_list.sort() - assert file_list.files == ['a.py', l('d/c.py')] + assert file_list.files == ['a.py', ml('d/c.py')] self.assertNoWarnings() file_list.process_template_line('global-include *.rb') file_list.sort() - assert file_list.files == ['a.py', l('d/c.py')] + assert file_list.files == ['a.py', ml('d/c.py')] self.assertWarnings() def test_global_exclude(self): - l = make_local_path + ml = make_local_path # global-exclude file_list = FileList() - file_list.files = ['a.py', 'b.txt', l('d/c.py')] + file_list.files = ['a.py', 'b.txt', ml('d/c.py')] file_list.process_template_line('global-exclude *.py') file_list.sort() @@ -538,65 +537,65 @@ class TestFileListTest(TempDirTestCase): self.assertWarnings() def test_recursive_include(self): - l = make_local_path + ml = make_local_path # recursive-include file_list = FileList() - self.make_files(['a.py', l('d/b.py'), l('d/c.txt'), l('d/d/e.py')]) + self.make_files(['a.py', ml('d/b.py'), ml('d/c.txt'), ml('d/d/e.py')]) file_list.process_template_line('recursive-include d *.py') file_list.sort() - assert file_list.files == [l('d/b.py'), l('d/d/e.py')] + assert file_list.files == [ml('d/b.py'), ml('d/d/e.py')] self.assertNoWarnings() file_list.process_template_line('recursive-include e *.py') file_list.sort() - assert file_list.files == [l('d/b.py'), l('d/d/e.py')] + assert file_list.files == [ml('d/b.py'), ml('d/d/e.py')] self.assertWarnings() def test_recursive_exclude(self): - l = make_local_path + ml = make_local_path # recursive-exclude file_list = FileList() - file_list.files = ['a.py', l('d/b.py'), l('d/c.txt'), l('d/d/e.py')] + file_list.files = ['a.py', ml('d/b.py'), ml('d/c.txt'), ml('d/d/e.py')] file_list.process_template_line('recursive-exclude d *.py') file_list.sort() - assert file_list.files == ['a.py', l('d/c.txt')] + assert file_list.files == ['a.py', ml('d/c.txt')] self.assertNoWarnings() file_list.process_template_line('recursive-exclude e *.py') file_list.sort() - assert file_list.files == ['a.py', l('d/c.txt')] + assert file_list.files == ['a.py', ml('d/c.txt')] self.assertWarnings() def test_graft(self): - l = make_local_path + ml = make_local_path # graft file_list = FileList() - self.make_files(['a.py', l('d/b.py'), l('d/d/e.py'), l('f/f.py')]) + self.make_files(['a.py', ml('d/b.py'), ml('d/d/e.py'), ml('f/f.py')]) file_list.process_template_line('graft d') file_list.sort() - assert file_list.files == [l('d/b.py'), l('d/d/e.py')] + assert file_list.files == [ml('d/b.py'), ml('d/d/e.py')] self.assertNoWarnings() file_list.process_template_line('graft e') file_list.sort() - assert file_list.files == [l('d/b.py'), l('d/d/e.py')] + assert file_list.files == [ml('d/b.py'), ml('d/d/e.py')] self.assertWarnings() def test_prune(self): - l = make_local_path + ml = make_local_path # prune file_list = FileList() - file_list.files = ['a.py', l('d/b.py'), l('d/d/e.py'), l('f/f.py')] + file_list.files = ['a.py', ml('d/b.py'), ml('d/d/e.py'), ml('f/f.py')] file_list.process_template_line('prune d') file_list.sort() - assert file_list.files == ['a.py', l('f/f.py')] + assert file_list.files == ['a.py', ml('f/f.py')] self.assertNoWarnings() file_list.process_template_line('prune e') file_list.sort() - assert file_list.files == ['a.py', l('f/f.py')] + assert file_list.files == ['a.py', ml('f/f.py')] self.assertWarnings() diff --git a/setuptools/tests/test_msvc.py b/setuptools/tests/test_msvc.py index 32d7a90..d1527bf 100644 --- a/setuptools/tests/test_msvc.py +++ b/setuptools/tests/test_msvc.py @@ -49,7 +49,8 @@ def mock_reg(hkcu=None, hklm=None): for k in hive if k.startswith(key.lower()) ) - return mock.patch.multiple(distutils.msvc9compiler.Reg, + return mock.patch.multiple( + distutils.msvc9compiler.Reg, read_keys=read_keys, read_values=read_values) @@ -61,7 +62,7 @@ class TestModulePatch: """ key_32 = r'software\microsoft\devdiv\vcforpython\9.0\installdir' - key_64 = r'software\wow6432node\microsoft\devdiv\vcforpython\9.0\installdir' + key_64 = key_32.replace(r'\microsoft', r'\wow6432node\microsoft') def test_patched(self): "Test the module is actually patched" @@ -87,7 +88,7 @@ class TestModulePatch: assert isinstance(exc, expected) assert 'aka.ms/vcpython27' in str(exc) - @pytest.yield_fixture + @pytest.fixture def user_preferred_setting(self): """ Set up environment with different install dirs for user vs. system @@ -115,7 +116,7 @@ class TestModulePatch: expected = os.path.join(user_preferred_setting, 'vcvarsall.bat') assert expected == result - @pytest.yield_fixture + @pytest.fixture def local_machine_setting(self): """ Set up environment with only the system environment configured. @@ -137,7 +138,7 @@ class TestModulePatch: expected = os.path.join(local_machine_setting, 'vcvarsall.bat') assert expected == result - @pytest.yield_fixture + @pytest.fixture def x64_preferred_setting(self): """ Set up environment with 64-bit and 32-bit system settings configured diff --git a/setuptools/tests/test_msvc14.py b/setuptools/tests/test_msvc14.py new file mode 100644 index 0000000..1aca12d --- /dev/null +++ b/setuptools/tests/test_msvc14.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +Tests for msvc support module (msvc14 unit tests). +""" + +import os +from distutils.errors import DistutilsPlatformError +import pytest +import sys + + +@pytest.mark.skipif(sys.platform != "win32", + reason="These tests are only for win32") +class TestMSVC14: + """Python 3.8 "distutils/tests/test_msvccompiler.py" backport""" + def test_no_compiler(self): + import setuptools.msvc as _msvccompiler + # makes sure query_vcvarsall raises + # a DistutilsPlatformError if the compiler + # is not found + + def _find_vcvarsall(plat_spec): + return None, None + + old_find_vcvarsall = _msvccompiler._msvc14_find_vcvarsall + _msvccompiler._msvc14_find_vcvarsall = _find_vcvarsall + try: + pytest.raises(DistutilsPlatformError, + _msvccompiler._msvc14_get_vc_env, + 'wont find this version') + finally: + _msvccompiler._msvc14_find_vcvarsall = old_find_vcvarsall + + def test_get_vc_env_unicode(self): + import setuptools.msvc as _msvccompiler + + test_var = 'ṰḖṤṪ┅ṼẨṜ' + test_value = '₃⁴₅' + + # Ensure we don't early exit from _get_vc_env + old_distutils_use_sdk = os.environ.pop('DISTUTILS_USE_SDK', None) + os.environ[test_var] = test_value + try: + env = _msvccompiler._msvc14_get_vc_env('x86') + assert test_var.lower() in env + assert test_value == env[test_var.lower()] + finally: + os.environ.pop(test_var) + if old_distutils_use_sdk: + os.environ['DISTUTILS_USE_SDK'] = old_distutils_use_sdk + + def test_get_vc2017(self): + import setuptools.msvc as _msvccompiler + + # This function cannot be mocked, so pass it if we find VS 2017 + # and mark it skipped if we do not. + version, path = _msvccompiler._msvc14_find_vc2017() + if os.environ.get('APPVEYOR_BUILD_WORKER_IMAGE', '') in [ + 'Visual Studio 2017' + ]: + assert version + if version: + assert version >= 15 + assert os.path.isdir(path) + else: + pytest.skip("VS 2017 is not installed") + + def test_get_vc2015(self): + import setuptools.msvc as _msvccompiler + + # This function cannot be mocked, so pass it if we find VS 2015 + # and mark it skipped if we do not. + version, path = _msvccompiler._msvc14_find_vc2015() + if os.environ.get('APPVEYOR_BUILD_WORKER_IMAGE', '') in [ + 'Visual Studio 2015', 'Visual Studio 2017' + ]: + assert version + if version: + assert version >= 14 + assert os.path.isdir(path) + else: + pytest.skip("VS 2015 is not installed") diff --git a/setuptools/tests/test_namespaces.py b/setuptools/tests/test_namespaces.py index 1ac1b35..270f90c 100644 --- a/setuptools/tests/test_namespaces.py +++ b/setuptools/tests/test_namespaces.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import, unicode_literals - -import os import sys import subprocess @@ -12,10 +9,10 @@ from setuptools.command import test class TestNamespaces: - @pytest.mark.xfail(sys.version_info < (3, 5), - reason="Requires importlib.util.module_from_spec") - @pytest.mark.skipif(bool(os.environ.get("APPVEYOR")), - reason="https://github.com/pypa/setuptools/issues/851") + @pytest.mark.skipif( + sys.version_info < (3, 5), + reason="Requires importlib.util.module_from_spec", + ) def test_mixed_site_and_non_site(self, tmpdir): """ Installing two packages sharing the same namespace, one installed @@ -55,8 +52,6 @@ class TestNamespaces: with test.test.paths_on_pythonpath(map(str, targets)): subprocess.check_call(try_import) - @pytest.mark.skipif(bool(os.environ.get("APPVEYOR")), - reason="https://github.com/pypa/setuptools/issues/851") def test_pkg_resources_import(self, tmpdir): """ Ensure that a namespace package doesn't break on import @@ -67,8 +62,9 @@ class TestNamespaces: target.mkdir() install_cmd = [ sys.executable, - '-m', 'easy_install', - '-d', str(target), + '-m', 'pip', + 'install', + '-t', str(target), str(pkg), ] with test.test.paths_on_pythonpath([str(target)]): @@ -81,8 +77,6 @@ class TestNamespaces: with test.test.paths_on_pythonpath([str(target)]): subprocess.check_call(try_import) - @pytest.mark.skipif(bool(os.environ.get("APPVEYOR")), - reason="https://github.com/pypa/setuptools/issues/851") def test_namespace_package_installed_and_cwd(self, tmpdir): """ Installing a namespace packages but also having it in the current @@ -109,3 +103,32 @@ class TestNamespaces: ] with test.test.paths_on_pythonpath([str(target)]): subprocess.check_call(pkg_resources_imp, cwd=str(pkg_A)) + + def test_packages_in_the_same_namespace_installed_and_cwd(self, tmpdir): + """ + Installing one namespace package and also have another in the same + namespace in the current working directory, both of them must be + importable. + """ + pkg_A = namespaces.build_namespace_package(tmpdir, 'myns.pkgA') + pkg_B = namespaces.build_namespace_package(tmpdir, 'myns.pkgB') + target = tmpdir / 'packages' + # use pip to install to the target directory + install_cmd = [ + sys.executable, + '-m', + 'pip.__main__', + 'install', + str(pkg_A), + '-t', str(target), + ] + subprocess.check_call(install_cmd) + namespaces.make_site_dir(target) + + # ensure that all packages import and pkg_resources imports + pkg_resources_imp = [ + sys.executable, + '-c', 'import pkg_resources; import myns.pkgA; import myns.pkgB', + ] + with test.test.paths_on_pythonpath([str(target)]): + subprocess.check_call(pkg_resources_imp, cwd=str(pkg_B)) diff --git a/setuptools/tests/test_packageindex.py b/setuptools/tests/test_packageindex.py index 63b9294..8e9435e 100644 --- a/setuptools/tests/test_packageindex.py +++ b/setuptools/tests/test_packageindex.py @@ -1,15 +1,15 @@ -from __future__ import absolute_import - import sys import os import distutils.errors +import platform +import urllib.request +import urllib.error +import http.client -from setuptools.extern import six -from setuptools.extern.six.moves import urllib, http_client +import mock +import pytest -import pkg_resources import setuptools.package_index -from setuptools.tests.server import IndexServer from .textwrap import DALS @@ -42,7 +42,10 @@ class TestPackageIndex: hosts=('www.example.com',) ) - url = 'url:%20https://svn.plone.org/svn/collective/inquant.contentmirror.plone/trunk' + url = ( + 'url:%20https://svn.plone.org/svn' + '/collective/inquant.contentmirror.plone/trunk' + ) try: v = index.open_url(url) except Exception as v: @@ -56,14 +59,14 @@ class TestPackageIndex: ) def _urlopen(*args): - raise http_client.BadStatusLine('line') + raise http.client.BadStatusLine('line') index.opener = _urlopen url = 'http://example.com' try: - v = index.open_url(url) - except Exception as v: - assert 'line' in str(v) + index.open_url(url) + except Exception as exc: + assert 'line' in str(exc) else: raise AssertionError('Should have raise here!') @@ -80,8 +83,12 @@ class TestPackageIndex: try: index.open_url(url) except distutils.errors.DistutilsError as error: - msg = six.text_type(error) - assert 'nonnumeric port' in msg or 'getaddrinfo failed' in msg or 'Name or service not known' in msg + msg = str(error) + assert ( + 'nonnumeric port' in msg + or 'getaddrinfo failed' in msg + or 'Name or service not known' in msg + ) return raise RuntimeError("Did not raise") @@ -105,43 +112,6 @@ class TestPackageIndex: url = 'file:///tmp/test_package_index' assert index.url_ok(url, True) - def test_links_priority(self): - """ - Download links from the pypi simple index should be used before - external download links. - https://bitbucket.org/tarek/distribute/issue/163 - - Usecase : - - someone uploads a package on pypi, a md5 is generated - - someone manually copies this link (with the md5 in the url) onto an - external page accessible from the package page. - - someone reuploads the package (with a different md5) - - while easy_installing, an MD5 error occurs because the external link - is used - -> Setuptools should use the link from pypi, not the external one. - """ - if sys.platform.startswith('java'): - # Skip this test on jython because binding to :0 fails - return - - # start an index server - server = IndexServer() - server.start() - index_url = server.base_url() + 'test_links_priority/simple/' - - # scan a test index - pi = setuptools.package_index.PackageIndex(index_url) - requirement = pkg_resources.Requirement.parse('foobar') - pi.find_packages(requirement) - server.stop() - - # the distribution has been found - assert 'foobar' in pi - # we have only one link, because links are compared without md5 - assert len(pi['foobar']) == 1 - # the link should be from the index - assert 'correct_md5' in pi['foobar'][0].location - def test_parse_bdist_wininst(self): parse = setuptools.package_index.parse_bdist_wininst @@ -212,17 +182,72 @@ class TestPackageIndex: ('+ubuntu_0', '+ubuntu.0'), ] versions = [ - [''.join([e, r, p, l]) for l in ll] + [''.join([e, r, p, loc]) for loc in locs] for e in epoch for r in releases for p in sum([pre, post, dev], ['']) - for ll in local] + for locs in local] for v, vc in versions: dists = list(setuptools.package_index.distros_for_url( 'http://example.com/example.zip#egg=example-' + v)) assert dists[0].version == '' assert dists[1].version == vc + def test_download_git_with_rev(self, tmpdir): + url = 'git+https://github.example/group/project@master#egg=foo' + index = setuptools.package_index.PackageIndex() + + with mock.patch("os.system") as os_system_mock: + result = index.download(url, str(tmpdir)) + + os_system_mock.assert_called() + + expected_dir = str(tmpdir / 'project@master') + expected = ( + 'git clone --quiet ' + 'https://github.example/group/project {expected_dir}' + ).format(**locals()) + first_call_args = os_system_mock.call_args_list[0][0] + assert first_call_args == (expected,) + + tmpl = 'git -C {expected_dir} checkout --quiet master' + expected = tmpl.format(**locals()) + assert os_system_mock.call_args_list[1][0] == (expected,) + assert result == expected_dir + + def test_download_git_no_rev(self, tmpdir): + url = 'git+https://github.example/group/project#egg=foo' + index = setuptools.package_index.PackageIndex() + + with mock.patch("os.system") as os_system_mock: + result = index.download(url, str(tmpdir)) + + os_system_mock.assert_called() + + expected_dir = str(tmpdir / 'project') + expected = ( + 'git clone --quiet ' + 'https://github.example/group/project {expected_dir}' + ).format(**locals()) + os_system_mock.assert_called_once_with(expected) + + def test_download_svn(self, tmpdir): + url = 'svn+https://svn.example/project#egg=foo' + index = setuptools.package_index.PackageIndex() + + with pytest.warns(UserWarning): + with mock.patch("os.system") as os_system_mock: + result = index.download(url, str(tmpdir)) + + os_system_mock.assert_called() + + expected_dir = str(tmpdir / 'project') + expected = ( + 'svn checkout -q ' + 'svn+https://svn.example/project {expected_dir}' + ).format(**locals()) + os_system_mock.assert_called_once_with(expected) + class TestContentCheckers: def test_md5(self): @@ -258,17 +283,27 @@ class TestContentCheckers: assert rep == 'My message about md5' +@pytest.fixture +def temp_home(tmpdir, monkeypatch): + key = ( + 'USERPROFILE' + if platform.system() == 'Windows' and sys.version_info > (3, 8) else + 'HOME' + ) + + monkeypatch.setitem(os.environ, key, str(tmpdir)) + return tmpdir + + class TestPyPIConfig: - def test_percent_in_password(self, tmpdir, monkeypatch): - monkeypatch.setitem(os.environ, 'HOME', str(tmpdir)) - pypirc = tmpdir / '.pypirc' - with pypirc.open('w') as strm: - strm.write(DALS(""" - [pypi] - repository=https://pypi.org - username=jaraco - password=pity% - """)) + def test_percent_in_password(self, temp_home): + pypirc = temp_home / '.pypirc' + pypirc.write(DALS(""" + [pypi] + repository=https://pypi.org + username=jaraco + password=pity% + """)) cfg = setuptools.package_index.PyPIConfig() cred = cfg.creds_by_repository['https://pypi.org'] assert cred.username == 'jaraco' diff --git a/setuptools/tests/test_register.py b/setuptools/tests/test_register.py new file mode 100644 index 0000000..9860580 --- /dev/null +++ b/setuptools/tests/test_register.py @@ -0,0 +1,22 @@ +from setuptools.command.register import register +from setuptools.dist import Distribution +from setuptools.errors import RemovedCommandError + +try: + from unittest import mock +except ImportError: + import mock + +import pytest + + +class TestRegister: + def test_register_exception(self): + """Ensure that the register command has been properly removed.""" + dist = Distribution() + dist.dist_files = [(mock.Mock(), mock.Mock(), mock.Mock())] + + cmd = register(dist) + + with pytest.raises(RemovedCommandError): + cmd.run() diff --git a/setuptools/tests/test_sandbox.py b/setuptools/tests/test_sandbox.py index d867542..99398cd 100644 --- a/setuptools/tests/test_sandbox.py +++ b/setuptools/tests/test_sandbox.py @@ -26,7 +26,8 @@ class TestSandbox: """ It should be possible to execute a setup.py with a Byte Order Mark """ - target = pkg_resources.resource_filename(__name__, + target = pkg_resources.resource_filename( + __name__, 'script-with-bom.py') namespace = types.ModuleType('namespace') setuptools.sandbox._execfile(target, vars(namespace)) diff --git a/setuptools/tests/test_sdist.py b/setuptools/tests/test_sdist.py index 02222da..302cff7 100644 --- a/setuptools/tests/test_sdist.py +++ b/setuptools/tests/test_sdist.py @@ -1,27 +1,23 @@ -# -*- coding: utf-8 -*- """sdist tests""" import os -import shutil import sys import tempfile import unicodedata import contextlib import io - -from setuptools.extern import six -from setuptools.extern.six.moves import map +from unittest import mock import pytest -import pkg_resources +from setuptools._importlib import metadata +from setuptools import SetuptoolsDeprecationWarning from setuptools.command.sdist import sdist from setuptools.command.egg_info import manifest_maker from setuptools.dist import Distribution from setuptools.tests import fail_on_ascii from .text import Filenames -py3_only = pytest.mark.xfail(six.PY2, reason="Test runs on Python 3 only") SETUP_ATTRS = { 'name': 'sdist_test', @@ -41,7 +37,7 @@ setup(**%r) @contextlib.contextmanager def quiet(): old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout, sys.stderr = six.StringIO(), six.StringIO() + sys.stdout, sys.stderr = io.StringIO(), io.StringIO() try: yield finally: @@ -50,7 +46,7 @@ def quiet(): # Convert to POSIX path def posix(path): - if six.PY3 and not isinstance(path, str): + if not isinstance(path, str): return path.replace(os.sep.encode('ascii'), b'/') else: return path.replace(os.sep, '/') @@ -58,7 +54,7 @@ def posix(path): # HFS Plus uses decomposed UTF-8 def decompose(path): - if isinstance(path, six.text_type): + if isinstance(path, str): return unicodedata.normalize('NFD', path) try: path = path.decode('utf-8') @@ -89,31 +85,35 @@ fail_on_latin1_encoded_filenames = pytest.mark.xfail( ) +def touch(path): + path.write_text('', encoding='utf-8') + + class TestSdistTest: - def setup_method(self, method): - self.temp_dir = tempfile.mkdtemp() - f = open(os.path.join(self.temp_dir, 'setup.py'), 'w') - f.write(SETUP_PY) - f.close() + @pytest.fixture(autouse=True) + def source_dir(self, tmpdir): + (tmpdir / 'setup.py').write_text(SETUP_PY, encoding='utf-8') # Set up the rest of the test package - test_pkg = os.path.join(self.temp_dir, 'sdist_test') - os.mkdir(test_pkg) - data_folder = os.path.join(self.temp_dir, "d") - os.mkdir(data_folder) + test_pkg = tmpdir / 'sdist_test' + test_pkg.mkdir() + data_folder = tmpdir / 'd' + data_folder.mkdir() # *.rst was not included in package_data, so c.rst should not be # automatically added to the manifest when not under version control - for fname in ['__init__.py', 'a.txt', 'b.txt', 'c.rst', - os.path.join(data_folder, "e.dat")]: - # Just touch the files; their contents are irrelevant - open(os.path.join(test_pkg, fname), 'w').close() + for fname in ['__init__.py', 'a.txt', 'b.txt', 'c.rst']: + touch(test_pkg / fname) + touch(data_folder / 'e.dat') - self.old_cwd = os.getcwd() - os.chdir(self.temp_dir) + with tmpdir.as_cwd(): + yield - def teardown_method(self, method): - os.chdir(self.old_cwd) - shutil.rmtree(self.temp_dir) + def assert_package_data_in_manifest(self, cmd): + manifest = cmd.filelist.files + assert os.path.join('sdist_test', 'a.txt') in manifest + assert os.path.join('sdist_test', 'b.txt') in manifest + assert os.path.join('sdist_test', 'c.rst') not in manifest + assert os.path.join('d', 'e.dat') in manifest def test_package_data_in_sdist(self): """Regression test for pull request #4: ensures that files listed in @@ -129,20 +129,113 @@ class TestSdistTest: with quiet(): cmd.run() + self.assert_package_data_in_manifest(cmd) + + def test_package_data_and_include_package_data_in_sdist(self): + """ + Ensure package_data and include_package_data work + together. + """ + setup_attrs = {**SETUP_ATTRS, 'include_package_data': True} + assert setup_attrs['package_data'] + + dist = Distribution(setup_attrs) + dist.script_name = 'setup.py' + cmd = sdist(dist) + cmd.ensure_finalized() + + with quiet(): + cmd.run() + + self.assert_package_data_in_manifest(cmd) + + def test_custom_build_py(self): + """ + Ensure projects defining custom build_py don't break + when creating sdists (issue #2849) + """ + from distutils.command.build_py import build_py as OrigBuildPy + + using_custom_command_guard = mock.Mock() + + class CustomBuildPy(OrigBuildPy): + """ + Some projects have custom commands inheriting from `distutils` + """ + + def get_data_files(self): + using_custom_command_guard() + return super().get_data_files() + + setup_attrs = {**SETUP_ATTRS, 'include_package_data': True} + assert setup_attrs['package_data'] + + dist = Distribution(setup_attrs) + dist.script_name = 'setup.py' + cmd = sdist(dist) + cmd.ensure_finalized() + + # Make sure we use the custom command + cmd.cmdclass = {'build_py': CustomBuildPy} + cmd.distribution.cmdclass = {'build_py': CustomBuildPy} + assert cmd.distribution.get_command_class('build_py') == CustomBuildPy + + msg = "setuptools instead of distutils" + with quiet(), pytest.warns(SetuptoolsDeprecationWarning, match=msg): + cmd.run() + + using_custom_command_guard.assert_called() + self.assert_package_data_in_manifest(cmd) + + def test_setup_py_exists(self): + dist = Distribution(SETUP_ATTRS) + dist.script_name = 'foo.py' + cmd = sdist(dist) + cmd.ensure_finalized() + + with quiet(): + cmd.run() + manifest = cmd.filelist.files - assert os.path.join('sdist_test', 'a.txt') in manifest - assert os.path.join('sdist_test', 'b.txt') in manifest - assert os.path.join('sdist_test', 'c.rst') not in manifest - assert os.path.join('d', 'e.dat') in manifest + assert 'setup.py' in manifest + + def test_setup_py_missing(self): + dist = Distribution(SETUP_ATTRS) + dist.script_name = 'foo.py' + cmd = sdist(dist) + cmd.ensure_finalized() - def test_defaults_case_sensitivity(self): + if os.path.exists("setup.py"): + os.remove("setup.py") + with quiet(): + cmd.run() + + manifest = cmd.filelist.files + assert 'setup.py' not in manifest + + def test_setup_py_excluded(self): + with open("MANIFEST.in", "w") as manifest_file: + manifest_file.write("exclude setup.py") + + dist = Distribution(SETUP_ATTRS) + dist.script_name = 'foo.py' + cmd = sdist(dist) + cmd.ensure_finalized() + + with quiet(): + cmd.run() + + manifest = cmd.filelist.files + assert 'setup.py' not in manifest + + def test_defaults_case_sensitivity(self, tmpdir): """ Make sure default files (README.*, etc.) are added in a case-sensitive way to avoid problems with packages built on Windows. """ - open(os.path.join(self.temp_dir, 'readme.rst'), 'w').close() - open(os.path.join(self.temp_dir, 'SETUP.cfg'), 'w').close() + touch(tmpdir / 'readme.rst') + touch(tmpdir / 'SETUP.cfg') dist = Distribution(SETUP_ATTRS) # the extension deliberately capitalized for this test @@ -190,13 +283,8 @@ class TestSdistTest: u_contents = contents.decode('UTF-8') # The manifest should contain the UTF-8 filename - if six.PY2: - fs_enc = sys.getfilesystemencoding() - filename = filename.decode(fs_enc) - assert posix(filename) in u_contents - @py3_only @fail_on_ascii def test_write_manifest_allows_utf8_filenames(self): # Test for #303. @@ -230,7 +318,6 @@ class TestSdistTest: # The filelist should have been updated as well assert u_filename in mm.filelist.files - @py3_only def test_write_manifest_skips_non_utf8_filenames(self): """ Files that cannot be encoded to UTF-8 (specifically, those that @@ -294,11 +381,9 @@ class TestSdistTest: cmd.read_manifest() # The filelist should contain the UTF-8 filename - if six.PY3: - filename = filename.decode('utf-8') + filename = filename.decode('utf-8') assert filename in cmd.filelist.files - @py3_only @fail_on_latin1_encoded_filenames def test_read_manifest_skips_non_utf8_filenames(self): # Test for #303. @@ -334,7 +419,7 @@ class TestSdistTest: @fail_on_latin1_encoded_filenames def test_sdist_with_utf8_encoded_filename(self): # Test for #303. - dist = Distribution(SETUP_ATTRS) + dist = Distribution(self.make_strings(SETUP_ATTRS)) dist.script_name = 'setup.py' cmd = sdist(dist) cmd.ensure_finalized() @@ -348,27 +433,33 @@ class TestSdistTest: if sys.platform == 'darwin': filename = decompose(filename) - if six.PY3: - fs_enc = sys.getfilesystemencoding() + fs_enc = sys.getfilesystemencoding() - if sys.platform == 'win32': - if fs_enc == 'cp1252': - # Python 3 mangles the UTF-8 filename - filename = filename.decode('cp1252') - assert filename in cmd.filelist.files - else: - filename = filename.decode('mbcs') - assert filename in cmd.filelist.files + if sys.platform == 'win32': + if fs_enc == 'cp1252': + # Python mangles the UTF-8 filename + filename = filename.decode('cp1252') + assert filename in cmd.filelist.files else: - filename = filename.decode('utf-8') + filename = filename.decode('mbcs') assert filename in cmd.filelist.files else: + filename = filename.decode('utf-8') assert filename in cmd.filelist.files + @classmethod + def make_strings(cls, item): + if isinstance(item, dict): + return { + key: cls.make_strings(value) for key, value in item.items()} + if isinstance(item, list): + return list(map(cls.make_strings, item)) + return str(item) + @fail_on_latin1_encoded_filenames def test_sdist_with_latin1_encoded_filename(self): # Test for #303. - dist = Distribution(SETUP_ATTRS) + dist = Distribution(self.make_strings(SETUP_ATTRS)) dist.script_name = 'setup.py' cmd = sdist(dist) cmd.ensure_finalized() @@ -381,33 +472,50 @@ class TestSdistTest: with quiet(): cmd.run() - if six.PY3: - # not all windows systems have a default FS encoding of cp1252 - if sys.platform == 'win32': - # Latin-1 is similar to Windows-1252 however - # on mbcs filesys it is not in latin-1 encoding - fs_enc = sys.getfilesystemencoding() - if fs_enc != 'mbcs': - fs_enc = 'latin-1' - filename = filename.decode(fs_enc) + # not all windows systems have a default FS encoding of cp1252 + if sys.platform == 'win32': + # Latin-1 is similar to Windows-1252 however + # on mbcs filesys it is not in latin-1 encoding + fs_enc = sys.getfilesystemencoding() + if fs_enc != 'mbcs': + fs_enc = 'latin-1' + filename = filename.decode(fs_enc) - assert filename in cmd.filelist.files - else: - # The Latin-1 filename should have been skipped - filename = filename.decode('latin-1') - filename not in cmd.filelist.files + assert filename in cmd.filelist.files else: - # Under Python 2 there seems to be no decoded string in the - # filelist. However, due to decode and encoding of the - # file name to get utf-8 Manifest the latin1 maybe excluded - try: - # fs_enc should match how one is expect the decoding to - # be proformed for the manifest output. - fs_enc = sys.getfilesystemencoding() - filename.decode(fs_enc) - assert filename in cmd.filelist.files - except UnicodeDecodeError: - filename not in cmd.filelist.files + # The Latin-1 filename should have been skipped + filename = filename.decode('latin-1') + filename not in cmd.filelist.files + + def test_pyproject_toml_in_sdist(self, tmpdir): + """ + Check if pyproject.toml is included in source distribution if present + """ + touch(tmpdir / 'pyproject.toml') + dist = Distribution(SETUP_ATTRS) + dist.script_name = 'setup.py' + cmd = sdist(dist) + cmd.ensure_finalized() + with quiet(): + cmd.run() + manifest = cmd.filelist.files + assert 'pyproject.toml' in manifest + + def test_pyproject_toml_excluded(self, tmpdir): + """ + Check that pyproject.toml can excluded even if present + """ + touch(tmpdir / 'pyproject.toml') + with open('MANIFEST.in', 'w') as mts: + print('exclude pyproject.toml', file=mts) + dist = Distribution(SETUP_ATTRS) + dist.script_name = 'setup.py' + cmd = sdist(dist) + cmd.ensure_finalized() + with quiet(): + cmd.run() + manifest = cmd.filelist.files + assert 'pyproject.toml' not in manifest def test_default_revctrl(): @@ -421,7 +529,9 @@ def test_default_revctrl(): This interface must be maintained until Ubuntu 12.04 is no longer supported (by Setuptools). """ - ep_def = 'svn_cvs = setuptools.command.sdist:_default_revctrl' - ep = pkg_resources.EntryPoint.parse(ep_def) - res = ep.resolve() + ep, = metadata.EntryPoints._from_text(""" + [setuptools.file_finders] + svn_cvs = setuptools.command.sdist:_default_revctrl + """) + res = ep.load() assert hasattr(res, '__iter__') diff --git a/setuptools/tests/test_setopt.py b/setuptools/tests/test_setopt.py new file mode 100644 index 0000000..3600863 --- /dev/null +++ b/setuptools/tests/test_setopt.py @@ -0,0 +1,41 @@ +import io +import configparser + +from setuptools.command import setopt + + +class TestEdit: + @staticmethod + def parse_config(filename): + parser = configparser.ConfigParser() + with io.open(filename, encoding='utf-8') as reader: + parser.read_file(reader) + return parser + + @staticmethod + def write_text(file, content): + with io.open(file, 'wb') as strm: + strm.write(content.encode('utf-8')) + + def test_utf8_encoding_retained(self, tmpdir): + """ + When editing a file, non-ASCII characters encoded in + UTF-8 should be retained. + """ + config = tmpdir.join('setup.cfg') + self.write_text(str(config), '[names]\njaraco=джарако') + setopt.edit_config(str(config), dict(names=dict(other='yes'))) + parser = self.parse_config(str(config)) + assert parser.get('names', 'jaraco') == 'джарако' + assert parser.get('names', 'other') == 'yes' + + def test_case_retained(self, tmpdir): + """ + When editing a file, case of keys should be retained. + """ + config = tmpdir.join('setup.cfg') + self.write_text(str(config), '[names]\nFoO=bAr') + setopt.edit_config(str(config), dict(names=dict(oTher='yes'))) + actual = config.read_text(encoding='ascii') + assert 'FoO' in actual + assert 'oTher' in actual diff --git a/setuptools/tests/test_setuptools.py b/setuptools/tests/test_setuptools.py index 26e37a6..0640f49 100644 --- a/setuptools/tests/test_setuptools.py +++ b/setuptools/tests/test_setuptools.py @@ -4,19 +4,24 @@ import sys import os import distutils.core import distutils.cmd -from distutils.errors import DistutilsOptionError, DistutilsPlatformError +from distutils.errors import DistutilsOptionError from distutils.errors import DistutilsSetupError from distutils.core import Extension -from distutils.version import LooseVersion +from zipfile import ZipFile import pytest +from setuptools.extern.packaging import version + import setuptools import setuptools.dist import setuptools.depends as dep -from setuptools import Feature from setuptools.depends import Require -from setuptools.extern import six + + +@pytest.fixture(autouse=True) +def isolated_dir(tmpdir_cwd): + yield def makeSetup(**args): @@ -50,7 +55,7 @@ class TestDepends: x = "test" y = z - fc = six.get_function_code(f1) + fc = f1.__code__ # unrecognized name assert dep.extract_constant(fc, 'q', -1) is None @@ -77,7 +82,8 @@ class TestDepends: from json import __version__ assert dep.get_module_constant('json', '__version__') == __version__ assert dep.get_module_constant('sys', 'version') == sys.version - assert dep.get_module_constant('setuptools.tests.test_setuptools', '__doc__') == __doc__ + assert dep.get_module_constant( + 'setuptools.tests.test_setuptools', '__doc__') == __doc__ @needs_bytecode def testRequire(self): @@ -85,12 +91,12 @@ class TestDepends: assert req.name == 'Json' assert req.module == 'json' - assert req.requested_version == '1.0.3' + assert req.requested_version == version.Version('1.0.3') assert req.attribute == '__version__' assert req.full_name() == 'Json-1.0.3' from json import __version__ - assert req.get_version() == __version__ + assert str(req.get_version()) == __version__ assert req.version_ok('1.0.9') assert not req.version_ok('0.9.1') assert not req.version_ok('unknown') @@ -98,15 +104,15 @@ class TestDepends: assert req.is_present() assert req.is_current() - req = Require('Json 3000', '03000', 'json', format=LooseVersion) - assert req.is_present() - assert not req.is_current() - assert not req.version_ok('unknown') - req = Require('Do-what-I-mean', '1.0', 'd-w-i-m') assert not req.is_present() assert not req.is_current() + @needs_bytecode + def test_require_present(self): + # In #1896, this test was failing for months with the only + # complaint coming from test runners (not end users). + # TODO: Evaluate if this code is needed at all. req = Require('Tests', None, 'tests', homepage="http://example.com") assert req.format is None assert req.attribute is None @@ -210,83 +216,6 @@ class TestDistro: self.dist.exclude(package_dir=['q']) -class TestFeatures: - def setup_method(self, method): - self.req = Require('Distutils', '1.0.3', 'distutils') - self.dist = makeSetup( - features={ - 'foo': Feature("foo", standard=True, require_features=['baz', self.req]), - 'bar': Feature("bar", standard=True, packages=['pkg.bar'], - py_modules=['bar_et'], remove=['bar.ext'], - ), - 'baz': Feature( - "baz", optional=False, packages=['pkg.baz'], - scripts=['scripts/baz_it'], - libraries=[('libfoo', 'foo/foofoo.c')] - ), - 'dwim': Feature("DWIM", available=False, remove='bazish'), - }, - script_args=['--without-bar', 'install'], - packages=['pkg.bar', 'pkg.foo'], - py_modules=['bar_et', 'bazish'], - ext_modules=[Extension('bar.ext', ['bar.c'])] - ) - - def testDefaults(self): - assert not Feature( - "test", standard=True, remove='x', available=False - ).include_by_default() - assert Feature("test", standard=True, remove='x').include_by_default() - # Feature must have either kwargs, removes, or require_features - with pytest.raises(DistutilsSetupError): - Feature("test") - - def testAvailability(self): - with pytest.raises(DistutilsPlatformError): - self.dist.features['dwim'].include_in(self.dist) - - def testFeatureOptions(self): - dist = self.dist - assert ( - ('with-dwim', None, 'include DWIM') in dist.feature_options - ) - assert ( - ('without-dwim', None, 'exclude DWIM (default)') in dist.feature_options - ) - assert ( - ('with-bar', None, 'include bar (default)') in dist.feature_options - ) - assert ( - ('without-bar', None, 'exclude bar') in dist.feature_options - ) - assert dist.feature_negopt['without-foo'] == 'with-foo' - assert dist.feature_negopt['without-bar'] == 'with-bar' - assert dist.feature_negopt['without-dwim'] == 'with-dwim' - assert ('without-baz' not in dist.feature_negopt) - - def testUseFeatures(self): - dist = self.dist - assert dist.with_foo == 1 - assert dist.with_bar == 0 - assert dist.with_baz == 1 - assert ('bar_et' not in dist.py_modules) - assert ('pkg.bar' not in dist.packages) - assert ('pkg.baz' in dist.packages) - assert ('scripts/baz_it' in dist.scripts) - assert (('libfoo', 'foo/foofoo.c') in dist.libraries) - assert dist.ext_modules == [] - assert dist.require_features == [self.req] - - # If we ask for bar, it should fail because we explicitly disabled - # it on the command line - with pytest.raises(DistutilsOptionError): - dist.include_feature('bar') - - def testFeatureWithInvalidRemove(self): - with pytest.raises(SystemExit): - makeSetup(features={'x': Feature('x', remove='y')}) - - class TestCommandTests: def testTestIsCommand(self): test_cmd = makeSetup().get_command_obj('test') @@ -366,3 +295,16 @@ def test_findall_missing_symlink(tmpdir, can_symlink): os.symlink('foo', 'bar') found = list(setuptools.findall()) assert found == [] + + +def test_its_own_wheel_does_not_contain_tests(setuptools_wheel): + with ZipFile(setuptools_wheel) as zipfile: + contents = [f.replace(os.sep, '/') for f in zipfile.namelist()] + + for member in contents: + assert '/tests/' not in member + + +def test_convert_path_deprecated(): + with pytest.warns(setuptools.SetuptoolsDeprecationWarning): + setuptools.convert_path('setuptools/tests') diff --git a/setuptools/tests/test_test.py b/setuptools/tests/test_test.py index 960527b..530474d 100644 --- a/setuptools/tests/test_test.py +++ b/setuptools/tests/test_test.py @@ -1,131 +1,40 @@ -# -*- coding: UTF-8 -*- - -from __future__ import unicode_literals - -from distutils import log -import os -import sys - import pytest +from jaraco import path from setuptools.command.test import test from setuptools.dist import Distribution from .textwrap import DALS -from . import contexts - -SETUP_PY = DALS(""" - from setuptools import setup - - setup(name='foo', - packages=['name', 'name.space', 'name.space.tests'], - namespace_packages=['name'], - test_suite='name.space.tests.test_suite', - ) - """) - -NS_INIT = DALS(""" - # -*- coding: Latin-1 -*- - # Söme Arbiträry Ünicode to test Distribute Issüé 310 - try: - __import__('pkg_resources').declare_namespace(__name__) - except ImportError: - from pkgutil import extend_path - __path__ = extend_path(__path__, __name__) - """) - -TEST_PY = DALS(""" - import unittest - - class TestTest(unittest.TestCase): - def test_test(self): - print "Foo" # Should fail under Python 3 unless 2to3 is used - - test_suite = unittest.makeSuite(TestTest) - """) - - -@pytest.fixture -def sample_test(tmpdir_cwd): - os.makedirs('name/space/tests') - - # setup.py - with open('setup.py', 'wt') as f: - f.write(SETUP_PY) - - # name/__init__.py - with open('name/__init__.py', 'wb') as f: - f.write(NS_INIT.encode('Latin-1')) - - # name/space/__init__.py - with open('name/space/__init__.py', 'wt') as f: - f.write('#empty\n') - - # name/space/tests/__init__.py - with open('name/space/tests/__init__.py', 'wt') as f: - f.write(TEST_PY) - - -@pytest.fixture -def quiet_log(): - # Running some of the other tests will automatically - # change the log level to info, messing our output. - log.set_verbosity(0) - - -@pytest.mark.usefixtures('sample_test', 'quiet_log') -def test_test(capfd): - params = dict( - name='foo', - packages=['name', 'name.space', 'name.space.tests'], - namespace_packages=['name'], - test_suite='name.space.tests.test_suite', - use_2to3=True, - ) - dist = Distribution(params) - dist.script_name = 'setup.py' - cmd = test(dist) - cmd.ensure_finalized() - # The test runner calls sys.exit - with contexts.suppress_exceptions(SystemExit): - cmd.run() - out, err = capfd.readouterr() - assert out == 'Foo\n' -@pytest.mark.xfail( - sys.version_info < (2, 7), - reason="No discover support for unittest on Python 2.6", -) -@pytest.mark.usefixtures('tmpdir_cwd', 'quiet_log') +@pytest.mark.usefixtures('tmpdir_cwd') def test_tests_are_run_once(capfd): params = dict( - name='foo', packages=['dummy'], ) - with open('setup.py', 'wt') as f: - f.write('from setuptools import setup; setup(\n') - for k, v in sorted(params.items()): - f.write(' %s=%r,\n' % (k, v)) - f.write(')\n') - os.makedirs('dummy') - with open('dummy/__init__.py', 'wt'): - pass - with open('dummy/test_dummy.py', 'wt') as f: - f.write(DALS( - """ - from __future__ import print_function - import unittest - class TestTest(unittest.TestCase): - def test_test(self): - print('Foo') - """)) + files = { + 'setup.py': + 'from setuptools import setup; setup(' + + ','.join(f'{name}={params[name]!r}' for name in params) + + ')', + 'dummy': { + '__init__.py': '', + 'test_dummy.py': DALS( + """ + import unittest + class TestTest(unittest.TestCase): + def test_test(self): + print('Foo') + """ + ), + }, + } + path.build(files) dist = Distribution(params) dist.script_name = 'setup.py' cmd = test(dist) cmd.ensure_finalized() - # The test runner calls sys.exit - with contexts.suppress_exceptions(SystemExit): - cmd.run() + cmd.run() out, err = capfd.readouterr() - assert out == 'Foo\n' + assert out.endswith('Foo\n') + assert len(out.split('Foo')) == 2 diff --git a/setuptools/tests/test_upload.py b/setuptools/tests/test_upload.py new file mode 100644 index 0000000..7586cb2 --- /dev/null +++ b/setuptools/tests/test_upload.py @@ -0,0 +1,22 @@ +from setuptools.command.upload import upload +from setuptools.dist import Distribution +from setuptools.errors import RemovedCommandError + +try: + from unittest import mock +except ImportError: + import mock + +import pytest + + +class TestUpload: + def test_upload_exception(self): + """Ensure that the register command has been properly removed.""" + dist = Distribution() + dist.dist_files = [(mock.Mock(), mock.Mock(), mock.Mock())] + + cmd = upload(dist) + + with pytest.raises(RemovedCommandError): + cmd.run() diff --git a/setuptools/tests/test_upload_docs.py b/setuptools/tests/test_upload_docs.py deleted file mode 100644 index a26e32a..0000000 --- a/setuptools/tests/test_upload_docs.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import zipfile -import contextlib - -import pytest - -from setuptools.command.upload_docs import upload_docs -from setuptools.dist import Distribution - -from .textwrap import DALS -from . import contexts - -SETUP_PY = DALS( - """ - from setuptools import setup - - setup(name='foo') - """) - - -@pytest.fixture -def sample_project(tmpdir_cwd): - # setup.py - with open('setup.py', 'wt') as f: - f.write(SETUP_PY) - - os.mkdir('build') - - # A test document. - with open('build/index.html', 'w') as f: - f.write("Hello world.") - - # An empty folder. - os.mkdir('build/empty') - - -@pytest.mark.usefixtures('sample_project') -@pytest.mark.usefixtures('user_override') -class TestUploadDocsTest: - def test_create_zipfile(self): - """ - Ensure zipfile creation handles common cases, including a folder - containing an empty folder. - """ - - dist = Distribution() - - cmd = upload_docs(dist) - cmd.target_dir = cmd.upload_dir = 'build' - with contexts.tempdir() as tmp_dir: - tmp_file = os.path.join(tmp_dir, 'foo.zip') - zip_file = cmd.create_zipfile(tmp_file) - - assert zipfile.is_zipfile(tmp_file) - - with contextlib.closing(zipfile.ZipFile(tmp_file)) as zip_file: - assert zip_file.namelist() == ['index.html'] - - def test_build_multipart(self): - data = dict( - a="foo", - b="bar", - file=('file.txt', b'content'), - ) - body, content_type = upload_docs._build_multipart(data) - assert 'form-data' in content_type - assert "b'" not in content_type - assert 'b"' not in content_type - assert isinstance(body, bytes) - assert b'foo' in body - assert b'content' in body diff --git a/setuptools/tests/test_virtualenv.py b/setuptools/tests/test_virtualenv.py index b66a311..6535854 100644 --- a/setuptools/tests/test_virtualenv.py +++ b/setuptools/tests/test_virtualenv.py @@ -1,94 +1,101 @@ -import glob import os import sys +import subprocess +from urllib.request import urlopen +from urllib.error import URLError -import pytest -from pytest import yield_fixture -from pytest_fixture_config import yield_requires_config +import pathlib -import pytest_virtualenv +import pytest +from . import contexts from .textwrap import DALS from .test_easy_install import make_nspkg_sdist @pytest.fixture(autouse=True) -def pytest_virtualenv_works(virtualenv): +def pytest_virtualenv_works(venv): """ pytest_virtualenv may not work. if it doesn't, skip these tests. See #1284. """ - venv_prefix = virtualenv.run( - 'python -c "import sys; print(sys.prefix)"', - capture=True, - ).strip() + venv_prefix = venv.run(["python" , "-c", "import sys; print(sys.prefix)"]).strip() if venv_prefix == sys.prefix: pytest.skip("virtualenv is broken (see pypa/setuptools#1284)") -@yield_requires_config(pytest_virtualenv.CONFIG, ['virtualenv_executable']) -@yield_fixture(scope='function') -def bare_virtualenv(): - """ Bare virtualenv (no pip/setuptools/wheel). - """ - with pytest_virtualenv.VirtualEnv(args=( - '--no-wheel', - '--no-pip', - '--no-setuptools', - )) as venv: - yield venv - - -SOURCE_DIR = os.path.join(os.path.dirname(__file__), '../..') - - -def test_clean_env_install(bare_virtualenv): +def test_clean_env_install(venv_without_setuptools, setuptools_wheel): """ Check setuptools can be installed in a clean environment. """ - bare_virtualenv.run(' && '.join(( - 'cd {source}', - 'python setup.py install', - )).format(source=SOURCE_DIR)) - - -def test_pip_upgrade_from_source(virtualenv): + cmd = ["python", "-m", "pip", "install", str(setuptools_wheel)] + venv_without_setuptools.run(cmd) + + +def access_pypi(): + # Detect if tests are being run without connectivity + if not os.environ.get('NETWORK_REQUIRED', False): # pragma: nocover + try: + urlopen('https://pypi.org', timeout=1) + except URLError: + # No network, disable most of these tests + return False + + return True + + +@pytest.mark.skipif( + 'platform.python_implementation() == "PyPy"', + reason="https://github.com/pypa/setuptools/pull/2865#issuecomment-965834995", +) +@pytest.mark.skipif(not access_pypi(), reason="no network") +# ^-- Even when it is not necessary to install a different version of `pip` +# the build process will still try to download `wheel`, see #3147 and #2986. +@pytest.mark.parametrize( + 'pip_version', + [ + None, + pytest.param('pip<20', marks=pytest.mark.xfail(reason='pypa/pip#6599')), + 'pip<20.1', + 'pip<21', + 'pip<22', + pytest.param( + 'https://github.com/pypa/pip/archive/main.zip', + marks=pytest.mark.xfail(reason='#2975'), + ), + ] +) +def test_pip_upgrade_from_source(pip_version, venv_without_setuptools, + setuptools_wheel, setuptools_sdist): """ Check pip can upgrade setuptools from source. """ - dist_dir = virtualenv.workspace - if sys.version_info < (2, 7): - # Python 2.6 support was dropped in wheel 0.30.0. - virtualenv.run('pip install -U "wheel<0.30.0"') - # Generate source distribution / wheel. - virtualenv.run(' && '.join(( - 'cd {source}', - 'python setup.py -q sdist -d {dist}', - 'python setup.py -q bdist_wheel -d {dist}', - )).format(source=SOURCE_DIR, dist=dist_dir)) - sdist = glob.glob(os.path.join(dist_dir, '*.zip'))[0] - wheel = glob.glob(os.path.join(dist_dir, '*.whl'))[0] - # Then update from wheel. - virtualenv.run('pip install ' + wheel) + # Install pip/wheel, in a venv without setuptools (as it + # should not be needed for bootstraping from source) + venv = venv_without_setuptools + venv.run(["pip", "install", "-U", "wheel"]) + if pip_version is not None: + venv.run(["python", "-m", "pip", "install", "-U", pip_version, "--retries=1"]) + with pytest.raises(subprocess.CalledProcessError): + # Meta-test to make sure setuptools is not installed + venv.run(["python", "-c", "import setuptools"]) + + # Then install from wheel. + venv.run(["pip", "install", str(setuptools_wheel)]) # And finally try to upgrade from source. - virtualenv.run('pip install --no-cache-dir --upgrade ' + sdist) + venv.run(["pip", "install", "--no-cache-dir", "--upgrade", str(setuptools_sdist)]) -def test_test_command_install_requirements(bare_virtualenv, tmpdir): +def _check_test_command_install_requirements(venv, tmpdir): """ Check the test command will install all required dependencies. """ - bare_virtualenv.run(' && '.join(( - 'cd {source}', - 'python setup.py develop', - )).format(source=SOURCE_DIR)) - def sdist(distname, version): dist_path = tmpdir.join('%s-%s.tar.gz' % (distname, version)) make_nspkg_sdist(str(dist_path), distname, version) return dist_path dependency_links = [ - str(dist_path) + pathlib.Path(str(dist_path)).as_uri() for dist_path in ( sdist('foobar', '2.4'), sdist('bits', '4.2'), @@ -131,9 +138,24 @@ def test_test_command_install_requirements(bare_virtualenv, tmpdir): open('success', 'w').close() ''')) - # Run test command for test package. - bare_virtualenv.run(' && '.join(( - 'cd {tmpdir}', - 'python setup.py test -s test', - )).format(tmpdir=tmpdir)) + + cmd = ["python", 'setup.py', 'test', '-s', 'test'] + venv.run(cmd, cwd=str(tmpdir)) assert tmpdir.join('success').check() + + +def test_test_command_install_requirements(venv, tmpdir, tmpdir_cwd): + # Ensure pip/wheel packages are installed. + venv.run(["python", "-c", "__import__('pkg_resources').require(['pip', 'wheel'])"]) + # disable index URL so bits and bobs aren't requested from PyPI + with contexts.environment(PYTHONPATH=None, PIP_NO_INDEX="1"): + _check_test_command_install_requirements(venv, tmpdir) + + +def test_no_missing_dependencies(bare_venv, request): + """ + Quick and dirty test to ensure all external dependencies are vendored. + """ + setuptools_dir = request.config.rootdir + for command in ('upload',): # sorted(distutils.command.__all__): + bare_venv.run(['python', 'setup.py', command, '-h'], cwd=setuptools_dir) diff --git a/setuptools/tests/test_wheel.py b/setuptools/tests/test_wheel.py index 150ac4c..a15c3a4 100644 --- a/setuptools/tests/test_wheel.py +++ b/setuptools/tests/test_wheel.py @@ -9,16 +9,20 @@ import contextlib import glob import inspect import os +import shutil import subprocess import sys +import zipfile import pytest +from jaraco import path from pkg_resources import Distribution, PathMetadata, PY_MAJOR +from setuptools.extern.packaging.utils import canonicalize_name +from setuptools.extern.packaging.tags import parse_tag from setuptools.wheel import Wheel from .contexts import tempdir -from .files import build_files from .textwrap import DALS @@ -58,6 +62,7 @@ WHEEL_INFO_TESTS = ( }), ) + @pytest.mark.parametrize( ('filename', 'info'), WHEEL_INFO_TESTS, ids=[t[0] for t in WHEEL_INFO_TESTS] @@ -86,7 +91,7 @@ def build_wheel(extra_file_defs=None, **kwargs): if extra_file_defs: file_defs.update(extra_file_defs) with tempdir() as source_dir: - build_files(file_defs, source_dir) + path.build(file_defs, source_dir) subprocess.check_call((sys.executable, 'setup.py', '-q', 'bdist_wheel'), cwd=source_dir) yield glob.glob(os.path.join(source_dir, 'dist', '*.whl'))[0] @@ -118,11 +123,12 @@ def flatten_tree(tree): def format_install_tree(tree): - return {x.format( - py_version=PY_MAJOR, - platform=get_platform(), - shlib_ext=get_config_var('EXT_SUFFIX') or get_config_var('SO')) - for x in tree} + return { + x.format( + py_version=PY_MAJOR, + platform=get_platform(), + shlib_ext=get_config_var('EXT_SUFFIX') or get_config_var('SO')) + for x in tree} def _check_wheel_install(filename, install_dir, install_tree_includes, @@ -142,10 +148,11 @@ def _check_wheel_install(filename, install_dir, install_tree_includes, if requires_txt is None: assert not dist.has_metadata('requires.txt') else: + # Order must match to ensure reproducibility. assert requires_txt == dist.get_metadata('requires.txt').lstrip() -class Record(object): +class Record: def __init__(self, id, **kwargs): self._id = id @@ -414,6 +421,38 @@ WHEEL_INSTALL_TESTS = ( ), dict( + id='requires_ensure_order', + install_requires=''' + foo + bar + baz + qux + ''', + extras_require={ + 'extra': ''' + foobar>3 + barbaz>4 + bazqux>5 + quxzap>6 + ''', + }, + requires_txt=DALS( + ''' + foo + bar + baz + qux + + [extra] + foobar>3 + barbaz>4 + bazqux>5 + quxzap>6 + ''' + ), + ), + + dict( id='namespace_package', file_defs={ 'foo': { @@ -445,6 +484,35 @@ WHEEL_INSTALL_TESTS = ( ), dict( + id='empty_namespace_package', + file_defs={ + 'foobar': { + '__init__.py': + "__import__('pkg_resources').declare_namespace(__name__)", + }, + }, + setup_kwargs=dict( + namespace_packages=['foobar'], + packages=['foobar'], + ), + install_tree=flatten_tree({ + 'foo-1.0-py{py_version}.egg': [ + 'foo-1.0-py{py_version}-nspkg.pth', + {'EGG-INFO': [ + 'PKG-INFO', + 'RECORD', + 'WHEEL', + 'namespace_packages.txt', + 'top_level.txt', + ]}, + {'foobar': [ + '__init__.py', + ]}, + ] + }), + ), + + dict( id='data_in_package', file_defs={ 'foo': { @@ -482,6 +550,7 @@ WHEEL_INSTALL_TESTS = ( ) + @pytest.mark.parametrize( 'params', WHEEL_INSTALL_TESTS, ids=list(params['id'] for params in WHEEL_INSTALL_TESTS), @@ -506,3 +575,42 @@ def test_wheel_install(params): _check_wheel_install(filename, install_dir, install_tree, project_name, version, requires_txt) + + +def test_wheel_install_pep_503(): + project_name = 'Foo_Bar' # PEP 503 canonicalized name is "foo-bar" + version = '1.0' + with build_wheel( + name=project_name, + version=version, + ) as filename, tempdir() as install_dir: + new_filename = filename.replace(project_name, + canonicalize_name(project_name)) + shutil.move(filename, new_filename) + _check_wheel_install(new_filename, install_dir, None, + canonicalize_name(project_name), + version, None) + + +def test_wheel_no_dist_dir(): + project_name = 'nodistinfo' + version = '1.0' + wheel_name = '{0}-{1}-py2.py3-none-any.whl'.format(project_name, version) + with tempdir() as source_dir: + wheel_path = os.path.join(source_dir, wheel_name) + # create an empty zip file + zipfile.ZipFile(wheel_path, 'w').close() + with tempdir() as install_dir: + with pytest.raises(ValueError): + _check_wheel_install(wheel_path, install_dir, None, + project_name, + version, None) + + +def test_wheel_is_compatible(monkeypatch): + def sys_tags(): + for t in parse_tag('cp36-cp36m-manylinux1_x86_64'): + yield t + monkeypatch.setattr('setuptools.wheel.sys_tags', sys_tags) + assert Wheel( + 'onnxruntime-0.1.2-cp36-cp36m-manylinux1_x86_64.whl').is_compatible() diff --git a/setuptools/tests/test_windows_wrappers.py b/setuptools/tests/test_windows_wrappers.py index d2871c0..8ac9bd0 100644 --- a/setuptools/tests/test_windows_wrappers.py +++ b/setuptools/tests/test_windows_wrappers.py @@ -12,9 +12,8 @@ the script they are to wrap and with the same name as the script they are to wrap. """ -from __future__ import absolute_import - import sys +import platform import textwrap import subprocess @@ -53,10 +52,20 @@ class WrapperTester: f.write(w) +def win_launcher_exe(prefix): + """ A simple routine to select launcher script based on platform.""" + assert prefix in ('cli', 'gui') + if platform.machine() == "ARM64": + return "{}-arm64.exe".format(prefix) + else: + return "{}-32.exe".format(prefix) + + class TestCLI(WrapperTester): script_name = 'foo-script.py' - wrapper_source = 'cli-32.exe' wrapper_name = 'foo.exe' + wrapper_source = win_launcher_exe('cli') + script_tmpl = textwrap.dedent(""" #!%(python_exe)s import sys @@ -97,7 +106,8 @@ class TestCLI(WrapperTester): 'arg 4\\', 'arg5 a\\\\b', ] - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE) + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE) stdout, stderr = proc.communicate('hello\nworld\n'.encode('ascii')) actual = stdout.decode('ascii').replace('\r\n', '\n') expected = textwrap.dedent(r""" @@ -134,7 +144,11 @@ class TestCLI(WrapperTester): with (tmpdir / 'foo-script.py').open('w') as f: f.write(self.prep_script(tmpl)) cmd = [str(tmpdir / 'foo.exe')] - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.STDOUT) stdout, stderr = proc.communicate() actual = stdout.decode('ascii').replace('\r\n', '\n') expected = textwrap.dedent(r""" @@ -152,7 +166,7 @@ class TestGUI(WrapperTester): ----------------------- """ script_name = 'bar-script.pyw' - wrapper_source = 'gui-32.exe' + wrapper_source = win_launcher_exe('gui') wrapper_name = 'bar.exe' script_tmpl = textwrap.dedent(""" @@ -164,7 +178,7 @@ class TestGUI(WrapperTester): """).strip() def test_basic(self, tmpdir): - """Test the GUI version with the simple scipt, bar-script.py""" + """Test the GUI version with the simple script, bar-script.py""" self.create_script(tmpdir) cmd = [ @@ -172,7 +186,9 @@ class TestGUI(WrapperTester): str(tmpdir / 'test_output.txt'), 'Test Argument', ] - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, + stderr=subprocess.STDOUT) stdout, stderr = proc.communicate() assert not stdout assert not stderr diff --git a/setuptools/tests/text.py b/setuptools/tests/text.py index ad2c624..e05cc63 100644 --- a/setuptools/tests/text.py +++ b/setuptools/tests/text.py @@ -1,8 +1,3 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals - - class Filenames: unicode = 'smörbröd.py' latin_1 = unicode.encode('latin-1') diff --git a/setuptools/tests/textwrap.py b/setuptools/tests/textwrap.py index 5cd9e5b..5e39618 100644 --- a/setuptools/tests/textwrap.py +++ b/setuptools/tests/textwrap.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import textwrap |