From 4c36ee433b4edfb0daee1592a79d392af1fe486c Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Mon, 9 Feb 2026 01:21:17 -0800 Subject: [PATCH 1/8] Refactor run_program() function --- .github/workflows/release.yml | 4 +- .github/workflows/tests.yml | 8 +- HISTORY.rst | 14 +- docs/commands.rst | 36 ---- docs/versioning.rst | 8 +- examples/direct/expected.txt | 3 - examples/hierarchical/expected.txt | 4 - examples/single/expected.txt | 3 - examples/via-cfg/expected.txt | 3 - pyproject.toml | 2 +- setup.py | 18 +- setupmeta/__init__.py | 235 ++++++++------------- setupmeta/commands.py | 131 +----------- setupmeta/hook.py | 23 -- setupmeta/model.py | 21 +- setupmeta/scm.py | 161 +++++++++----- setupmeta/versioning.py | 10 +- tests/conftest.py | 156 ++++++++------ tests/scenarios.py | 25 +-- tests/scenarios/README.rst | 2 +- tests/scenarios/So complex/.commands | 1 + tests/scenarios/So complex/expected.txt | 50 +++-- tests/scenarios/So complex/setup.py | 14 +- tests/scenarios/bogus/expected.txt | 18 +- tests/scenarios/bogus/setup.py | 2 +- tests/scenarios/complex-reqs/expected.txt | 3 - tests/scenarios/disabled/.commands | 1 - tests/scenarios/disabled/expected.txt | 6 - tests/scenarios/packaged/expected.txt | 3 - tests/scenarios/pinned/expected.txt | 3 - tests/scenarios/readmes/expected.txt | 3 - tests/scenarios/simple-src/expected.txt | 3 - tests/scenarios/via_req_files/expected.txt | 3 - tests/test_commands.py | 18 +- tests/test_content.py | 46 +--- tests/test_model.py | 16 -- tests/test_scenarios.py | 8 +- tests/test_scm.py | 35 ++- tests/test_setup_py.py | 105 +++++++++ tests/test_versioning.py | 106 +--------- tox.ini | 5 + 41 files changed, 528 insertions(+), 788 deletions(-) delete mode 100644 tests/scenarios/disabled/.commands create mode 100644 tests/test_setup_py.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 533fa7c..f263127 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,8 +22,8 @@ jobs: - name: Configure git run: | - git config --global user.name "GH-actions-bot" - git config --global user.email "gh-actions-bot@noreply.github.com" + git config --global user.name "Tester" + git config --global user.email "test@example.com" - uses: astral-sh/setup-uv@v7 - run: uvx --with tox-uv tox -e py,docs,style diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 358a8e0..13c1998 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,8 +28,8 @@ jobs: - name: Configure git run: | - git config --global user.name "GH-actions-bot" - git config --global user.email "gh-actions-bot@noreply.github.com" + git config --global user.name "Tester" + git config --global user.email "test@example.com" - uses: astral-sh/setup-uv@v7 - run: uvx --with tox-uv tox -e py @@ -56,8 +56,8 @@ jobs: - name: Configure git run: | - git config --global user.name "GH-actions-bot" - git config --global user.email "gh-actions-bot@noreply.github.com" + git config --global user.name "Tester" + git config --global user.email "test@example.com" - uses: astral-sh/setup-uv@v7 - run: uvx --with tox-uv tox -e py diff --git a/HISTORY.rst b/HISTORY.rst index 5547397..ebf5eca 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,14 +2,24 @@ Release notes ============= -3.9.0 (2026-02-09) +3.9.0 (2026-02-11) ------------------ * Test with py3.14, publish sdist with py3.13 +* Removed commands: ``entrypoints``, ``cleanall`` + * Removed post-version-bump hook support (unused, unnecessary, and was not well documented) -* Internal project modernizations (use ``uv``, ``ruff``, enabled more linter rules, etc) +* Removed old ``register`` hook used with setuptools v50 + +* Removed support for pygradle-style versioning + +* Internal project modernizations: + + * Refactored usage of ``subrocess.run()``, removed last left overs from the py2 days + + * use ``uv``, ``ruff``, enabled more linter rules, etc * Use coveralls_ for test coverage reporting diff --git a/docs/commands.rst b/docs/commands.rst index 5b0b553..add44a1 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -106,40 +106,4 @@ Typical usage:: python setup.py version --b minor --commit # Effectively bump -cleanall -======== - -Handily clean build artifacts. Cleans the usual suspects: -``.cache/ .tox/ build/ dist/ venv/ __pycache__/ *.egg-info *.py[cod]``. - -Example:: - - 🦎 3.9 ~/dev/github/setupmeta: ./setup.py cleanall - running cleanall - deleted .tox - deleted setupmeta.egg-info - deleted examples/direct/__pycache__ - deleted examples/hierarchical/__pycache__ - deleted examples/single/__pycache__ - deleted setupmeta/__pycache__ - deleted tests/__pycache__ - deleted tests/scenarios/complex/tests/__pycache__ - deleted tests/scenarios/readmes/__pycache__ - deleted 14 .pyc files - - -entrypoints -=========== - -This will simply show you your ``entry_points/console_scripts``. -Can be handy for pygradle_ users. - -Example:: - - 🦎 3.9 ~/github/pickley: python setup.py entrypoints - - pickley = pickley.cli:protected_main - .. _PEP-440: https://www.python.org/dev/peps/pep-0440/ - -.. _pygradle: https://github.com/linkedin/pygradle/ diff --git a/docs/versioning.rst b/docs/versioning.rst index 01c35c3..1301df9 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -392,8 +392,14 @@ This is what ``versioning="post"`` is a shortcut for:: ... ) +Note that starting with setupmeta v4.0, only ``v*.*`` tags are taken into account by default. +The above complex ``versioning`` form will be the only way to continue using old ``*.*`` version tags... + +Prior to setupmeta v4.0, setupmeta used to look for ``v*.*`` first, and if it didn't find anything, +it would "try harder" by falling back to looking for ``*.*``. + ``version_tag`` is the glob pattern of git tags to consider as version tags. -Unfortunately (for historical reasons), the default form is ``*.*`` (ie: any git tag +Unfortunately (for historical reasons), the default form was ``*.*`` (ie: any git tag with a dot in it), and arguably should have been ``v*.*`` (ie: git tags that start with ``v`` and have dot in them...) diff --git a/examples/direct/expected.txt b/examples/direct/expected.txt index 1af5e5d..6a3e37b 100644 --- a/examples/direct/expected.txt +++ b/examples/direct/expected.txt @@ -54,8 +54,5 @@ direct = direct:main""", # from entr :: check -:: entrypoints -direct = direct:main - :: version 1.0.0 diff --git a/examples/hierarchical/expected.txt b/examples/hierarchical/expected.txt index 198097c..18ef1b1 100644 --- a/examples/hierarchical/expected.txt +++ b/examples/hierarchical/expected.txt @@ -58,9 +58,5 @@ subm = hierarchical.submodule:main""", # from ent :: check -:: entrypoints -hierarchical = hierarchical:main -subm = hierarchical.submodule:main - :: version 1.0.0 diff --git a/examples/single/expected.txt b/examples/single/expected.txt index d30df90..e0acddf 100644 --- a/examples/single/expected.txt +++ b/examples/single/expected.txt @@ -39,8 +39,5 @@ setup( :: check -:: entrypoints - - :: version 0.1.0 diff --git a/examples/via-cfg/expected.txt b/examples/via-cfg/expected.txt index a3d5515..a3d0906 100644 --- a/examples/via-cfg/expected.txt +++ b/examples/via-cfg/expected.txt @@ -41,8 +41,5 @@ setup( :: check -:: entrypoints -pytest=pytest:main - :: version 1.2.3 diff --git a/pyproject.toml b/pyproject.toml index 64c422d..c71a200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ extend-select = [ "T10", # flake8-debugger "TID", # flake8-tidy-imports "TCH", # flake8-type-checking - "TD", # flake8-todos +# "TD", # flake8-todos "TRY", # tryceratops "W", # pycodestyle warnings ] diff --git a/setup.py b/setup.py index 0d86ea6..31d711a 100644 --- a/setup.py +++ b/setup.py @@ -12,8 +12,6 @@ ENTRY_POINTS = """ [distutils.commands] check = setupmeta.commands:CheckCommand -cleanall = setupmeta.commands:CleanCommand -entrypoints = setupmeta.commands:EntryPointsCommand explain = setupmeta.commands:ExplainCommand version = setupmeta.commands:VersionCommand @@ -26,20 +24,13 @@ """ -def decode(text): - if isinstance(text, bytes): - return text.decode("utf-8") - - return text - - def run_bootstrap(message): - sys.stderr.write("--- Bootstrapping %s\n" % message) - p = subprocess.Popen([sys.executable, "setup.py", "egg_info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) # noqa: S603 + sys.stderr.write(f"--- Bootstrapping {message}\n") + p = subprocess.Popen([sys.executable, "setup.py", "egg_info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # noqa: S603 output, error = p.communicate() if p.returncode: - print(decode(output)) - sys.stderr.write("%s\n" % decode(error)) + print(output) + sys.stderr.write(f"{error}\n") sys.exit(p.returncode) if not os.path.isdir(EGG): @@ -48,6 +39,7 @@ def run_bootstrap(message): def complete_args(args): args["setup_requires"] = ["setupmeta"] + args["install_requires"] = ["setuptools>=67"] args["versioning"] = "dev" diff --git a/setupmeta/__init__.py b/setupmeta/__init__.py index c122808..ce0df8e 100644 --- a/setupmeta/__init__.py +++ b/setupmeta/__init__.py @@ -16,10 +16,8 @@ import tempfile import warnings -import setuptools - USER_HOME = os.path.expanduser("~") # Used to pretty-print subfolders of ~ -DEBUG = os.environ.get("SETUPMETA_DEBUG") +TRACE_ENABLED = os.environ.get("SETUPMETA_DEBUG") VERSION_FILE = ".setupmeta.version" # File used to work with projects that are in a subfolder of a git checkout SCM_DESCRIBE = "SCM_DESCRIBE" # Name of env var used as pass-through for cases where git checkout is not available RE_SPACES = re.compile(r"\s+", re.MULTILINE) @@ -50,12 +48,10 @@ def warn(message): def trace(message): - """Output 'message' if tracing is on""" - if not DEBUG: - return - - sys.stderr.write(":: %s\n" % message) - sys.stderr.flush() + """Output `message` if tracing is on""" + if TRACE_ENABLED: + sys.stderr.write(f":: {message}\n") + sys.stderr.flush() def get_words(text): @@ -74,7 +70,7 @@ def to_int(text, default=None): def short(text, c=None): """Short representation of 'text'""" if not text: - return "%s" % text + return f"{text}" if c is None: c = Console.columns() @@ -84,22 +80,19 @@ def short(text, c=None): result = re.sub(RE_SPACES, " ", result) if c and len(result) > abs(c): if c < 0: - return "%s..." % result[:-c] + return f"{result[:-c]}..." if isinstance(text, dict): - summary = "%s keys" % len(text) + summary = f"{len(text)} keys" elif isinstance(text, list): - summary = "%s items" % len(text) + summary = f"{len(text)} items" else: - return "%s..." % result[: c - 3] + return f"{result[: c - 3]}..." cutoff = c - len(summary) - 5 - if cutoff <= 0: - return summary - - return "%s: %s..." % (summary, result[:cutoff]) + return summary if cutoff <= 0 else f"{summary}: {result[:cutoff]}..." return result @@ -112,10 +105,6 @@ def strip_dash(text): return text.strip("-") -def is_executable(path): - return path and os.path.isfile(path) and os.access(path, os.X_OK) - - def version_components(text): """ :param str text: Text to parse @@ -128,7 +117,7 @@ def version_components(text): distance = None for component in components: if not isinstance(component, int): - qualifier = "%s%s" % (qualifier, component) + qualifier = f"{qualifier}{component}" continue if not additional and not qualifier and len(main_triplet) < 3: @@ -139,7 +128,7 @@ def version_components(text): if distance is None and qualifier in ("dev", "post"): distance = component - component = "%s%s" % (qualifier, component) + component = f"{qualifier}{component}" qualifier = "" additional.append(str(component)) @@ -158,29 +147,13 @@ def version_components(text): return main_triplet[0], main_triplet[1], main_triplet[2], ".".join(additional), distance, dirty -def which(program): - if not program: - return None - - if os.path.isabs(program): - if is_executable(program): - return program - - return None - - for p in os.environ.get("PATH", "").split(os.pathsep): - fp = os.path.join(p, program) - if is_executable(fp): - return fp - - def represented_args(args, separator=" "): result = [] for text in args: text = str(text) if not text or " " in text: sep = "'" if '"' in text else '"' - result.append("%s%s%s" % (sep, text, sep)) + result.append(f"{sep}{text}{sep}") else: result.append(text) @@ -188,120 +161,106 @@ def represented_args(args, separator=" "): return separator.join(result) -def merged(output, error): - if output and error: - return "%s\n%s" % (output, error) - - if not output and error: - return error +class FullPathCache: + """Used to find and trace full paths to programs once.""" - return output - - -def run_program(program, *args, **kwargs): - """ - Run 'program' with 'args' - - :param str program: Path to program to run - :param args: Arguments to pass to program - :param bool dryrun: When True, do not run, just print what would be ran - :param bool fatal: When True, exit immediately on return code != 0 - :param bool capture: None: let output pass through, return exit code - False: ignore output, return exit code - True: return exit code and output/error - """ - full_path = which(program) - fatal = kwargs.pop("fatal", False) - dryrun = kwargs.pop("dryrun", False) - capture = kwargs.pop("capture", None) - represented = "%s %s" % (program, represented_args(args)) - if dryrun: - print("Would run: %s" % represented) - return None if capture else 0 - - problem = None if full_path else "'%s' is not installed" % program - if problem: - if fatal: - sys.exit(problem) - - return None if capture else 1 - - if capture in (None, "testing-scenarios"): - print("Running: %s" % represented) - - if capture is not None or capture == "testing-scenarios": - kwargs["stdout"] = subprocess.PIPE - kwargs["stderr"] = subprocess.PIPE - - p = subprocess.Popen([full_path, *args], **kwargs) # noqa: S603 - output, error = p.communicate() - output = decode(output) - error = decode(error) - trace_msg = "ran [%s], exitcode: %s" % (represented, p.returncode) - if output: - output = output.rstrip() - trace_msg = "%s, output: [%s]" % (trace_msg, output.strip()) - - if error: - error = error.rstrip() - trace_msg = "%s, error: [%s]" % (trace_msg, error.strip()) + def __init__(self): + self.cache = {} - trace(trace_msg) - if capture: - if p.returncode and not _should_ignore_run_fail(program, args, error): - warn("%s exited with error code %s\n%s" % (represented, p.returncode, error or "-no stderr-")) + def which(self, program): + if program not in self.cache: + full_path = shutil.which(program) + self.cache[program] = full_path + trace(f"Full path for {program}: {full_path or '-not installed-'}") - if capture == "all": - return merged(output, error) + return self.cache[program] - return output - if p.returncode: - if fatal: - print("%s exited with code %s:\n%s" % (represented, p.returncode, error)) +class RunResult: + full_path_cache = FullPathCache() - if fatal: - sys.exit(p.returncode) + def __init__(self, program=None, args=None, returncode=0, stdout="", stderr=""): + self.program = program + self.full_path = self.full_path_cache.which(program) + self.full_args = [self.full_path or program, *args] + self.args = args + self.represented_args = f"{program} {represented_args(args)}".strip() + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + if not self.full_path: + self.returncode = returncode or 1 + self.stderr = stderr or f"'{program}' is not installed" - return p.returncode + def require_success(self): + """Abort execution if run was not successful""" + if self.returncode: + sys.stderr.write(f"{self.represented_args} exited with code {self.returncode}:\n{self.stderr or '-no stderr-'}\n") + sys.exit(self.returncode) + def trace_message(self): + trace_msg = f"{self.represented_args} exited with code: {self.returncode}" + if self.stdout: + trace_msg += f", stdout: [{self.stdout}]" -def _should_ignore_run_fail(program, args, error): - """Edge case: don't warn for known expected failures""" - if not program or not args or not args[0] or "git" not in program: - return False + if self.stderr: + trace_msg += f", stderr: [{self.stderr}]" - if args[0] in ("rev-list", "rev-parse") and "HEAD" in args: - # No commits yet, brand-new git repo - return error and "revision" in error.lower() + return trace_msg - if args[0] == "describe": - # No tags are present, git states "No names found" in that case - return error and "no names" in error.lower() - if args[0] in ("show-ref", "ls-remote") and "--tags" in args: - # Used for version bump, don't warn if there are no tags yet or no remote defined - return True +def run_program(program, *args, announce=False, cwd=None, dryrun=False, env=None): + """ + Run `program` with `args` + + Parameters + ---------- + program : str + Program to run + *args : str + Arguments to pass to program + announce : bool + If True, announce the run + cwd : str | None + Working directory + dryrun : bool + When True, do not run, just print what would be run + env : dict | None + Environment variables + + Returns + ------- + RunResult + """ + result = RunResult(program, args) + if dryrun: + print(f"Would run: {result.represented_args}") + elif announce: + print(f"Running: {result.represented_args}") -def decode(value): - """Python 2/3 friendly decoding of output""" - if isinstance(value, bytes): - value = value.decode("utf-8") + if dryrun or not result.full_path: + return result - return value + trace(f"Running: {result.represented_args}") + r = subprocess.run(result.full_args, capture_output=True, cwd=cwd, env=env, text=True) # noqa: S603 + result.returncode = r.returncode + result.stdout = r.stdout.rstrip() + result.stderr = r.stderr.rstrip() + trace(result.trace_message()) + return result def quoted(text): """Quoted text, with single or double-quotes""" if text: if "\n" in text: - return '"""%s"""' % text + return f'"""{text}"""' if '"' in text: - return "'%s'" % text + return f"'{text}'" - return '"%s"' % text + return f'"{text}"' def _strs(value, bracket, first, sep, quote, indent): @@ -780,15 +739,6 @@ def register_command(cls, command): """Register our own 'command'""" command.description = command.__doc__.strip().split("\n")[0] command.__init__ = meta_command_init - if command.initialize_options == setuptools.Command.initialize_options: - command.initialize_options = lambda _: None - - if command.finalize_options == setuptools.Command.finalize_options: - command.finalize_options = lambda _: None - - if not hasattr(command, "user_options"): - command.user_options = [] - cls.commands.append(command) return command @@ -851,9 +801,8 @@ class Console: @classmethod def columns(cls, default=160): if cls._columns is None and sys.stdout.isatty() and "TERM" in os.environ: - cols = os.popen("tput cols", "r").read() # noqa: S605, S607 - cols = decode(cols) - cls._columns = to_int(cols, default=None) + result = run_program("tput", "cols") + cls._columns = to_int(result.stdout, default=None) if cls._columns is None: cls._columns = default diff --git a/setupmeta/commands.py b/setupmeta/commands.py index 9aececb..dc52de6 100644 --- a/setupmeta/commands.py +++ b/setupmeta/commands.py @@ -2,19 +2,12 @@ Commands contributed by setupmeta """ -import collections -import os -import shutil from distutils.command.check import check as check_cmd -from itertools import chain import setuptools import setupmeta -CLEANABLE_EXTENSIONS = {"egg-info", "pyc", "pyo", "pyd"} -flatten = chain.from_iterable - def MetaCommand(cls): """Decorator allowing for less boilerplate in our commands""" @@ -45,6 +38,9 @@ def initialize_options(self): self.status = None self.reqs = None + def finalize_options(self): + pass + def run(self): if not self.setupmeta: return check_cmd.run(self) @@ -76,7 +72,7 @@ def _show_git_status(self): if self.setupmeta.versioning: scm = self.setupmeta.versioning.scm if scm: - diff = scm.get_output("diff", "--stat", capture=True) + diff = scm.get_diff_report() if diff: print("Pending changes:\n%s" % diff) @@ -100,6 +96,9 @@ def initialize_options(self): self.simulate_branch = None self.show_next = None + def finalize_options(self): + pass + def run(self): if not self.setupmeta: return @@ -137,6 +136,9 @@ def initialize_options(self): self.recommend = False self.chars = setupmeta.Console.columns() + def finalize_options(self): + pass + def check_recommend(self, key, hint=None): if key not in self.setupmeta.definitions: hint = ", %s" % hint if hint else "" @@ -294,116 +296,3 @@ def run(self): preview = setupmeta.short(source.value, c=max_chars) s = form % (prefix, setupmeta.short(source.source), preview) print(s) - - -@MetaCommand -class EntryPointsCommand(setuptools.Command): - """List entry points for pygradle consumption""" - - def run(self): - if not self.setupmeta: - return - - entry_points = self.setupmeta.value("entry_points") - console_scripts = get_console_scripts(entry_points) - if not console_scripts: - return - - if isinstance(console_scripts, list): - for ep in console_scripts: - print(ep) - - return - - for line in console_scripts.splitlines(): - line = line.strip() - if line: - print(line) - - -def get_console_scripts(entry_points): - """pygradle's 'entrypoints' are misnamed: they really mean 'consolescripts'""" - if not entry_points: - return None - - if isinstance(entry_points, dict): - return entry_points.get("console_scripts") - - if isinstance(entry_points, list): - result = [] - in_console_scripts = False - for line in entry_points: - line = line.strip() - if line and line.startswith("["): - in_console_scripts = "console_scripts" in line - continue - - if in_console_scripts: - result.append(line) - - return result - - return get_console_scripts(entry_points.split("\n")) - - -@MetaCommand -class CleanCommand(setuptools.Command): - """Clean build artifacts and virtual envs""" - - deleted = 0 - by_ext = None - - def delete(self, full_path): - if os.path.isdir(full_path): - shutil.rmtree(full_path) - print("deleted %s" % setupmeta.relative_path(full_path)) - - else: - os.unlink(full_path) - self.by_ext[full_path.rpartition(".")[2]] += 1 - - self.deleted += 1 - - def clean_direct(self): - for target in (".cache", ".tox", "build", "dist", "venv"): - full_path = setupmeta.project_path(target) - if os.path.exists(full_path): - self.delete(full_path) - - def run(self): - if not self.setupmeta: - return - - self.deleted = 0 - self.by_ext = collections.defaultdict(int) - self.clean_direct() - for dirpath, dirnames, filenames in os.walk(setupmeta.MetaDefs.project_dir): - remove = [] - for dname in dirnames: - if dname in (".git", ".gradle", ".idea", ".venv"): - remove.append(dname) - - elif dname == "__pycache__": - remove.append(dname) - self.delete(os.path.join(dirpath, dname)) - - else: - ext = dname.rpartition(".")[2] - if ext in CLEANABLE_EXTENSIONS: - remove.append(dname) - self.delete(os.path.join(dirpath, dname)) - - for dname in remove: - dirnames.remove(dname) - - for fname in filenames: - ext = fname.rpartition(".")[2] - if ext in CLEANABLE_EXTENSIONS: - self.delete(os.path.join(dirpath, fname)) - - if self.by_ext: - info = ["%s .%s files" % (v, k) for k, v in sorted(self.by_ext.items())] - print("deleted %s" % ", ".join(info)) - - if self.deleted == 0: - print("all clean, no deletable files found") diff --git a/setupmeta/hook.py b/setupmeta/hook.py index f12a144..7ea3fc0 100644 --- a/setupmeta/hook.py +++ b/setupmeta/hook.py @@ -3,7 +3,6 @@ """ import functools -import warnings import setuptools.dist @@ -63,25 +62,3 @@ def register_keyword(dist, name, value): """ if name == "setup_requires" and not hasattr(dist, "_setupmeta"): finalize_dist(dist, setup_requires=value) - - -# Add alias to register_keyword for backwards compatibility -def register(dist, name, value): # pragma: no cover; Should not be used in normal operations. - """ - This is an alias for `register_keyword` that is used only when there is a - collision in expected entrypoints due to `setupmeta` being installed into - the runtime environment as well as via a local egg within a project. This - should eventually be able to be removed one everyone has migrated to - newer versions of setupmeta (>=2.7.10), and should only affect a handful - in any case. - """ - warnings.warn( - "It appears that you have an installed version of `setupmeta` that is " - "interfering with a newer version's functionality. Things should still work " - "for you, but we recommend uninstalling `setupmeta` from your environment." - "`setupmeta` is only useful during the setup process, and does not need " - "to be properly installed.", - RuntimeWarning, - stacklevel=2, - ) - register_keyword(dist, name, value) diff --git a/setupmeta/model.py b/setupmeta/model.py index 2aa0a8e..6324381 100644 --- a/setupmeta/model.py +++ b/setupmeta/model.py @@ -91,11 +91,6 @@ def __init__(self, key, value, source): def __repr__(self): return "%s=%s from %s" % (self.key, short(self.value), self.source) - @property - def is_explicit(self): - """Did this entry come explicitly from setup(**attrs)?""" - return self.source == EXPLICIT - class Definition(object): """Record definitions for a given key, and where they were found""" @@ -131,11 +126,6 @@ def source(self): if self.sources: return self.sources[0].source - @property - def is_explicit(self): - """Did this entry come explicitly from setup(**attrs)?""" - return any(s.is_explicit for s in self.sources) - def merge_sources(self, sources): """Record the fact that we saw this definition in 'sources'""" for entry in sources: @@ -170,7 +160,7 @@ def add(self, value, source, override=False): @property def is_meaningful(self): """Should this definition make it to the final setup attrs?""" - return self.value is not None or self.is_explicit + return self.value is not None class Settings: @@ -317,20 +307,17 @@ def get_pip(): Deprecated, see https://github.com/pypa/setuptools/issues/2355 and https://github.com/codrsquad/setupmeta/issues/49 Left around for a while because some callers import this, they will have to adapt to pip 20.1+ """ + import contextlib + attempts = ( ("pip._internal.network.session", "pip._internal.req"), # for pip >= 19.3 ("pip._internal.download", "pip._internal.req"), # for pip >= 10.0 ("pip.download", "pip.req"), # for pip < 10.0 ) for session_path, req_path in attempts: - try: + with contextlib.suppress(ImportError): mod_session = __import__(session_path, fromlist=["PipSession"]) mod_req = __import__(req_path, fromlist=["parse_requirements"]) - - except ImportError: # pragma: no cover - continue - - else: return mod_req.parse_requirements, mod_session.PipSession diff --git a/setupmeta/scm.py b/setupmeta/scm.py index c581611..41bda7c 100644 --- a/setupmeta/scm.py +++ b/setupmeta/scm.py @@ -1,5 +1,6 @@ import os import re +import sys import setupmeta @@ -10,7 +11,6 @@ class Scm: """API used by setupmeta for versioning using SCM tags""" - program = None # type: str # Program name (like 'git' or 'hg') version_tag = None # type: str # Format for tags to consider as version tags in underlying SCM, when applicable def __init__(self, root): @@ -43,6 +43,16 @@ def get_branch(self): :return str: Current branch name """ + def get_diff_report(self): + """ + This is legacy and will be removed in setupmeta v5.0 + Textual diff report of the current repo, designed to show if any changes are pending (and thus why version is marked "dirty") + + Returns + ------- + str + """ + def get_version(self): """ :return Version: Current version as computed from latest SCM version tag @@ -68,36 +78,6 @@ def apply_tag(self, commit, push, next_version, branch): :param str branch: Branch on which tag is being applied """ - def get_output(self, *args, **kwargs): - """ - Run underlying SCM CLI program with 'args' and optional additional 'kwargs' (passed through to subprocess.Popen) - Command is ran with cwd being 'self.root' - - :param args: CLI arguments (example: describe --tags) - :param kwargs: Additional named arguments - :return str|int: Output if kwargs['capture'] is True, exit code otherwise - """ - capture = kwargs.pop("capture", True) - cwd = kwargs.pop("cwd", self.root) - return setupmeta.run_program(self.program, *args, capture=capture, cwd=cwd, **kwargs) - - def run(self, commit, *args, **kwargs): - """ - Run underlying SCM CLI program with 'args' and optional additional 'kwargs' (passed through to subprocess.Popen) - Output is "passed through" to stdout/stderr. - - :param bool commit: Effectively run the command if True, otherwise just print "Would run: ..." - :param args: CLI arguments (example: push origin) - :param kwargs: Additional named arguments - :return int: Exit code (always zero, unless fatal=False is passed explicitly in kwargs) - """ - fatal = kwargs.pop("fatal", True) - capture = kwargs.pop("capture", None) - if capture is None and commit and os.environ.get("SETUPMETA_RUNNING_SCENARIOS"): - capture = "testing-scenarios" - - return self.get_output(*args, capture=capture, fatal=fatal, dryrun=not commit, **kwargs) - class Snapshot(Scm): """ @@ -108,8 +88,6 @@ class Snapshot(Scm): This implementation allows to still be able to properly determine version even in that case """ - program = None - def is_dirty(self): v = os.environ.get(setupmeta.SCM_DESCRIBE) return v and "dirty" in v @@ -131,11 +109,10 @@ def get_version(self): class Git(Scm): """Implementation for git""" - program = "git" _has_origin = None def _get_tags(self, *cmd): - text = self.get_output(*cmd) + text = self.git_output(*cmd) result = set() for line in text.splitlines(): p = line.rpartition("/")[2] @@ -174,16 +151,19 @@ def is_dirty(self): This checks both the working tree and index, in a single command. Ref: https://stackoverflow.com/a/2659808/15690 """ - exitcode = self.get_output("diff", "--quiet", "--ignore-submodules", capture=False) - if exitcode == 0: - exitcode = self.get_output("diff", "--quiet", "--ignore-submodules", "--staged", capture=False) + result = self.run_git("diff", "--quiet", "--ignore-submodules", fatal=False) + if result.returncode == 0: + result = self.run_git("diff", "--quiet", "--ignore-submodules", "--staged", fatal=False) - return exitcode != 0 + return result.returncode != 0 def get_branch(self): - branch = self.get_output("rev-parse", "--abbrev-ref", "HEAD") + branch = self.git_output("rev-parse", "--abbrev-ref", "HEAD") return branch and branch.strip() + def get_diff_report(self): + return self.git_output("diff", "--stat") + def git_describe_output(self): """ Determine version tag from git @@ -194,21 +174,20 @@ def git_describe_output(self): # Override was given, just use it as-is setupmeta.trace("Using SETUPMETA_GIT_DESCRIBE_COMMAND: %s" % override) cmd = override.split(" ") - return self.get_output(*cmd) + return self.git_output(*cmd) version_tag = self.version_tag - cmd = ["describe", "--dirty", "--tags", "--long", "--first-parent", "--match", version_tag] + cmd = ["describe", "--dirty", "--tags", "--long", "--first-parent", "--match"] if version_tag: # A custom version tag was configured, use it setupmeta.trace("Using configured version_tag: %s" % version_tag) - return self.get_output(*cmd) + return self.git_output(*cmd, version_tag) # No overrides, try v*.* first, then fall back to '*.*' if need be - cmd[-1] = "v*.*" - text = self.get_output(*cmd) + text = self.git_output(*cmd, "v*.*") if not text: - cmd[-1] = "*.*" - text = self.get_output(*cmd) + # TODO(zsimic): Remove this for setupmeta v4.0 + text = self.git_output(*cmd, "*.*") return text @@ -219,15 +198,15 @@ def get_version(self): return version # Try harder - commitid = self.get_output("rev-parse", "--short", "HEAD") + commitid = self.git_output("rev-parse", "--short", "HEAD") commitid = "g%s" % commitid if commitid else "" - distance = self.get_output("rev-list", "HEAD") + distance = self.git_output("rev-list", "HEAD") distance = distance.count("\n") + 1 if distance else 0 return Version(main=None, distance=distance, commitid=commitid, dirty=self.is_dirty()) def has_origin(self): if self._has_origin is None: - self._has_origin = bool(self.get_output("config", "--get", "remote.origin.url")) + self._has_origin = bool(self.git_output("config", "--get", "remote.origin.url")) return self._has_origin @@ -236,18 +215,18 @@ def commit_files(self, commit, push, relative_paths, next_version): return relative_paths = sorted(set(relative_paths)) - self.run(commit, "add", *relative_paths) - self.run(commit, "commit", "-m", "Version %s" % next_version, "--no-verify") + self.run_git("add", *relative_paths, dryrun=not commit, passthrough=True) + self.run_git("commit", "-m", "Version %s" % next_version, "--no-verify", dryrun=not commit, passthrough=True) if push: if self.has_origin(): - self.run(commit, "push", "origin") + self.run_git("push", "origin", dryrun=not commit, passthrough=True) else: print("Won't push: no origin defined") def apply_tag(self, commit, push, next_version, branch): - self.get_output("fetch", "--all") - output = self.get_output("status", "--porcelain", "--branch") + self.run_git("fetch", "--all", dryrun=not commit, passthrough=True) + output = self.git_output("status", "--porcelain", "--branch") for line in output.splitlines(): m = RE_BRANCH_STATUS.match(line) if m and m.group(1) == branch: @@ -259,14 +238,82 @@ def apply_tag(self, commit, push, next_version, branch): bump_msg = "Version %s" % next_version tag = "v%s" % next_version - self.run(commit, "tag", "-a", tag, "-m", bump_msg) + self.run_git("tag", "-a", tag, "-m", bump_msg, dryrun=not commit, passthrough=True) if push: if self.has_origin(): - self.run(commit, "push", "--tags", "origin") + self.run_git("push", "--tags", "origin", dryrun=not commit, passthrough=True) else: print("Not running 'git push --tags origin' as you don't have an origin") + def git_output(self, *args) -> str: + result = self.run_git(*args, fatal=False) + return result.stdout + + def run_program(self, cmd, *args, announce=False, dryrun=False): + """Used to make mocking easier""" + return setupmeta.run_program("git", cmd, *args, announce=announce, cwd=self.root, dryrun=dryrun) + + def run_git(self, *args, dryrun=False, fatal=True, passthrough=False): + """ + Run git with `args` + + Parameters + ---------- + *args: str + CLI arguments (example: push origin) + dryrun : bool + When True, do not run, just print what would be run + passthrough: bool + When True, pass-through stderr/stdout + fatal: bool + When True, abort execution is command exited with code != 0 + + Returns + ------- + setupmeta.RunResult + """ + result = self.run_program(*args, announce=passthrough, dryrun=dryrun) + if result.returncode and result.stderr: + if self.should_ignore_error(result): + result.returncode = 0 + result.stderr = "" + + elif not fatal: + # Bubble up unexpected non-fatal errors as warnings + sys.stderr.write(f"WARNING: {result.represented_args} exited with code {result.returncode}, stderr:\n") + sys.stderr.write(f"{result.stderr}\n") + result.stderr = "" + + if passthrough and os.environ.get("SETUPMETA_RUNNING_SCENARIOS"): + passthrough = False # Reduce chatter when running test scenarios + + if passthrough and result.stdout: + print(result.stdout) + + if fatal: + result.require_success() # stderr is always shown on failure, so don't repeat it with `passthrough` below + + if passthrough and result.stderr: + sys.stderr.write(f"{result.stderr}\n") + + return result + + @staticmethod + def should_ignore_error(result): + """Edge case: don't warn for known expected failures""" + if result.args[0] in ("rev-list", "rev-parse") and "HEAD" in result.args: + # No commits yet, brand-new git repo + return result.stderr and "revision" in result.stderr.lower() + + if result.args[0] == "describe": + # No tags are present, git states "No names found" in that case + return result.stderr and "no names" in result.stderr.lower() + + if result.args[0] in ("show-ref", "ls-remote") and "--tags" in result.args: + # Used for version bump, don't warn if there are no tags yet or no remote defined + return True + class Version: """ diff --git a/setupmeta/versioning.py b/setupmeta/versioning.py index 23edb25..127b029 100644 --- a/setupmeta/versioning.py +++ b/setupmeta/versioning.py @@ -228,7 +228,7 @@ def bits(self, fmt): return fmt result = [] - if not fmt: + if not isinstance(fmt, str): return result before, _, after = fmt.partition("{") @@ -303,7 +303,7 @@ def bumped(self, what, current_version): :return str: Represented next version, with 'what' bumped """ if not isinstance(self.main_bits, list): - setupmeta.abort("Main format is not a list: %s" % setupmeta.stringify(self.main_bits)) + setupmeta.abort("Can't bump with custom version definition: %s" % setupmeta.stringify(self.main_bits)) if what not in self.bumpable: msg = "Can't bump '%s', it's out of scope" % what @@ -430,12 +430,6 @@ def __init__(self, meta, scm): def auto_fill_version(self): """Autofill version as defined by 'self.strategy'""" - pygradle_version = os.environ.get("PYGRADLE_PROJECT_VERSION") - if pygradle_version: - # Minimal support for https://github.com/linkedin/pygradle - self.meta.auto_fill("version", pygradle_version, "pygradle", override=True) - return - if not self.enabled: setupmeta.trace("not auto-filling version, versioning is disabled") return diff --git a/tests/conftest.py b/tests/conftest.py index 9582d0b..df887b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,6 @@ setupmeta.MetaDefs.project_dir = PROJECT_DIR os.environ["PYTHONDONTWRITEBYTECODE"] = "1" -os.environ["SETUPMETA_RUNNING_SCENARIOS"] = "1" sys.dont_write_bytecode = True @@ -38,21 +37,11 @@ def print_warning(message, *_, **__): print("WARNING: %s" % setupmeta.short(message, -60)) -def run_program(program, *args, **kwargs): - capture = kwargs.pop("capture", True) - fatal = kwargs.pop("fatal", True) - represented = "%s %s" % (program, setupmeta.represented_args(args)) - print("Running: %s" % represented) - output = setupmeta.run_program(program, *args, capture=capture, fatal=fatal, **kwargs) - return output - - -def run_git(*args, **kwargs): +def run_git(*args, cwd=None): # git requires a user.email configured, which is usually done in ~/.gitconfig, however under tox, we don't have $HOME defined - kwargs.setdefault("capture", True) - kwargs.setdefault("fatal", True) - output = setupmeta.run_program("git", "-c", "user.name=Tester", "-c", "user.email=test@example.com", *args, **kwargs) - return output + result = setupmeta.run_program("git", "-c", "user.name=Tester", "-c", "user.email=test@example.com", *args, cwd=cwd) + result.require_success() + return result @pytest.fixture @@ -129,13 +118,25 @@ def __init__(self, stdout=True, stderr=True): def __repr__(self): result = "" if self.out_buffer: - result += setupmeta.decode(self.out_buffer.getvalue()) + result += self.out_buffer.getvalue() if self.err_buffer: - result += setupmeta.decode(self.err_buffer.getvalue()) + result += self.err_buffer.getvalue() return result.rstrip() + def clear(self): + """Clear captured content""" + self.out_buffer.seek(0) + self.out_buffer.truncate(0) + self.err_buffer.seek(0) + self.err_buffer.truncate(0) + + def pop(self): + result = str(self) + self.clear() + return result + def __enter__(self): if self.old_out is not None: sys.stdout = self.out_buffer = StringIO() @@ -172,35 +173,42 @@ def simplified_output_path(line, representation, path): return line -def cleaned_output(text, folder=None): - text = setupmeta.decode(text) - if not text: - return text - +def _cleaned_text(folder, cwd, text): result = [] - cwd = os.getcwd() for line in text.splitlines(): line = line.rstrip() - if not line or line.startswith(("pydev debugger:", "Connected to: ", folder) + line = simplified_output_path(line, "", TESTS) + line = simplified_output_path(line, "", PROJECT_DIR) + line = simplified_output_path(line, "", cwd) + result.append(line) + + return "\n".join(result).rstrip() + + +def cleaned_output(run_result, folder=None): + cwd = os.getcwd() + result = [] + output = _cleaned_text(folder, cwd, run_result.stdout) + if output: + result.append(output) - line = simplified_output_path(line, "", folder) - line = simplified_output_path(line, "", TESTS) - line = simplified_output_path(line, "", PROJECT_DIR) - line = simplified_output_path(line, "", cwd) - result.append(line) + if run_result.returncode: + result.append(f"{setupmeta.represented_args(run_result.args)} exited with code {run_result.returncode}:") + output = _cleaned_text(folder, cwd, run_result.stderr) + if output: + result.append(output) return "\n".join(result).rstrip() def spawn_setup_py(folder, *args): """Invoke `setup.py` from `folder` as an external process, silence all warnings""" - with setupmeta.current_folder(folder): - env = dict(os.environ) - env["PYTHONWARNINGS"] = "ignore" - output = run_program(sys.executable, "setup.py", "-q", *args, env=env) - output = cleaned_output(output) - return output + env = dict(os.environ) + env["PYTHONWARNINGS"] = "ignore" + result = setupmeta.run_program(sys.executable, "setup.py", "-q", *args, cwd=folder, env=env) + return cleaned_output(result, folder=folder) def invoke_setup_py(folder, *args): @@ -210,23 +218,22 @@ def invoke_setup_py(folder, *args): old_argv = sys.argv old_pd = setupmeta.MetaDefs.project_dir - setupmeta.DEBUG = False try: setup_py = os.path.join(folder, "setup.py") + sys.argv = [setup_py, "-q", *args] + result = setupmeta.RunResult(program=sys.executable, args=sys.argv) with capture_output() as logged: - sys.argv = [setup_py, "-q", *args] - run_output = "" try: spec = importlib.util.spec_from_file_location("setup", setup_py) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + result.stdout = str(logged) except (SystemExit, setupmeta.UsageError) as e: - run_output += "'setup.py %s' exited with code 1:\n" % " ".join(args) - run_output += "%s\n" % e + result.returncode = 1 + result.stderr = str(e) - run_output = "%s\n%s" % (logged, run_output.rstrip()) - return cleaned_output(run_output, folder=folder) + return cleaned_output(result, folder=folder) finally: setupmeta.MetaDefs.project_dir = old_pd @@ -243,40 +250,59 @@ def __init__(self, describe="v0.1.2-3-g123-dirty", branch="main", commitid="abc1 self._remote_tags = remote_tags Git.__init__(self, TESTS) - @property - def dirty(self): - return "-dirty" in self.describe + def run_program(self, cmd, *args, announce=False, dryrun=False): + if dryrun: + return setupmeta.run_program("git", cmd, *args, announce=announce, dryrun=dryrun) + + result = setupmeta.RunResult(program="git", args=args) + if cmd in ("fetch", "add", "commit"): + return result + + if cmd == "tag": + result.stderr = "chatty stderr" # Simulate output on stderr is passed through + return result + + if cmd == "push": + result.returncode = 1 + result.stderr = "oops push failed" + return result - def get_output(self, cmd, *args, **kwargs): - if cmd.startswith("diff"): - return 1 if self.dirty else 0 + if cmd == "diff": + if args[0] == "--quiet": + if "-dirty" in self.describe: + result.returncode = 1 + + elif args[0] == "--stat": + result.returncode = 1 + result.stdout = "some diff stats" + result.stderr = "oops something happened" + + return result if cmd == "describe": - return self.describe + result.stdout = self.describe + return result if cmd == "rev-parse": - if "--abbrev-ref" in args: - return self.branch - - return self.commitid + result.stdout = self.branch if "--abbrev-ref" in args else self.commitid.splitlines()[0] + return result if cmd == "rev-list": - return self.commitid.split() + result.stdout = self.commitid + return result if cmd == "config": - return args[1] + result.stdout = args[1] + return result if cmd == "show-ref": - return self._local_tags + result.stdout = self._local_tags + return result if cmd == "ls-remote": - return self._remote_tags - - if cmd.startswith("fetch"): - return None + result.stdout = self._remote_tags + return result if cmd.startswith("status"): - return self.status_message - - assert kwargs.get("dryrun") is True - return Git.get_output(self, cmd, *args, **kwargs) + result.stdout = self.status_message + return result diff --git a/tests/scenarios.py b/tests/scenarios.py index b86a157..5f2522d 100644 --- a/tests/scenarios.py +++ b/tests/scenarios.py @@ -1,5 +1,6 @@ import argparse import contextlib +import difflib import io import logging import os @@ -19,7 +20,7 @@ SCENARIOS = os.path.join(conftest.TESTS, "scenarios") EXAMPLES = os.path.join(conftest.PROJECT_DIR, "examples") -SCENARIO_COMMANDS = ["explain -c180 -r", "explain -d", "explain --expand", "check", "entrypoints", "version"] +SCENARIO_COMMANDS = ["explain -c180 -r", "explain -d", "explain --expand", "check", "version"] def valid_scenarios(folder): @@ -57,7 +58,7 @@ class Scenario: folder = None # type: str # Folder where scenario is defined preparation = None # type: list[str] # Commands to run in preparation step commands = None # type: list[str] # setup.py commands to run - target = None # type: str # Folder where to run the scenario (temp folder for full git modification support) + target = None # type: str | None # Folder where to run the scenario (temp folder for full git modification support) temp = None # type: str # Optional temp folder used origin = None # type: str # Temp SCM origin to use @@ -65,6 +66,7 @@ class Scenario: def __init__(self, relative_path, in_place=False): self.short_name = relative_path src = os.path.join(conftest.PROJECT_DIR, relative_path) + assert isinstance(src, str) if in_place: self.folder = src self.target = relative_path @@ -92,7 +94,7 @@ def __init__(self, relative_path, in_place=False): self.target = None with io.open(extra_commands, "rt") as fh: for line in fh: - line = setupmeta.decode(line).strip() + line = line.strip() if line: if line.startswith(":"): self.preparation.append(line[1:]) @@ -103,11 +105,8 @@ def __init__(self, relative_path, in_place=False): def __repr__(self): return self.short_name - def run_git(self, *args, **kwargs): - kwargs.setdefault("capture", False) - kwargs.setdefault("cwd", self.target) - output = conftest.run_git(*args, **kwargs) - return output + def run_git(self, *args, cwd=None): + return conftest.run_git(*args, cwd=cwd or self.target) def prepare(self): if self.target: @@ -125,7 +124,10 @@ def prepare(self): copytree(self.folder, self.target) for command in self.preparation: - setupmeta.run_program(*command.split(), cwd=self.target) + result = setupmeta.run_program(*command.split(), cwd=self.target) + output = conftest.cleaned_output(result) + if output: + logging.debug("Preparation %s: n%s", command, output) self.run_git("add", ".") self.run_git("commit", "-m", "Initial commit") @@ -146,7 +148,8 @@ def replay(self): self.prepare() result = [] for command in self.commands: - output = ":: %s\n%s" % (command, conftest.spawn_setup_py(self.target, *command.split())) + output = conftest.spawn_setup_py(self.target, *command.split()) + output = ":: %s\n%s" % (command, output) result.append(output) return "\n\n".join(result).rstrip() @@ -200,8 +203,6 @@ def main(): elif args.command == "replay": folder = os.path.abspath(folder) with setupmeta.temp_resource(): - import difflib - scenario = Scenario(folder, in_place=False) expected = scenario.expected_contents() output = scenario.replay() diff --git a/tests/scenarios/README.rst b/tests/scenarios/README.rst index 9688fc7..ee98387 100644 --- a/tests/scenarios/README.rst +++ b/tests/scenarios/README.rst @@ -7,7 +7,7 @@ This folder contains test case scenarios. The tests consist of: * examples_ subfolders are also used as test case scenarios (the cases there are more "vanilla", while the cases here are edge-case oriented) -* commands ``explain`` and ``entrypoints`` are ran on each example, and their output is compared to ``expected.txt`` (has to match) +* commands ``explain`` and ``version`` are ran on each example, and their output is compared to ``expected.txt`` (has to match) * `scenarios.py`_ (or ``tox -e refreshscenarios``) can be used to regenerate ``expected.txt`` (see ``git diff`` to verify changes look good) diff --git a/tests/scenarios/So complex/.commands b/tests/scenarios/So complex/.commands index 284d2fb..691ae7d 100644 --- a/tests/scenarios/So complex/.commands +++ b/tests/scenarios/So complex/.commands @@ -6,3 +6,4 @@ version --bump major --commit --push version explain -c180 --name +:/dev/null/does/not/exist --version diff --git a/tests/scenarios/So complex/expected.txt b/tests/scenarios/So complex/expected.txt index 0b8c45c..c3d3607 100644 --- a/tests/scenarios/So complex/expected.txt +++ b/tests/scenarios/So complex/expected.txt @@ -2,20 +2,20 @@ author: (auto-adjust ) Someone \_: (setup.py:2 ) Someone someone@example.com author_email: (auto-adjust ) someone@example.com - classifiers: (explicit ) ["Programming Language :: Python"] + classifiers: (explicit ) ["Programming Language :: Python", "foo"] contact: (src/My_cplx_nm_here/__init__.py:14 ) Someone contact_email: (src/My_cplx_nm_here/__init__.py:15 ) me@example.com - description: (README:1 ) My cplx-nm_here - Sample complex project - \_: (src/My_cplx_nm_here/__init__.py:2 ) Short description of complex project + description: (explicit ) Wassuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu... + \_: (README:1 ) My cplx-nm_here - Sample complex project download_url: (auto-fill ) https://example.com/complex/archive/1.2.3+hlocal.tar.gz \_: (setup.py:4 ) archive/{version}.tar.gz entry_points: (explicit ) {console_scripts: a=b} \_: (entry_points.ini ) [foo] [console_scripts] foo = My_cplx_nm_here.some_module:main_func bar = My_cplx_nm_here.some_module:bar - extras_require: (explicit ) {bar: ["docutils"], baz: ["some", "long", "list-of", "requirements"], foo: ["long", "enough", "to-be", "abbreviated"]} + extras_require: (explicit ) 3 keys: {bar: ["docutils"], baz: ["some", "really", "long", "list-of", "requirements"], foo: ["long", "enough", "to-be",... install_requires: (pinned.txt ) ["a", "b", "c", "d>1", "e==1", "f", "g", "h", "i==1"] - keywords: (explicit ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] - \_: (setup.py:5 ) ["setup", "docstring"] - \_: (setup.py:12 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] + keywords: (explicit ) 16 items: ["some", "really", "long", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by"... + \_: (setup.py:5 ) ["setup", 'doc"string'] + \_: (setup.py:12 ) 16 items: ["some", "really", "long", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by"... \_: (src/My_cplx_nm_here/__version__.py:6) ["complex", "version"] \_: (src/My_cplx_nm_here/__init__.py:7 ) ["src", "complex", "init"] license: (explicit ) foo @@ -61,20 +61,22 @@ __version__ = "1.2.3+hlocal" setup( author="Someone", # from setup.py:2 author_email="someone@example.com", - classifiers=["Programming Language :: Python"], + classifiers=["Programming Language :: Python", "foo"], contact="Someone", # from src/My_cplx_nm_here/__init__.py:14 contact_email="me@example.com", # from src/My_cplx_nm_here/__init__.py:15 - description="My cplx-nm_here - Sample complex project", # from README:1 + description="Wassuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuup!?", download_url="https://example.com/complex/archive/%s.tar.gz" % __version__, # from setup.py:4 entry_points={"console_scripts": "a=b"}, extras_require={ "bar": ["docutils"], - "baz": ["some", "long", "list-of", "requirements"], + "baz": ["some", "really", "long", "list-of", "requirements"], "foo": ["long", "enough", "to-be", "abbreviated"] }, install_requires=["a", "b", "c", "d>1", "e==1", "f", "g", "h", "i==1"], # from pinned.txt keywords=[ "some", + "really", + "long", "list", "of", "keywords", @@ -104,9 +106,6 @@ setup( :: check [setupmeta] install_requires: 4 abstracted, 3 ignored, 5 untouched -:: entrypoints -a=b - :: version 1.2.3+hlocal @@ -114,22 +113,24 @@ a=b Not committing bump, use --commit to commit Not pushing bump, use --push to push Would update setup.py:6 with: version: 1.2.4 -Would update setup.py:11 with: __version__ = "1.2.4" +Would update setup.py:11 with: __version__ = '1.2.4' # fmt: skip # noqa: Q000 Would update src/My_cplx_nm_here/__version__.py:3 with: __version__ = "1.2.4" # Ignored due to setuptools_scm ref Would update src/My_cplx_nm_here/__init__.py:8 with: __version__ = "1.2.4" Would run: git add setup.py src/My_cplx_nm_here/__init__.py src/My_cplx_nm_here/__version__.py Would run: git commit -m "Version 1.2.4" --no-verify +Would run: git fetch --all Would run: git tag -a v1.2.4 -m "Version 1.2.4" :: version --bump minor --push Not committing bump, use --commit to commit Would update setup.py:6 with: version: 1.3.0 -Would update setup.py:11 with: __version__ = "1.3.0" +Would update setup.py:11 with: __version__ = '1.3.0' # fmt: skip # noqa: Q000 Would update src/My_cplx_nm_here/__version__.py:3 with: __version__ = "1.3.0" # Ignored due to setuptools_scm ref src/My_cplx_nm_here/__init__.py:8 already has the right version Would run: git add setup.py src/My_cplx_nm_here/__version__.py Would run: git commit -m "Version 1.3.0" --no-verify Would run: git push origin +Would run: git fetch --all Would run: git tag -a v1.3.0 -m "Version 1.3.0" Would run: git push --tags origin @@ -137,11 +138,12 @@ Would run: git push --tags origin Not committing bump, use --commit to commit Not pushing bump, use --push to push Would update setup.py:6 with: version: 2.0.0 -Would update setup.py:11 with: __version__ = "2.0.0" +Would update setup.py:11 with: __version__ = '2.0.0' # fmt: skip # noqa: Q000 Would update src/My_cplx_nm_here/__version__.py:3 with: __version__ = "2.0.0" # Ignored due to setuptools_scm ref Would update src/My_cplx_nm_here/__init__.py:8 with: __version__ = "2.0.0" Would run: git add setup.py src/My_cplx_nm_here/__init__.py src/My_cplx_nm_here/__version__.py Would run: git commit -m "Version 2.0.0" --no-verify +Would run: git fetch --all Would run: git tag -a v2.0.0 -m "Version 2.0.0" :: version --bump minor --commit @@ -149,12 +151,14 @@ Not pushing bump, use --push to push src/My_cplx_nm_here/__init__.py:8 already has the right version Running: git add setup.py src/My_cplx_nm_here/__version__.py Running: git commit -m "Version 1.3.0" --no-verify +Running: git fetch --all Running: git tag -a v1.3.0 -m "Version 1.3.0" :: version --bump major --commit --push Running: git add setup.py src/My_cplx_nm_here/__init__.py src/My_cplx_nm_here/__version__.py Running: git commit -m "Version 2.0.0" --no-verify Running: git push origin +Running: git fetch --all Running: git tag -a v2.0.0 -m "Version 2.0.0" Running: git push --tags origin @@ -165,20 +169,20 @@ Running: git push --tags origin author: (auto-adjust ) Someone \_: (setup.py:2 ) Someone someone@example.com author_email: (auto-adjust ) someone@example.com - classifiers: (explicit ) ["Programming Language :: Python"] + classifiers: (explicit ) ["Programming Language :: Python", "foo"] contact: (src/My_cplx_nm_here/__init__.py:14 ) Someone contact_email: (src/My_cplx_nm_here/__init__.py:15 ) me@example.com - description: (README:1 ) My cplx-nm_here - Sample complex project - \_: (src/My_cplx_nm_here/__init__.py:2 ) Short description of complex project + description: (explicit ) Wassuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu... + \_: (README:1 ) My cplx-nm_here - Sample complex project download_url: (auto-fill ) https://example.com/complex/archive/2.0.0+hlocal.tar.gz \_: (setup.py:4 ) archive/{version}.tar.gz entry_points: (explicit ) {console_scripts: a=b} \_: (entry_points.ini ) [foo] [console_scripts] foo = My_cplx_nm_here.some_module:main_func bar = My_cplx_nm_here.some_module:bar - extras_require: (explicit ) {bar: ["docutils"], baz: ["some", "long", "list-of", "requirements"], foo: ["long", "enough", "to-be", "abbreviated"]} + extras_require: (explicit ) 3 keys: {bar: ["docutils"], baz: ["some", "really", "long", "list-of", "requirements"], foo: ["long", "enough", "to-be",... install_requires: (pinned.txt ) ["a", "b", "c", "d>1", "e==1", "f", "g", "h", "i==1"] - keywords: (explicit ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] - \_: (setup.py:5 ) ["setup", "docstring"] - \_: (setup.py:12 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] + keywords: (explicit ) 16 items: ["some", "really", "long", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by"... + \_: (setup.py:5 ) ["setup", 'doc"string'] + \_: (setup.py:12 ) 16 items: ["some", "really", "long", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by"... \_: (src/My_cplx_nm_here/__version__.py:6) ["complex", "version"] \_: (src/My_cplx_nm_here/__init__.py:7 ) ["src", "complex", "init"] license: (explicit ) foo diff --git a/tests/scenarios/So complex/setup.py b/tests/scenarios/So complex/setup.py index ddb36ab..6cd93d9 100644 --- a/tests/scenarios/So complex/setup.py +++ b/tests/scenarios/So complex/setup.py @@ -2,28 +2,28 @@ author: Someone someone@example.com # will be auto-adjusted url: https://example.com/complex # url will be auto-completed download_url: archive/{version}.tar.gz # will be auto-completed too -keywords: setup, docstring +keywords: setup, doc"string # Exercise quoting edge case version: 1.0a1 """ from setuptools import setup -__version__ = "1.0b1" -__keywords__ = "some,list,of,keywords,here,long,enough,to,be,abbreviated,by,the,explain,command" +__version__ = '1.0b1' # fmt: skip # noqa: Q000 +__keywords__ = "some,really,long,list,of,keywords,here,long,enough,to,be,abbreviated,by,the,explain,command" __title__ = "My cplx-nm_here" setup( versioning="branch(release,main):{major}.{minor}.{patch}+h{$*BUILD_ID:local}.{dirty}", setup_requires=["setupmeta"], - # This will overshadow classifiers.txt - classifiers=["Programming Language :: Python"], + # Edge case galore + classifiers=["Programming Language :: Python", "foo"], extras_require={ "bar": ["docutils"], - "baz": ["some", "long", "list-of", "requirements"], + "baz": ["some", "really", "long", "list-of", "requirements"], "foo": ["long", "enough", "to-be", "abbreviated"], }, - # Edge case galore + description=f"Wass{'u' * 120}p!?", keywords=__keywords__.split(","), entry_points={"console_scripts": "a=b"}, license="foo", diff --git a/tests/scenarios/bogus/expected.txt b/tests/scenarios/bogus/expected.txt index 6f8e500..281010f 100644 --- a/tests/scenarios/bogus/expected.txt +++ b/tests/scenarios/bogus/expected.txt @@ -9,7 +9,7 @@ long_description_content_type: (README.md ) text/markdown setup_requires: (explicit ) ["setupmeta"] url: (missing ) - Consider specifying 'url' version: (git ) 1.0 - versioning: (explicit ) {extra: [], main: function 'main_part'} + versioning: (explicit ) {extra: ("foo", "bar"), main: function 'main_part'} :: explain -d # This reflects only auto-fill, doesn't look at explicit settings from your setup.py @@ -22,20 +22,17 @@ Generated by https://pypi.org/project/setupmeta/ from setuptools import setup __version__ = "1.0" setup( - description="bogus versioning spec", # from README.md:1 - long_description=open("README.md").read(), # from README.md - long_description_content_type="text/markdown", # from README.md + description="bogus versioning spec", # from README.md:1 + long_description=open("README.md").read(), # from README.md + long_description_content_type="text/markdown", # from README.md name="bogus", - version=__version__, # from git - # versioning={"extra": [], "main": function 'main_part'}, + version=__version__, # from git + # versioning={"extra": ("foo", "bar"), "main": function 'main_part'}, ) :: check -:: entrypoints - - :: version 1.0 @@ -43,7 +40,8 @@ setup( 1.0 :: version --bump patch - +setup.py -q version --bump patch exited with code 1: +error: Can't bump with custom version definition: function 'main_part' :: version 1.0 diff --git a/tests/scenarios/bogus/setup.py b/tests/scenarios/bogus/setup.py index 5ce1d92..80505a4 100644 --- a/tests/scenarios/bogus/setup.py +++ b/tests/scenarios/bogus/setup.py @@ -10,6 +10,6 @@ def main_part(_): setup_requires="setupmeta", versioning={ "main": main_part, - "extra": [], + "extra": ("foo", "bar"), }, ) diff --git a/tests/scenarios/complex-reqs/expected.txt b/tests/scenarios/complex-reqs/expected.txt index c1594b1..1fb7535 100644 --- a/tests/scenarios/complex-reqs/expected.txt +++ b/tests/scenarios/complex-reqs/expected.txt @@ -41,8 +41,5 @@ setup( :: check [setupmeta] install_requires: 0 abstracted, 1 ignored, 0 untouched -:: entrypoints - - :: version None diff --git a/tests/scenarios/disabled/.commands b/tests/scenarios/disabled/.commands deleted file mode 100644 index 779634a..0000000 --- a/tests/scenarios/disabled/.commands +++ /dev/null @@ -1 +0,0 @@ -cleanall diff --git a/tests/scenarios/disabled/expected.txt b/tests/scenarios/disabled/expected.txt index 3fefe3b..aae6ddb 100644 --- a/tests/scenarios/disabled/expected.txt +++ b/tests/scenarios/disabled/expected.txt @@ -10,10 +10,4 @@ :: check -:: entrypoints - - :: version - - -:: cleanall diff --git a/tests/scenarios/packaged/expected.txt b/tests/scenarios/packaged/expected.txt index 5dc2710..20bfbdb 100644 --- a/tests/scenarios/packaged/expected.txt +++ b/tests/scenarios/packaged/expected.txt @@ -54,8 +54,5 @@ pre-packaged-test = pre_packaged:main""", # from src/ :: check -:: entrypoints -pre-packaged-test = pre_packaged:main - :: version 1.3.2.dev4 diff --git a/tests/scenarios/pinned/expected.txt b/tests/scenarios/pinned/expected.txt index a0f42d3..8429446 100644 --- a/tests/scenarios/pinned/expected.txt +++ b/tests/scenarios/pinned/expected.txt @@ -36,8 +36,5 @@ setup( :: check -:: entrypoints - - :: version 0.1.0 diff --git a/tests/scenarios/readmes/expected.txt b/tests/scenarios/readmes/expected.txt index 123c491..bbc38e3 100644 --- a/tests/scenarios/readmes/expected.txt +++ b/tests/scenarios/readmes/expected.txt @@ -38,8 +38,5 @@ setup( :: check -:: entrypoints - - :: version 2.3.1.dev3 diff --git a/tests/scenarios/simple-src/expected.txt b/tests/scenarios/simple-src/expected.txt index 46765a4..b8c5f9f 100644 --- a/tests/scenarios/simple-src/expected.txt +++ b/tests/scenarios/simple-src/expected.txt @@ -29,8 +29,5 @@ setup( :: check -:: entrypoints - - :: version None diff --git a/tests/scenarios/via_req_files/expected.txt b/tests/scenarios/via_req_files/expected.txt index 24062c4..8bd3428 100644 --- a/tests/scenarios/via_req_files/expected.txt +++ b/tests/scenarios/via_req_files/expected.txt @@ -41,8 +41,5 @@ setup( :: check -:: entrypoints - - :: version None diff --git a/tests/test_commands.py b/tests/test_commands.py index 302d4a6..c8c0870 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -85,11 +85,10 @@ def test_version(sample_project): # noqa: ARG001, fixture @patch("sys.stdout.isatty", return_value=True) -@patch("os.popen", return_value=conftest.StringIO("60")) @patch.dict(os.environ, {"TERM": "testing"}) def test_console(*_): setupmeta.Console._columns = None - assert setupmeta.Console.columns() == 60 + assert setupmeta.Console.columns() == 160 def touch(folder, isdir, *paths): @@ -101,18 +100,3 @@ def touch(folder, isdir, *paths): else: with open(full_path, "w") as fh: fh.write("from setuptools import setup\nsetup(setup_requires='setupmeta')\n") - - -def test_clean(sample_project): - touch(sample_project, True, ".idea", "build", "foo.egg-info", "subfolder/foo/__pycache__") - touch(sample_project, False, "subfolder/foo/__pycache__/foo.pyc", "a.pyc", ".pyo", "bar.pyc", "setup.py") - run_setup_py( - ["cleanall"], - """ - deleted build - deleted foo.egg-info - deleted 2 .pyc files, 1 .pyo files - """, - ) - # Run a 2nd time: nothing to be cleaned anymore - run_setup_py(["cleanall"], "all clean, no deletable files found") diff --git a/tests/test_content.py b/tests/test_content.py index 8e9946a..4782c09 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,12 +1,7 @@ import os -import sys - -import pytest import setupmeta -from . import conftest - def test_shortening(): assert setupmeta.short(None) == "None" @@ -29,10 +24,6 @@ def test_shortening(): assert setupmeta.short({"foo": "bar"}, c=8) == "1 keys" - assert setupmeta.merged("a", None) == "a" - assert setupmeta.merged(None, "a") == "a" - assert setupmeta.merged("a", "b") == "a\nb" - def test_strip(): assert setupmeta.strip_dash(None) is None @@ -47,12 +38,7 @@ def test_listify(): assert setupmeta.listify("a,, b", separator=",") == ["a", "b"] assert setupmeta.listify("a,\n b", separator=",") == ["a", "b"] assert setupmeta.listify("a\n b", separator=",") == ["a", "b"] - - -def test_decode(): - assert setupmeta.decode(None) is None - assert setupmeta.decode("") == "" - assert setupmeta.decode(b"") == "" + assert setupmeta.listify(("a", "b")) == ["a", "b"] def test_parsing(): @@ -66,36 +52,6 @@ def test_parsing(): assert setupmeta.to_int(0, default=2) == 0 -def test_which(): - assert setupmeta.which(None) is None - assert setupmeta.which("/foo/does/not/exist") is None - assert setupmeta.which("foo/does/not/exist") is None - assert setupmeta.which("python3") - - -def test_run_program(): - setupmeta.DEBUG = True - with conftest.capture_output() as out: - assert setupmeta.run_program("ls", capture=True, dryrun=True) is None - assert setupmeta.run_program("ls", capture=False, dryrun=True) == 0 - assert setupmeta.run_program("ls", "foo/does/not/exist", capture=None) != 0 - assert setupmeta.run_program(sys.executable, "--version", capture=True) - assert setupmeta.run_program(sys.executable, "-c", "foo", capture=True) == "" - assert "NameError:" in setupmeta.run_program(sys.executable, "-c", "foo", capture="all") - assert setupmeta.run_program("/foo/does/not/exist", capture=True, dryrun=True) is None - assert setupmeta.run_program("/foo/does/not/exist", capture=False) != 0 - - with pytest.raises(SystemExit): - setupmeta.run_program("/foo/does/not/exist", fatal=True) - - with pytest.raises(SystemExit): - assert setupmeta.run_program("ls", "foo/does/not/exist", fatal=True) - - assert "exitcode" in out - - setupmeta.DEBUG = False - - def test_stringify(): assert setupmeta.stringify((1, 2)) == '("1", "2")' assert setupmeta.stringify(["1", "2"]) == '["1", "2"]' diff --git a/tests/test_model.py b/tests/test_model.py index 9cda027..d931e3f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,6 +1,4 @@ -import os import sys -from unittest.mock import patch import setupmeta from setupmeta.model import Definition, DefinitionEntry, get_pip, is_setup_py_path @@ -133,20 +131,6 @@ def test_empty(): assert str(meta).startswith("0 definitions, ") -@patch.dict(os.environ, {"PYGRADLE_PROJECT_VERSION": "1.2.3"}) -def test_pygradle_version(): - with conftest.capture_output(), conftest.TestMeta(setup="/dev/null/shouldnotexist/setup.py", name="pygradle_project") as meta: - assert len(meta.definitions) == 2 - assert meta.value("name") == "pygradle_project" - assert meta.value("version") == "1.2.3" - - name = meta.definitions["name"] - version = meta.definitions["version"] - - assert name.is_explicit - assert not version.is_explicit - - def test_meta(): assert not is_setup_py_path(None) assert not is_setup_py_path("") diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 878bfa2..c95ff41 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -2,7 +2,6 @@ Verify that ../examples/*/setup.py behave as expected """ -import subprocess import sys import pytest @@ -19,8 +18,9 @@ def scenario_folder(request): yield request.param -def test_scenario(scenario_folder): +def test_scenario(scenario_folder, monkeypatch): """Check that 'scenario' yields expected explain output""" + monkeypatch.setenv("SETUPMETA_RUNNING_SCENARIOS", "1") with conftest.capture_output(): scenario = scenarios.Scenario(scenario_folder) assert str(scenario) == scenario_folder @@ -31,7 +31,7 @@ def test_scenario(scenario_folder): def test_adhoc_replay(): with setupmeta.current_folder(conftest.PROJECT_DIR): - result = subprocess.run([sys.executable, "tests/scenarios.py", "replay", "examples/single"], capture_output=True) # noqa: S603 + result = setupmeta.run_program(sys.executable, "tests/scenarios.py", "replay", "tests/scenarios/bogus") assert result.returncode == 0 - output = setupmeta.decode(result.stdout) + output = conftest.cleaned_output(result) assert "OK, no diffs found" in output diff --git a/tests/test_scm.py b/tests/test_scm.py index 4dde8ed..a1f7332 100644 --- a/tests/test_scm.py +++ b/tests/test_scm.py @@ -11,7 +11,6 @@ def test_scm(): assert scm.get_version() is None assert scm.commit_files(False, False, None, "") is None assert scm.apply_tag(False, False, "", "main") is None - assert scm.get_output() is None def test_git(): @@ -23,12 +22,18 @@ def test_git(): assert not str(out) git.commit_files(False, True, ["foo"], "2.0") + assert out.pop() == 'Would run: git add foo\nWould run: git commit -m "Version 2.0" --no-verify\nWould run: git push origin' git.apply_tag(False, True, "2.0", "main") - assert "Would run: git add foo" in out - assert 'Would run: git commit -m "Version 2.0"' in out - assert "Would run: git push origin" in out - assert 'Would run: git tag -a v2.0 -m "Version 2.0"' in out - assert "Would run: git push --tags origin" in out + assert out.pop() == 'Would run: git fetch --all\nWould run: git tag -a v2.0 -m "Version 2.0"\nWould run: git push --tags origin' + + with pytest.raises(SystemExit): + git.apply_tag(True, True, "2.0", "main") + + assert out.pop() == "chatty stderr\ngit --tags origin exited with code 1:\noops push failed" + + report = git.get_diff_report() + assert report == "some diff stats" + assert out.pop() == "WARNING: git --stat exited with code 1, stderr:\noops something happened" git._has_origin = "" with conftest.capture_output() as out: @@ -36,27 +41,17 @@ def test_git(): assert not str(out) git.commit_files(False, True, ["foo"], "2.0") + assert "Won't push: no origin defined" in out.pop() git.apply_tag(False, True, "2.0", "main") - assert "Would run: git add foo" in out - assert 'Would run: git commit -m "Version 2.0"' in out - assert 'Would run: git tag -a v2.0 -m "Version 2.0"' in out - assert "Not running 'git push --tags origin' as you don't have an origin" in out - - assert "Would run: git push origin" not in out - assert "Would run: git push --tags origin" not in out + assert "Would run: git push " not in out + assert "Not running 'git push --tags origin' as you don't have an origin" in out.pop() git._has_origin = True git.status_message = "## main...origin/main [behind 1]" - with pytest.raises(setupmeta.UsageError): + with pytest.raises(setupmeta.UsageError, match="branch 'main' is out of date"): git.apply_tag(False, True, "2.0", "main") -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): monkeypatch.setenv("SETUPMETA_GIT_DESCRIBE_COMMAND", "describe foo") diff --git a/tests/test_setup_py.py b/tests/test_setup_py.py new file mode 100644 index 0000000..debeba0 --- /dev/null +++ b/tests/test_setup_py.py @@ -0,0 +1,105 @@ +""" +Tests based on running legacy setup.py +""" + +import sys +from pathlib import Path + +import setupmeta + +from . import conftest + +SAMPLE_EMPTY_PROJECT = """ +from setuptools import setup +setup( + name='testing', + py_modules=['foo'], + setup_requires='setupmeta', + versioning='distance', +) +""" + + +def write_to_file(path, text): + with open(path, "w") as fh: + fh.write(text) + fh.write("\n") + + +def setup_py_output(*args): + result = setupmeta.run_program(sys.executable, "setup.py", *args) + return conftest.cleaned_output(result) + + +def test_brand_new_project(): + with setupmeta.temp_resource(): + conftest.run_git("init") + with open("setup.py", "w") as fh: + fh.write(SAMPLE_EMPTY_PROJECT) + + # Test that we avoid warning about no tags etc. on brand-new empty git repos + assert setup_py_output("--version") == "0.0.0" + + # Now stage a file + conftest.run_git("add", "setup.py") + assert setup_py_output("--version") == "0.0.0+dirty" + + # Un-stage it + conftest.run_git("reset", "setup.py") + assert setup_py_output("--version") == "0.0.0" + + # Commit it, and touch a new file + conftest.run_git("add", "setup.py") + conftest.run_git("commit", "-m", "Initial commit") + with open("foo", "w") as fh: + fh.write("foo\n") + + assert setup_py_output("--version") == "0.0.1" + + +def test_git_versioning(sample_project): # noqa: ARG001, fixture + output = setup_py_output("--version") + assert output == "0.0.1" + + # Bump with no initial tags shouldn't warn + output = setup_py_output("version", "--bump", "minor") + assert "UserWarning" not in output + assert "Would run: git tag -a v0.1.0" in output + + conftest.run_git("tag", "-a", "v0.1.0", "-m", "Version 2.4.2") + output = setup_py_output("--version") + assert output == "0.1.0" + + output = setup_py_output("explain") + assert "0.1.0" in output + assert "UserWarning" not in output + + # New file does not change dirtiness + write_to_file("foo", "print('hello')") + output = setup_py_output("--version") + assert output == "0.1.0" + + # Modify existing file makes checkout dirty + write_to_file("sample.py", "__version__ = '0.1.0'\nprint('hello')") + output = setup_py_output("--version") + assert output == "0.1.0+dirty" + + # git add -> version should still be dirty, as we didn't commit yet + conftest.run_git("add", "sample.py") + output = setup_py_output("--version") + assert output == "0.1.0+dirty" + + # git commit -> version reflects new distance + conftest.run_git("commit", "-m", "Testing") + output = setup_py_output("--version") + assert output == "0.1.1" + + # Bump minor, we should get 0.2.0 + output = setup_py_output("version", "--bump", "minor", "--commit") + assert "Not pushing bump, use --push to push" in output + assert "Running: git add sample.py" in output + assert "Running: git tag -a v0.2.0" in output + output = setup_py_output("--version") + assert output == "0.2.0" + content = Path("sample.py").read_text() + assert content.startswith("__version__ = '0.2.0'\n") diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 48ff9db..f3ff851 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1,5 +1,4 @@ import os -import sys from pathlib import Path from unittest.mock import patch @@ -51,10 +50,13 @@ def test_deprecated_strategy_notation(): assert "'!' character in 'versioning' is now deprecated" in logged -def test_disabled(): - with conftest.capture_output(): +def test_disabled(monkeypatch): + monkeypatch.setattr(setupmeta, "TRACE_ENABLED", True) + with conftest.capture_output() as out: meta = new_meta(False) versioning = meta.versioning + logged = out.pop() + assert ":: versioning given: 'None', strategy: [None], problem: [setupmeta versioning not enabled]" in logged assert not versioning.enabled assert versioning.problem == "setupmeta versioning not enabled" with pytest.raises(setupmeta.UsageError, match="versioning not enabled"): @@ -87,7 +89,6 @@ def test_snapshot_with_version_file(): versioning = meta.versioning assert meta.version == "1.2.3.post4" assert not versioning.generate_version_file - assert versioning.scm.program is None assert str(versioning.scm).startswith("snapshot ") assert not versioning.scm.is_dirty() assert versioning.scm.get_branch() == "HEAD" @@ -445,103 +446,6 @@ def check_bump(versioning): versioning.bump("foo") -def write_to_file(path, text): - with open(path, "w") as fh: - fh.write(text) - fh.write("\n") - - -SAMPLE_EMPTY_PROJECT = """ -from setuptools import setup -setup( - name='testing', - py_modules=['foo'], - setup_requires='setupmeta', - versioning='distance', -) -""" - - -def check_version_output(expected): - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - output = conftest.cleaned_output(output) - assert output == expected - - -def test_brand_new_project(): - with setupmeta.temp_resource(): - conftest.run_git("init") - with open("setup.py", "w") as fh: - fh.write(SAMPLE_EMPTY_PROJECT) - - # Test that we avoid warning about no tags etc. on brand-new empty git repos - check_version_output("0.0.0") - - # Now stage a file - conftest.run_git("add", "setup.py") - check_version_output("0.0.0+dirty") - - # Un-stage it - conftest.run_git("reset", "setup.py") - check_version_output("0.0.0") - - # Commit it, and touch a new file - conftest.run_git("add", "setup.py") - conftest.run_git("commit", "-m", "Initial commit") - with open("foo", "w") as fh: - fh.write("foo\n") - - check_version_output("0.0.1") - - -def test_git_versioning(sample_project): # noqa: ARG001, fixture - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.0.1" - - # Bump with no initial tags shouldn't warn - output = setupmeta.run_program(sys.executable, "setup.py", "version", "--bump", "minor", capture="all") - assert "UserWarning" not in output - assert "Would run: git tag -a v0.1.0" in output - - conftest.run_git("tag", "-a", "v0.1.0", "-m", "Version 2.4.2") - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.1.0" - - output = setupmeta.run_program(sys.executable, "setup.py", "explain", capture="all") - assert "0.1.0" in output - assert "UserWarning" not in output - - # New file does not change dirtiness - write_to_file("foo", "print('hello')") - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.1.0" - - # Modify existing file makes checkout dirty - write_to_file("sample.py", "__version__ = '0.1.0'\nprint('hello')") - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.1.0+dirty" - - # git add -> version should still be dirty, as we didn't commit yet - conftest.run_git("add", "sample.py") - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.1.0+dirty" - - # git commit -> version reflects new distance - conftest.run_git("commit", "-m", "Testing") - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.1.1" - - # Bump minor, we should get 0.2.0 - output = setupmeta.run_program(sys.executable, "setup.py", "version", "--bump", "minor", "--commit", capture=True) - assert "Not pushing bump, use --push to push" in output - assert "Running: git add sample.py" in output - assert "Running: git tag -a v0.2.0" in output - output = setupmeta.run_program(sys.executable, "setup.py", "--version", capture=True) - assert output == "0.2.0" - content = Path("sample.py").read_text() - assert content.startswith("__version__ = '0.2.0'\n") - - def test_missing_tags(): with conftest.capture_output() as logged: meta = new_meta("distance", scm=conftest.MockGit(describe="v0.1.2-3-g123", local_tags="v1.0\nv1.1", remote_tags="v1.0\nv2.0")) diff --git a/tox.ini b/tox.ini index 646a318..17c3f33 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ setenv = COVERAGE_FILE={toxworkdir}/.coverage.{envname} usedevelop = True deps = -rtests/requirements.txt py37: pip + py37: wheel commands = pytest {posargs:-vv --cov=setupmeta --cov-report=xml tests} [testenv:coverage] @@ -55,6 +56,10 @@ commands = python tests/scenarios.py regen ignore-bad-ideas = PKG-INFO ignore = .setupmeta.version +[coverage:run] +patch = subprocess +data_file = .tox/.coverage + [coverage:xml] output = .tox/test-reports/coverage.xml [coverage:html] From 2f1ebe72be7f22ddeedd2cf668f24d60829e65f9 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 22:18:43 -0800 Subject: [PATCH 2/8] Reviewed docs --- HISTORY.rst | 2 +- README.rst | 18 +++++-------- docs/commands.rst | 43 ++++++++++++-------------------- docs/contributing.rst | 2 +- docs/versioning.rst | 18 ++++--------- examples/direct/README.rst | 9 ++++--- examples/hierarchical/README.rst | 17 ++++++------- 7 files changed, 42 insertions(+), 67 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index ebf5eca..ecee81c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -17,7 +17,7 @@ Release notes * Internal project modernizations: - * Refactored usage of ``subrocess.run()``, removed last left overs from the py2 days + * Refactored usage of ``subprocess.run()``, removed last left overs from the py2 days * use ``uv``, ``ruff``, enabled more linter rules, etc diff --git a/README.rst b/README.rst index 0e69f35..73aac34 100644 --- a/README.rst +++ b/README.rst @@ -65,10 +65,6 @@ See examples_ for more. git describe --dirty --tags --long --first-parent --match 'v*.*' - # Then, if above yields nothing, we try the more vague '*.*' - - git describe --dirty --tags --long --first-parent --match '*.*' - you will need **git version >= 1.8.4** if you wish to use ``setupmeta``'s versioning capabilities. @@ -84,7 +80,7 @@ The goal of this project is to: * Support tag-based versioning_ (like setuptools_scm_, but with super simple configuration/defaults and automated ``bump`` capability) * Provide useful Commands_ to see the metadata (**explain**), **version** (including support for bumping versions), - **cleanall**, etc + and **check** How it works? @@ -102,12 +98,10 @@ How it works? * ``entry_points`` is auto-filled from file ``entry_points.ini`` (bonus: tools like PyCharm have a nice syntax highlighter for those) -* ``install_requires`` is auto-filled if you have a ``requirements.txt`` (or ``pinned.txt``) file, +* ``install_requires`` is auto-filled from ``requirements.in`` (preferred), then ``requirements.txt`` + (or ``pinned.txt`` for older projects), pinning is abstracted away by default as per `community recommendation`_, see requirements_ for more info. -* ``tests_require`` is auto-filled if you have a ``tests/requirements.txt``, or ``requirements-dev.txt``, - or ``dev-requirements.txt``, or ``test-requirements.txt`` file - * ``description`` will be the 1st line of your README (unless that 1st line is too short, or is just the project's name), or the 1st line of the first docstring found in the scanned files (see list below) @@ -130,7 +124,7 @@ How it works? * tag "v1.0.0", 5 commits since tag -> version is "1.0.5" - * if checkout is dirty, a marker is added -> version would be "1.0.5.post5.dirty" + * if checkout is dirty, a marker is added -> version would be "1.0.5+dirty" * With ``versioning="post"``, your git tags will be of the form ``v{major}.{minor}.{patch}``, a "post" addendum will be present if there are commits since latest version tag: @@ -139,7 +133,7 @@ How it works? * tag "v1.0.0", 5 commits since tag -> version is "1.0.0.post5" - * if checkout is dirty, a marker is added -> version would be "1.0.0.post5.dirty" + * if checkout is dirty, a marker is added -> version would be "1.0.0.post5+dirty" * With ``versioning="build-id"``, your git tags will be of the form ``v{major}.{minor}.0``, the number of commits since latest version tag will be used to auto-fill the "patch" part of the version: @@ -154,7 +148,7 @@ How it works? * if checkout is dirty, a marker is added -> version would be "1.0.5+hlocal.g456.dirty" - * Use the **bump** command (see commands_) to easily bump (ie: increment major, minor or patch + apply git tag) + * Use the **version** command (see commands_) to easily bump (ie: increment major, minor or patch + apply git tag) * Version format can be customized, see versioning_ for more info diff --git a/docs/commands.rst b/docs/commands.rst index add44a1..93b9a2e 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -18,21 +18,23 @@ For example, this is what setupmeta says about itself (it's self-using):: author: (auto-adjust ) Zoran Simic \_: (setupmeta/__init__.py:6) Zoran Simic zoran@simicweb.com author_email: (auto-adjust ) zoran@simicweb.com - classifiers: (classifiers.txt ) 21 items: ["Development Status :: 5 - Production/Stable", "Intend... + bugtrack_url: (auto-fill ) https://github.com/codrsquad/setupmeta/issues + classifiers: (explicit ) 23 items: ["Development Status :: 5 - Production/Stable", "Intend... description: (setupmeta/__init__.py:2) Simplify your setup.py - download_url: (auto-fill ) https://github.com/codrsquad/setupmeta/archive/v2.1.1.tar.gz + download_url: (auto-fill ) https://github.com/codrsquad/setupmeta/archive/v3.9.0.tar.gz \_: (setupmeta/__init__.py:5) archive/v{version}.tar.gz - entry_points: (explicit ) [distutils.commands] check = setupmeta.commands:CheckCommand clea... - keywords: (setup.py:4 ) ["simple", "DRY", "setup.py"] + entry_points: (explicit ) [distutils.commands] check = setupmeta.commands:CheckCommand expla... + include_package_data: (MANIFEST.in ) True + install_requires: (explicit ) ["setuptools>=67"] license: (auto-fill ) MIT long_description: (README.rst ) Simplify your setup.py ====================== .. image:: https://... long_description_content_type: (README.rst ) text/x-rst - name: (setup.py:16 ) setupmeta - packages: (auto-fill ) ["setupmeta"] + name: (explicit ) setupmeta + packages: (explicit ) ["setupmeta"] + python_requires: (explicit ) >=3.7 setup_requires: (explicit ) ["setupmeta"] - title*: (setup.py:16 ) setupmeta url: (setupmeta/__init__.py:4) https://github.com/codrsquad/setupmeta - version: (git ) 2.1.1 + version: (git ) 3.9.0 versioning: (explicit ) dev zip_safe: (explicit ) True @@ -48,9 +50,9 @@ In the above output: had a value that came from 2 different sources, final value showing at top, while all the other values seen showing below with the ``\_`` indicator. -* ``classifiers`` came from file ``classifiers.txt`` +* ``classifiers`` came from explicit settings in ``setup.py`` -* ``description`` came from ``setup.py`` line 2 +* ``description`` came from ``setupmeta/__init__.py`` line 2 * ``download_url`` was defined in ``setupmeta/__init__.py`` line 5, since it was mentioning ``{version}`` (and was a relative path), it got auto-expanded and filled in properly @@ -59,25 +61,12 @@ In the above output: * ``long_description`` came from ``README.rst`` -* ``name`` came from line 16 of setup.py, note that ``title`` also came from that line - - this simply means the constant ``__title__`` was used as ``name`` +* ``name`` came from an explicit setting in setup.py -* Note that ``title*`` is shown with an asterisk, the asterisk means that setupmeta sees - the value and can use it, but doesn't transfer it to setuptools +* ``packages`` came from explicit settings in setup.py -* ``packages`` was auto-filled to ``["setupmeta"]`` - -* ``version`` was determined from git tag (due to ``versioning="post"`` in setup.py), - in this case ``1.1.2.post1+g816252c`` means: - - * latest tag was 1.1.2 - - * there was 1 commit since that tag (``.post1`` means 1 change since tag, - ``".post"`` denotes this would be a "post-release" version, - and should play nicely with PEP-440_) - - * the ``+g816252c`` suffix means that the checkout wasn't clean when ``explain`` command - was ran, local checkout was dirty at short git commit id "816252c" +* ``version`` was determined from git (due to ``versioning="dev"`` in setup.py), + in this case ``3.9.0`` means current commit is exactly on a version tag If you'd like to see what your ``setup.py`` would look like without setupmeta diff --git a/docs/contributing.rst b/docs/contributing.rst index a4e4ddf..850b0c9 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -52,7 +52,7 @@ A coverage report is generated on all ``tox`` runs Run this to see the generated html report:: - open .tox/coverage/index.html + open .tox/test-reports/htmlcov/index.html Refreshing the test scenarios diff --git a/docs/versioning.rst b/docs/versioning.rst index 1301df9..e31814e 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -39,7 +39,7 @@ How does it work? **Note**: ``setupmeta``'s versioning is based on (by default):: - git describe --dirty --tags --long --match *.* --first-parent + git describe --dirty --tags --long --first-parent --match 'v*.*' you will need **git version >= 1.8.4** if you wish to use ``setupmeta``'s versioning capabilities. @@ -144,7 +144,7 @@ Example: Commit Tag Version Note (command ran to add tag) ======= ====== ================= ============================================================== no .git 0.0.0 Version defaults to 0.0.0 (when no tag yet) -none 0.0.0.dirty No commit yet (but ``git init`` was ran) +none 0.0.0+dirty No commit yet (but ``git init`` was ran) g1 0.0.0.post1 Initial commit g1 0.0.0.post1+dirty Same as above, only checkout was not clean anymore g2 0.0.0.post2 @@ -242,7 +242,7 @@ distance This is well suited if you want to publish a new version at every commit (but don't want to keep bumping version in code for every commit). -``distance`` corresponds to this format: ``branch(main,master):{major}.{minor}.{distance}{dirty}`` +``distance`` corresponds to this format: ``branch(main,master):{major}.{minor}.{distance}+{dirty}`` State this in your ``setup.py``:: @@ -387,21 +387,13 @@ This is what ``versioning="post"`` is a shortcut for:: "main": "{major}.{minor}.{patch}{post}", "extra": "{dirty}", "branches": ["main"], - "version_tag": "*.*", + "version_tag": "v*.*", }, ... ) -Note that starting with setupmeta v4.0, only ``v*.*`` tags are taken into account by default. -The above complex ``versioning`` form will be the only way to continue using old ``*.*`` version tags... - -Prior to setupmeta v4.0, setupmeta used to look for ``v*.*`` first, and if it didn't find anything, -it would "try harder" by falling back to looking for ``*.*``. - ``version_tag`` is the glob pattern of git tags to consider as version tags. -Unfortunately (for historical reasons), the default form was ``*.*`` (ie: any git tag -with a dot in it), and arguably should have been ``v*.*`` (ie: git tags that start with ``v`` -and have dot in them...) +The default form is ``v*.*`` (ie: git tags that start with ``v`` and have a dot in them). Ideally, git would allow to state a full regex, as only tags that match this regex would ideally be considered as version tags: ``^v?\d+\.\d+(\.\d+)?$``, however this is not diff --git a/examples/direct/README.rst b/examples/direct/README.rst index 391535e..2604f87 100644 --- a/examples/direct/README.rst +++ b/examples/direct/README.rst @@ -23,9 +23,11 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long |-- LICENSE.txt |-- README.rst - |-- classifiers.txt + |-- MANIFEST.in |-- direct/ # Python module as subfolder | |-- __init__.py # Definitions are taken from here + | |-- some_submodule/ + | |-- __init__.py |-- entry_points.ini |-- requirements.txt |-- setup.py @@ -65,7 +67,7 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long author: (auto-adjust ) Someone \_: (direct/__init__.py:5 ) Someone someone@example.com author_email: (auto-adjust ) someone@example.com - classifiers: (classifiers.txt ) ['Framework :: Pytest', 'Programming Language :: Python', 'License :: OSI Approved :: Apache Software License'] + bugtrack_url: (auto-fill ) https://github.com/codrsquad/simple/issues description: (direct/__init__.py:2 ) A package implemented by one direct ((not under src/)) module folder download_url: (auto-fill ) https://github.com/codrsquad/simple/archive/1.0.0.tar.gz \_: (direct/__init__.py:10) https://github.com/codrsquad/simple/archive/{version}.tar.gz @@ -75,8 +77,7 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long license: (auto-fill ) Apache 2.0 long_description: (README.rst ) 611 chars: direct: A package implemented by one direct ((not under src/)) module folder ... name: (setup.py:4 ) direct - packages: (auto-fill ) ['direct'] + packages: (auto-fill ) ['direct', 'direct.some_submodule'] setup_requires: (explicit ) ['setupmeta'] - title*: (setup.py:4 ) direct url: (direct/__init__.py:9 ) https://github.com/codrsquad/simple version: (direct/__init__.py:8 ) 1.0.0 diff --git a/examples/hierarchical/README.rst b/examples/hierarchical/README.rst index f850e5d..4c299a0 100644 --- a/examples/hierarchical/README.rst +++ b/examples/hierarchical/README.rst @@ -22,7 +22,6 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long |-- LICENSE.txt |-- README.rst - |-- classifiers.txt |-- entry_points.ini |-- requirements.txt |-- setup.py @@ -54,8 +53,8 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long """ __version__ = '1.0.0' - __url__ = "https://github.com/codrsquad/simple" - __download_url__ = "https://github.com/codrsquad/simple/archive/{version}.tar.gz" + __url__ = "https://github.com/codrsquad" + __download_url__ = "archive/{version}.tar.gz" def main(): @@ -67,13 +66,13 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long author: (auto-adjust ) Someone \_: (src/hierarchical/__init__.py:5 ) Someone someone@example.com author_email: (auto-adjust ) someone@example.com - classifiers: (classifiers.txt ) ['Framework :: Pytest', 'Programming Language :: Python', 'License :: OSI Approved :: MIT License'] + bugtrack_url: (auto-fill ) https://github.com/codrsquad/hierarchical/issues description: (README.rst:1 ) A hierarchical package (code under src/) \_: (src/hierarchical/__init__.py:2 ) A hierarchical package (code under src/, tests under tests/) - download_url: (auto-fill ) https://github.com/codrsquad/simple/archive/1.0.0.tar.gz - \_: (src/hierarchical/__init__.py:10) https://github.com/codrsquad/simple/archive/{version}.tar.gz + download_url: (auto-fill ) https://github.com/codrsquad/hierarchical/archive/1.0.0.tar.gz + \_: (src/hierarchical/__init__.py:10) archive/{version}.tar.gz entry_points: (entry_points.ini ) [console_scripts] hierarchical = hierarchical:main subm = hierarchical.submodule:main - install_requires: (requirements.txt ) ['arrow', 'click>=6.7'] + install_requires: (requirements.txt ) ['click>=6.7', 'pytest-cov'] keywords: (src/hierarchical/__init__.py:4 ) ['hierarchical', 'package'] license: (auto-fill ) MIT long_description: (README.rst ) 616 chars: hierarchical: A hierarchical package (code under src/) ... @@ -81,6 +80,6 @@ This part will be ignored for setup.py ``long_description``, due to ``[[end long package_dir: (auto-fill ) {: src} packages: (auto-fill ) ['hierarchical', 'hierarchical.submodule'] setup_requires: (explicit ) ['setupmeta'] - title*: (setup.py:4 ) hierarchical - url: (src/hierarchical/__init__.py:9 ) https://github.com/codrsquad/simple + url: (auto-fill ) https://github.com/codrsquad/hierarchical + \_: (src/hierarchical/__init__.py:9 ) https://github.com/codrsquad version: (src/hierarchical/__init__.py:8 ) 1.0.0 From c808ca7b58f24aad601b0dadcc48d7f40d42da90 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 22:56:39 -0800 Subject: [PATCH 3/8] Update tests/scenarios.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/scenarios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/scenarios.py b/tests/scenarios.py index 5f2522d..13e7396 100644 --- a/tests/scenarios.py +++ b/tests/scenarios.py @@ -127,7 +127,7 @@ def prepare(self): result = setupmeta.run_program(*command.split(), cwd=self.target) output = conftest.cleaned_output(result) if output: - logging.debug("Preparation %s: n%s", command, output) + logging.debug("Preparation %s: \n%s", command, output) self.run_git("add", ".") self.run_git("commit", "-m", "Initial commit") From e840692b6edcdf8cdabde91827aa6cb8abad2078 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 23:00:45 -0800 Subject: [PATCH 4/8] Added AGENTS.md --- AGENTS.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 12 +++++-- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f767d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# AGENTS.md + +## Purpose + +This file defines repo-specific guidance for coding agents working on `setupmeta`. +Use it as the first source of truth for how to make changes safely and consistently. + +## Project Summary + +- `setupmeta` helps keep `setup.py` files minimal by auto-filling metadata and requirements. +- It provides custom setup commands: `explain`, `version`, and `check`. +- It supports git-tag-based versioning and automated version bumps. +- The project is self-hosted: `setup.py` uses `setupmeta` itself and has bootstrap behavior. + +## Core Principles + +- Preserve backward compatibility for users relying on legacy `setup.py` workflows. +- Keep runtime dependencies minimal; prefer stdlib for runtime code. +- Favor explicit, test-covered behavior over cleverness. +- Keep documentation aligned with actual behavior and test scenarios. + +## Repository Layout + +- `setupmeta/`: main library code (`commands`, `model`, `scm`, `versioning`, etc.) +- `tests/`: unit tests + scenario replay tests +- `tests/scenarios/` and `examples/`: behavior fixtures with `expected.txt` snapshots +- `docs/`: user and contributor documentation +- `setup.py`: self-bootstrapping package definition +- `tox.ini`: test/lint/docs/coverage orchestration + +## Environment and Commands + +Use these commands from repo root: + +- Quick tests: `.venv/bin/pytest -q` +- Full test/lint/docs run: `tox` +- Single tox env: `tox -e py314` +- Fast compatibility matrix (old+new + coverage): `tox -e py39,py314,coverage` +- Style only: `tox -e style` +- Docs checks: `tox -e docs` +- Refresh scenario snapshots: `tox -e refreshscenarios` +- Manual command checks: + - `.venv/bin/python setup.py explain` + - `.venv/bin/python setup.py version` + - `.venv/bin/python setup.py check -q` + +Notes: + +- `tox.ini` pins `UV_CACHE_DIR` to `.tox/.uv-cache`, so no command-line prefix is needed. +- `py37` exists because it is the oldest Python still supported by this library. +- If `py37` is unavailable locally (common on macOS arm64), substitute the oldest available interpreter and pair it with the newest one. + Example: system Python `3.9` + `3.14` via `tox -e py39,py314,coverage`. +- Intent: keep local runs fast while still exercising one older runtime and one modern runtime, with `coverage combine` validating cross-env coverage data. + +## Code Change Expectations + +- Keep changes narrow and focused. +- Maintain Python compatibility targeted by this repo (including older supported versions). +- When changing behavior, update both tests and docs in the same change. +- Do not silently alter CLI output formats used by scenario snapshots unless intentional. +- If you touch versioning logic, verify: + - `tests/test_versioning.py` + - `tests/test_setup_py.py` + - scenario outputs that include `version`/`explain`. + +## Scenario Snapshot Rules + +- Scenario tests compare command output against `expected.txt`. +- If behavior changes are intentional: + 1. Regenerate snapshots (`tox -e refreshscenarios`). + 2. Review diffs in `tests/scenarios/*/expected.txt` and `examples/*/expected.txt`. + 3. Ensure docs and release notes explain user-visible changes. +- If behavior changes are not intentional, fix code/tests instead of accepting snapshot churn. + +## Documentation Rules + +- Keep `README.rst`, `docs/*.rst`, and example READMEs consistent with current behavior. +- Avoid documenting deprecated/removed commands as active features. +- Keep versioning docs aligned with current strategy defaults and command examples. +- Record user-visible changes in `HISTORY.rst`. + +## Safety and Review Checklist + +Before finalizing a change, verify: + +1. Tests pass for affected areas. +2. Formatting/lint checks pass. +3. Scenario snapshots are unchanged unless intentionally updated. +4. Docs are updated if behavior or commands changed. +5. No unrelated files were modified. + +## When to Ask for Clarification + +Ask the maintainer before proceeding if: + +- A change could break backward compatibility. +- Expected output changes are broad/noisy and intent is unclear. +- A docs update conflicts with tested behavior. +- A refactor touches bootstrap or version bump internals in `setup.py`, `setupmeta/hook.py`, `setupmeta/versioning.py`, or `setupmeta/scm.py`. diff --git a/tox.ini b/tox.ini index 17c3f33..60ec0af 100644 --- a/tox.ini +++ b/tox.ini @@ -7,15 +7,20 @@ indexserver = default = https://pypi.org/simple [testenv] -setenv = COVERAGE_FILE={toxworkdir}/.coverage.{envname} +setenv = UV_CACHE_DIR={toxworkdir}/.uv-cache + COVERAGE_FILE={toxworkdir}/.coverage.{envname} usedevelop = True deps = -rtests/requirements.txt py37: pip py37: wheel commands = pytest {posargs:-vv --cov=setupmeta --cov-report=xml tests} +[testenv:.pkg] +setenv = UV_CACHE_DIR={toxworkdir}/.uv-cache + [testenv:coverage] -setenv = COVERAGE_FILE={toxworkdir}/.coverage +setenv = {[testenv]setenv} + COVERAGE_FILE={toxworkdir}/.coverage skip_install = True deps = coverage commands = coverage combine @@ -24,7 +29,8 @@ commands = coverage combine coverage html [testenv:docs] -setenv = PYTHONWARNINGS=ignore +setenv = {[testenv]setenv} + PYTHONWARNINGS=ignore skip_install = True deps = check-manifest readme-renderer From 1f61d78695fb513c8cc0e9a2443fdcd152973a13 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 23:03:40 -0800 Subject: [PATCH 5/8] Corrected `clear()` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/conftest.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index df887b0..dfc2ccb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,10 +127,12 @@ def __repr__(self): def clear(self): """Clear captured content""" - self.out_buffer.seek(0) - self.out_buffer.truncate(0) - self.err_buffer.seek(0) - self.err_buffer.truncate(0) + if self.out_buffer is not None: + self.out_buffer.seek(0) + self.out_buffer.truncate(0) + if self.err_buffer is not None: + self.err_buffer.seek(0) + self.err_buffer.truncate(0) def pop(self): result = str(self) From c09d0cff94eb3ee15206400660425ca5c1861d29 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 23:05:14 -0800 Subject: [PATCH 6/8] Record args correctly Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index dfc2ccb..a6542bf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -256,7 +256,7 @@ def run_program(self, cmd, *args, announce=False, dryrun=False): if dryrun: return setupmeta.run_program("git", cmd, *args, announce=announce, dryrun=dryrun) - result = setupmeta.RunResult(program="git", args=args) + result = setupmeta.RunResult(program="git", args=(cmd, *args)) if cmd in ("fetch", "add", "commit"): return result From 8618b09f061dd9a686dbb53f10f7a8ddef89ec9c Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 23:06:52 -0800 Subject: [PATCH 7/8] AGENTS.md not needed in sdist --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 60ec0af..aece327 100644 --- a/tox.ini +++ b/tox.ini @@ -61,6 +61,7 @@ commands = python tests/scenarios.py regen [check-manifest] ignore-bad-ideas = PKG-INFO ignore = .setupmeta.version + AGENTS.md [coverage:run] patch = subprocess From fec2e08f4fb58284c6b58a6716dea88ca8e9a1f1 Mon Sep 17 00:00:00 2001 From: Zoran Simic Date: Tue, 10 Feb 2026 23:11:00 -0800 Subject: [PATCH 8/8] Corrected tests --- tests/test_scm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_scm.py b/tests/test_scm.py index a1f7332..b700dfa 100644 --- a/tests/test_scm.py +++ b/tests/test_scm.py @@ -29,11 +29,11 @@ def test_git(): with pytest.raises(SystemExit): git.apply_tag(True, True, "2.0", "main") - assert out.pop() == "chatty stderr\ngit --tags origin exited with code 1:\noops push failed" + assert out.pop() == "chatty stderr\ngit push --tags origin exited with code 1:\noops push failed" report = git.get_diff_report() assert report == "some diff stats" - assert out.pop() == "WARNING: git --stat exited with code 1, stderr:\noops something happened" + assert out.pop() == "WARNING: git diff --stat exited with code 1, stderr:\noops something happened" git._has_origin = "" with conftest.capture_output() as out: