diff --git a/HISTORY.rst b/HISTORY.rst index 93fce21..e9a7376 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,15 @@ Release notes ============= +3.8.0 (2025-03-25) +------------------ + +* Stopped using ``pkg_resources``. + +* Removed ``--deptree`` flag from redefined ``check`` command, this way of visualizing + a dependency tree is not useful anymore, please use ``uv pip tree`` or hdeps_ instead. + + 3.7.2 (2025-03-20) ------------------ @@ -544,3 +553,5 @@ Release notes .. _report: https://github.com/codrsquad/setupmeta/issues .. _pip-compile: https://pypi.org/project/pip-tools/ + +.. _hdeps: https://pypi.org/project/hdeps/ diff --git a/docs/versioning.rst b/docs/versioning.rst index a34f8c8..01c35c3 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -113,8 +113,8 @@ here for illustration purposes. In general, you should simply use ``versioning=" (or any other format you like). You could leverage this ``__version__`` possibility if you have specific use case for that -(like: you'd like to show which version your code is at without using something like -``import pkg_resources``) +(like: you'd like to show which version your code is at without having to do any +dynamic query) Preconfigured formats diff --git a/setupmeta/__init__.py b/setupmeta/__init__.py index 80f26ba..bdaa766 100644 --- a/setupmeta/__init__.py +++ b/setupmeta/__init__.py @@ -6,7 +6,6 @@ author: Zoran Simic zoran@simicweb.com """ -import io import os import platform import re @@ -18,14 +17,6 @@ import setuptools -try: - import pkg_resources - -except ImportError: # pragma: no cover - warnings.warn("pkg_resources is not available, expect limited functionality", category=RuntimeWarning) - pkg_resources = None - - USER_HOME = os.path.expanduser("~") # Used to pretty-print subfolders of ~ DEBUG = os.environ.get("SETUPMETA_DEBUG") VERSION_FILE = ".setupmeta.version" # File used to work with projects that are in a subfolder of a git checkout @@ -40,7 +31,7 @@ # Simplistic parsing of known formats used in requirements.txt RE_SIMPLE_PIN = re.compile(r"^(%s)\s*==\s*([^;\s]+)\s*(;.*)?$" % PKGID) -RE_WORDS = re.compile(r"[^\w]+") +RE_WORDS = re.compile(r"\W+") RE_PKG_NAME = re.compile(r"^(%s)$" % PKGID) ABSTRACT = "abstract" @@ -67,19 +58,6 @@ def trace(message): sys.stderr.flush() -def pkg_req(text): - """ - :param str|None text: Text to parse - :return pkg_resources.Requirement|None: Corresponding parsed requirement, if valid - """ - if text: - try: - return pkg_resources.Requirement(text) - - except Exception: - return None - - def get_words(text): if text: return [s.strip() for s in RE_WORDS.split(text) if s.strip()] @@ -170,7 +148,7 @@ def version_components(text): component = "%s%s" % (qualifier, component) qualifier = "" - additional.append(component) + additional.append(str(component)) while len(main_triplet) < 3: main_triplet.append(0) @@ -183,7 +161,6 @@ def version_components(text): additional.append(qualifier) dirty = "dirty" in additional - return main_triplet[0], main_triplet[1], main_triplet[2], ".".join(additional), distance, dirty @@ -316,7 +293,7 @@ def _should_ignore_run_fail(program, args, error): return False if args[0] in ("rev-list", "rev-parse") and "HEAD" in args: - # No commits yet, brand new git repo + # No commits yet, brand-new git repo return error and "revision" in error.lower() if args[0] == "describe": @@ -418,7 +395,7 @@ def readlines(relative_path, limit=0): try: result = [] full_path = project_path(relative_path) - with io.open(full_path, "rt") as fh: + with open(full_path, "rt") as fh: for line in fh: limit -= 1 if limit == 0: diff --git a/setupmeta/commands.py b/setupmeta/commands.py index e8c9245..e4f6b44 100644 --- a/setupmeta/commands.py +++ b/setupmeta/commands.py @@ -16,12 +16,6 @@ flatten = chain.from_iterable -def abort(message): - from distutils.errors import DistutilsSetupError - - raise DistutilsSetupError(message) - - def MetaCommand(cls): """Decorator allowing for less boilerplate in our commands""" return setupmeta.MetaDefs.register_command(cls) @@ -43,21 +37,19 @@ class CheckCommand(check_cmd): user_options = check_cmd.user_options + [ ("status", "t", "Show git status recap (useful to get evidence as to why version was dirty during CI jobs)"), - ("deptree", "d", "Show dependency tree (from currently activated venv, or ./.venv, or ./venv)"), ("reqs", "q", "Show how many requirements were auto-abstracted or ignored, if any"), ] def initialize_options(self): check_cmd.initialize_options(self) self.status = None - self.deptree = None self.reqs = None def run(self): if not self.setupmeta: return check_cmd.run(self) - if count(self.restructuredtext, self.status, self.deptree, self.reqs) == 0: + if count(self.restructuredtext, self.status, self.reqs) == 0: self.status = 1 self.reqs = 1 @@ -67,9 +59,6 @@ def run(self): if self.status: self._show_git_status() - if self.deptree: - self._warnings = _show_dependencies(self.setupmeta.definitions) - check_cmd.run(self) def _show_requirements_synopsis(self): @@ -126,7 +115,9 @@ def run(self): print(self.setupmeta.version) except setupmeta.UsageError as e: - abort(e) + from distutils.errors import DistutilsSetupError + + raise DistutilsSetupError(e) @MetaCommand @@ -428,244 +419,3 @@ def run(self): if self.deleted == 0: print("all clean, no deletable files found") - - -def _show_dependencies(definitions): - """ - Conveniently get dependency tree via ./setup.py check --dep, similar to https://pypi.org/project/pipdeptree - """ - if setupmeta.pkg_resources is None or not hasattr(setupmeta.pkg_resources, "WorkingSet"): - setupmeta.warn("pkg_resources is not available, can't show dependencies") - return 1 - - venv = find_venv() - if not venv: - setupmeta.warn("Could not find virtual environment to scan for dependencies") - return 1 - - entries = list(find_subfolders(venv, ["site-packages"])) - if not entries: - setupmeta.warn("Could not find 'site-packages' subfolder in '%s'" % venv) - return 1 - - tree = DepTree(setupmeta.pkg_resources.WorkingSet(entries), definitions) - print(tree.rendered()) - return len(tree.conflicts) + len(tree.cycles) - - -class PipReq(object): - def __init__(self, obj, package): - """ - :param pkg_resources.Requirement obj: - :param PipPackage package: Associated package - """ - self._obj = obj - self.package = package - self.key = obj.key - self.version = package.version - self.version_spec = ",".join(["".join(sp) for sp in sorted(obj.specs, reverse=True)]) - self.version_rec = setupmeta.pkg_req("%s%s" % (self.key, self.version_spec or "")) - self.is_conflicting = not self.package.version or self.package.version not in self.version_rec - - def __repr__(self): - return self.key - - def __eq__(self, other): - return isinstance(other, PipReq) and self.key is other.key - - def __lt__(self, other): - return isinstance(other, PipReq) and self.key < other.key - - def render(self): - conflict = " CONFLICT!" if self.is_conflicting else "" - return "%s [required: %s, installed: %s]%s" % (self.key, self.version_spec or "Any", self.version, conflict) - - -class PipPackage(object): - """Represents a pip package""" - - def __init__(self, tree, obj): - """ - :param DepTree tree: Associated tree - :param pkg_resources.DistInfoDistribution obj: - """ - self.tree = tree - self._obj = obj - self.key = obj.key - self.version = obj.version - self.requires = [] - self.required_by = set() - self.transitive = set() - self.cycle = None - - def __repr__(self): - return self.key - - def __eq__(self, other): - return isinstance(other, PipPackage) and self.key is other.key - - def __lt__(self, other): - return isinstance(other, PipPackage) and self.key < other.key - - def __hash__(self): - return hash(self.key) - - def resolve(self): - for req in self._obj.requires(): - package = self.tree.get_package(req) - if package: - pr = PipReq(req, package) - self.requires.append(pr) - package.required_by.add(self) - - def _add_transitive(self, required): - if isinstance(required, PipReq): - required = required.package - - if isinstance(required, PipPackage): - if required not in self.transitive: - self.transitive.add(required) - self._add_transitive(required.requires) - - return - - for req in required: - self._add_transitive(req) - - def _find_cycle(self, target, visited): - if self in visited: - return None - - visited.add(self) - for r in sorted(self.requires): - if r.package is target: - return [r.package] - - c = r.package._find_cycle(target, visited) - if c: - return [r.package] + c - - def resolve_transitive(self): - self._add_transitive(self.requires) - if self in self.transitive: - self.cycle = self._find_cycle(self, set()) - - def render(self): - return "%s==%s" % (self.key, self.version) - - -def find_subfolders(folder, names, depth=3): - if folder and os.path.isdir(folder): - for name in os.listdir(folder): - fpath = os.path.join(folder, name) - if name in names: - yield fpath - continue - - if os.path.isdir(fpath) and depth > 0: - for p in find_subfolders(fpath, names, depth=depth - 1): - yield p - - -def find_venv(): - venv = os.environ.get("VIRTUAL_ENV") - if venv: - return venv - - for folder in (".venv", "venv"): - fpath = setupmeta.project_path(folder) - if os.path.isdir(fpath): - return fpath - - -class DepTree: - def __init__(self, ws, definitions): - self.packages = dict((d.key, PipPackage(self, d)) for d in ws) - self.setup = definitions.get("setup_requires") - self.install_requires = definitions.get("install_requires") - self.extras_require = definitions.get("extras_require") - self.conflicts = set() - self.cycles = {} - - for p in sorted(self.packages.values()): - p.resolve() - self.conflicts.update(r.key for r in p.requires if r.is_conflicting) - - for p in sorted(self.packages.values()): - p.resolve_transitive() - if p.cycle: - key = setupmeta.represented_args(sorted(p.cycle)) - if key not in self.cycles: - self.cycles[key] = [p] + p.cycle - - def get_package(self, ref): - return self.packages.get(getattr(ref, "key", ref)) - - def get_packages(self, dependencies): - result = [] - for dep in dependencies: - p = self.get_package(setupmeta.pkg_req(dep)) - if p: - result.append(p) - - return result - - def get_children(self, ref): - return self.get_package(ref).requires - - def render_section(self, report, seen, title, dependencies): - nodes = self.get_packages(dependencies) - if not nodes: - return - - def aux(node, indent=2, chain=None): - if chain is None: - chain = [] - - result = ["%s%s" % (" " * indent, node.render())] - children = sorted(self.get_children(node)) - children = [aux(c, indent=indent + 2, chain=chain + [c.key]) for c in children if c.key not in chain] - - chain.append(node.key) - p = self.packages.get(node.key) - if p: - seen.add(p) - - result += list(flatten(children)) - return result - - seen.update(nodes) - auxed = [aux(p) for p in nodes] - report.append("%s:\n%s" % (title, "-" * len(title))) - report.extend(flatten(auxed)) - report.append("") - - def rendered(self): - """String representation""" - result = ["Dependency tree:"] - seen = set() - - if self.install_requires: - self.render_section(result, seen, "install_requires", self.install_requires.value) - - if self.extras_require and self.extras_require.value: - for name, value in self.extras_require.value.items(): - self.render_section(result, seen, "extras_require[%s]" % name, value) - - other = set(self.packages.values()) - seen - if other: - other = sorted(p.key for p in other if not p.required_by) - self.render_section(result, seen, "other", other) - - if self.conflicts: - result.append("\n%s conflicts: %s" % (len(self.conflicts), setupmeta.represented_args(self.conflicts, separator=", "))) - - if self.cycles: - result.append("\n%s cycles found:" % len(self.cycles)) - for c in sorted(self.cycles.values()): - result.append(setupmeta.represented_args(c, separator=" -> ")) - - if len(result) < 2: - result.append("- no dependencies -") - - return "\n".join(result) diff --git a/setupmeta/model.py b/setupmeta/model.py index 9fddb38..07b247e 100644 --- a/setupmeta/model.py +++ b/setupmeta/model.py @@ -396,8 +396,7 @@ def get_requirements(self): if self.requires_dist: return RequirementsFile.from_lines(self.requires_dist, do_abstract=False, source_path="PKG-INFO/Requires-Dist") - if self.requires_txt: - # requires.txt is not produced anymore by setuptools 68.2+ + if self.requires_txt: # pragma: no cover, requires.txt is not produced anymore by setuptools 68.2+ return RequirementsFile.from_file(self.requires_txt, do_abstract=False) def load_more_info(self, folder, depth=3): diff --git a/tests/conftest.py b/tests/conftest.py index 24efbe6..e770021 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import importlib.util import os import shutil import sys @@ -182,9 +183,6 @@ def should_ignore_output(line): # Edge case when pinning setupmeta itself to a certain version return True - if "pkg_resources.working_set.add" in line: - return True - def simplified_temp_path(line, *paths): if line: @@ -246,20 +244,9 @@ def run_internal_setup_py(folder, *args): sys.argv = [setup_py, "-q"] + list(args) run_output = "" try: - basename = "setup" - if sys.version_info[0] > 2: - import importlib.util - - spec = importlib.util.spec_from_file_location(basename, setup_py) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - else: - # With python2, we have to use deprecated imp module - import imp - - fp, pathname, description = imp.find_module(basename, [folder]) - imp.load_module(basename, fp, pathname, description) + spec = importlib.util.spec_from_file_location("setup", setup_py) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) except SystemExit as e: run_output += "'setup.py %s' exited with code 1:\n" % " ".join(args) diff --git a/tests/test_commands.py b/tests/test_commands.py index 9da04f7..377f4c8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,7 +3,6 @@ from unittest.mock import patch import setupmeta -from setupmeta.commands import _show_dependencies, DepTree, find_venv from . import conftest @@ -35,148 +34,6 @@ def test_check(sample_project): assert "Pending changes:" in output -def test_check_dependencies(): - if os.environ.get("VIRTUAL_ENV"): - # check --deptree is only useful when ran from a venv, which is guaranteed when invoking tests via tox (but may not be otherwise) - run_setup_py( - ["check", "--deptree"], - r""" - packaging \[required: Any, installed: [\d+.]+\] - pytest-cov==.+ - """, - folder=conftest.PROJECT_DIR, - ) - - with patch("setupmeta.commands.find_venv", return_value=None): - with conftest.capture_output() as logged: - assert _show_dependencies(None) == 1 - assert "Could not find virtual environment" in logged - - with patch("setupmeta.commands.find_subfolders", return_value=[]): - with conftest.capture_output() as logged: - assert _show_dependencies(None) == 1 - assert "Could not find 'site-packages' subfolder" in logged - - with patch.dict(os.environ, {"VIRTUAL_ENV": ""}): - with patch("os.path.isdir", return_value=True): - assert find_venv() - - with patch("setupmeta.pkg_resources", spec=str): - with conftest.capture_output() as logged: - assert _show_dependencies(None) == 1 - assert "pkg_resources is not available" in logged - - -class FakeDist: - def __init__(self, spec, requires): - req = setupmeta.pkg_req(spec) - self.key = req.key - self.version = req.specs[0][1] if req.specs else "1.0" - self._requires = requires - - def requires(self): - return self._requires - - @staticmethod - def from_string(specs): - result = [] - for spec in specs.split(): - name, _, req = spec.partition(":") - if req: - req = [setupmeta.pkg_req(r) for r in req.split("+")] - - else: - req = [] - - result.append(FakeDist(name, req)) - - return result - - -class FakeDefinition: - def __init__(self, value): - self.value = value - - -def expect_render(definitions, spec, expected): - dists = FakeDist.from_string(spec) - definitions = dict((k, FakeDefinition(v)) for k, v in definitions.items()) - tree = DepTree(dists, definitions) - s = tree.rendered() - assert s.strip() == expected.strip() - return tree - - -def test_dep_tree(): - # No deps edge case - expect_render({}, "", """ -Dependency tree: -- no dependencies - -""") - - # Simple case, no conflicts, no cycles - tree = expect_render( - {"install_requires": ["mock"]}, - "mock==2.0:pbr>=0.11 pbr", - """ -Dependency tree: -install_requires: ----------------- - mock==2.0 - pbr [required: >=0.11, installed: 1.0] -""") - - # Some extra edge case coverage - assert tree.packages["mock"] != tree.packages["pbr"] - assert str(tree.packages["mock"]) == "mock" - pbr = tree.packages["mock"].requires[0] - assert str(pbr) == "pbr" - assert tree.packages["mock"].requires[0] == pbr - report = [] - seen = set() - tree.render_section(report, seen, "some title", ["absent"]) - assert not report - assert not seen - - # Conflict and cycles - expect_render( - {"install_requires": ["mock"], "extras_require": {"bonus": ["pbr"]}}, - "mock==2.0:pbr+attrs pbr:attrs attrs:six six:mock>=3.0 foo", - """ -Dependency tree: -install_requires: ----------------- - mock==2.0 - attrs [required: Any, installed: 1.0] - six [required: Any, installed: 1.0] - mock [required: >=3.0, installed: 2.0] CONFLICT! - pbr [required: Any, installed: 1.0] - pbr [required: Any, installed: 1.0] - attrs [required: Any, installed: 1.0] - six [required: Any, installed: 1.0] - mock [required: >=3.0, installed: 2.0] CONFLICT! - -extras_require[bonus]: ---------------------- - pbr==1.0 - attrs [required: Any, installed: 1.0] - six [required: Any, installed: 1.0] - mock [required: >=3.0, installed: 2.0] CONFLICT! - pbr [required: Any, installed: 1.0] - -other: ------ - foo==1.0 - - -1 conflicts: mock - -2 cycles found: -attrs -> six -> mock -> attrs -pbr -> attrs -> six -> mock -> pbr -""") - - def test_explain(): """ Test setupmeta's own setup.py """ run_setup_py( diff --git a/tests/test_model.py b/tests/test_model.py index 6549ff9..191ff8a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -59,9 +59,6 @@ def test_representation(): def test_requirements(): - assert setupmeta.pkg_req(None) is None - assert setupmeta.pkg_req("#foo") is None - assert setupmeta.requirements_from_file("/dev/null/foo") is None sample = conftest.resource("scenarios/disabled/requirements.txt") diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 54ef081..42aa4c7 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -2,8 +2,6 @@ Verify that ../examples/*/setup.py behave as expected """ -import sys - import pytest import setupmeta @@ -18,14 +16,8 @@ def scenario_folder(request): yield request.param -@pytest.mark.skipif(sys.version_info < (3, 7), reason="EOL-ed versions have minor diffs in warnings coming from old setuptools checks") def test_scenario(scenario_folder): """Check that 'scenario' yields expected explain output""" - py = ".".join(str(s) for s in sys.version_info[:2]) - if py < "3.7" and "via-cfg" in scenario_folder: - # For some reason, older pythons don't all seem to handle setup.cfg well... maybe min version of setuptools needed? - pytest.skip("via-cfg scenario useful only in 3.7+") - scenario = scenarios.Scenario(scenario_folder) expected = scenario.expected_contents() output = scenario.replay() diff --git a/tests/test_scm.py b/tests/test_scm.py index 801945c..4dde8ed 100644 --- a/tests/test_scm.py +++ b/tests/test_scm.py @@ -54,6 +54,7 @@ def test_git(): def test_ignore_git_failures(): assert setupmeta._should_ignore_run_fail("git", ["rev-list", "HEAD"], "ambiguous argument 'HEAD': unknown revision or path") assert setupmeta._should_ignore_run_fail("git", ["describe"], "fatal: no names found, cannot describe anything.") + assert not setupmeta._should_ignore_run_fail("foo", ["bar"], "some error") def test_git_describe_override(monkeypatch):