diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e09713..533fa7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,28 +3,29 @@ name: Release on: push: tags: - - 'v[0-9]*' + - "v[0-9]*" jobs: - publish-wheel: - + publish: + name: Build and publish to PyPI runs-on: ubuntu-latest environment: release permissions: id-token: write # mandatory for trusted publishing + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: "3.13" - - run: | - pip install -U pip setuptools wheel tox - git config --global user.name tester - git config --global user.email tester@example.com - - run: tox -e py,docs,style,security - - run: python setup.py sdist bdist_wheel --universal + - name: Configure git + run: | + git config --global user.name "GH-actions-bot" + git config --global user.email "gh-actions-bot@noreply.github.com" - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: astral-sh/setup-uv@v7 + - run: uvx --with tox-uv tox -e py,docs,style + - run: uv build + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fa78d3a..358a8e0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,63 +6,86 @@ on: pull_request: branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: - + name: Test py${{ matrix.python-version }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - run: | - pip install -U pip tox - git config --global user.name tester - git config --global user.email tester@example.com - - run: tox -e py - - uses: codecov/codecov-action@v4 + - name: Configure git + run: | + git config --global user.name "GH-actions-bot" + git config --global user.email "gh-actions-bot@noreply.github.com" + + - uses: astral-sh/setup-uv@v7 + - run: uvx --with tox-uv tox -e py + + - uses: coverallsapp/github-action@v2 with: - files: .tox/test-reports/coverage.xml + file: .tox/test-reports/coverage.xml + flag-name: python-${{ matrix.python-version }} + parallel: true test-eol: - # EOL-ed versions of python are exercised on older linux distros - # Testing against these versions will eventually be retired - runs-on: ubuntu-20.04 + name: Test py${{ matrix.python-version }} (EOL) + runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9'] + python-version: ["3.7"] # Only one ancient python needed for test coverage... steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: deadsnakes/action@v3.2.0 with: python-version: ${{ matrix.python-version }} - - run: | - pip install -U pip tox - git config --global user.name tester - git config --global user.email tester@example.com - - run: tox -e py - - uses: codecov/codecov-action@v4 + - name: Configure git + run: | + git config --global user.name "GH-actions-bot" + git config --global user.email "gh-actions-bot@noreply.github.com" + + - uses: astral-sh/setup-uv@v7 + - run: uvx --with tox-uv tox -e py + + - uses: coverallsapp/github-action@v2 with: - files: .tox/test-reports/coverage.xml + file: .tox/test-reports/coverage.xml + flag-name: python-${{ matrix.python-version }} + parallel: true - linters: + coveralls-finish: + name: Finish Coveralls + needs: [test, test-eol] + runs-on: ubuntu-latest + steps: + - name: Finish parallel build + uses: coverallsapp/github-action@v2 + with: + parallel-finished: true + linters: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: "3.14" - - run: pip install -U pip tox - - run: tox -e docs,style,security + - uses: astral-sh/setup-uv@v7 + - run: uvx --with tox-uv tox -e docs,style diff --git a/.gitignore b/.gitignore index 60d9ee8..1c63a87 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .DS_Store* *.lock .*version +.coverage .idea/ # Build artifacts diff --git a/HISTORY.rst b/HISTORY.rst index e9a7376..5547397 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,18 @@ Release notes ============= +3.9.0 (2026-02-09) +------------------ + +* Test with py3.14, publish sdist with py3.13 + +* Removed post-version-bump hook support (unused, unnecessary, and was not well documented) + +* Internal project modernizations (use ``uv``, ``ruff``, enabled more linter rules, etc) + +* Use coveralls_ for test coverage reporting + + 3.8.0 (2025-03-25) ------------------ @@ -555,3 +567,5 @@ Release notes .. _pip-compile: https://pypi.org/project/pip-tools/ .. _hdeps: https://pypi.org/project/hdeps/ + +.. _coveralls: https://coveralls.io/ diff --git a/README.rst b/README.rst index 6b33707..0e69f35 100644 --- a/README.rst +++ b/README.rst @@ -9,9 +9,9 @@ Simplify your setup.py :target: https://github.com/codrsquad/setupmeta/actions :alt: Tested with Github Actions -.. image:: https://codecov.io/gh/codrsquad/setupmeta/branch/main/graph/badge.svg - :target: https://codecov.io/gh/codrsquad/setupmeta - :alt: Test code codecov +.. image:: https://coveralls.io/repos/github/codrsquad/setupmeta/badge.svg?branch=main + :target: https://coveralls.io/github/codrsquad/setupmeta?branch=main + :alt: Code coverage with coveralls .. image:: https://img.shields.io/pypi/pyversions/setupmeta.svg :target: https://github.com/codrsquad/setupmeta diff --git a/docs/contributing.rst b/docs/contributing.rst index 027b6cf..a4e4ddf 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -37,14 +37,12 @@ locally installed. You can use pyenv_ for example to get python installations. Run: -* ``tox -e py310`` (for example) to limit test run to only one python version. +* ``tox -e py314`` (for example) to limit test run to only one python version. * ``tox -e style`` to run style checks only * ``tox -e docs`` to verify that the main README.rst renders properly -* ``tox -e security`` to run the security checks - Test coverage ============= diff --git a/examples/direct/setup.py b/examples/direct/setup.py index afc0771..e85c1b5 100644 --- a/examples/direct/setup.py +++ b/examples/direct/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="direct", setup_requires=["setupmeta"], diff --git a/examples/direct/tests/README.md b/examples/direct/tests/README.md deleted file mode 100644 index 4edce80..0000000 --- a/examples/direct/tests/README.md +++ /dev/null @@ -1 +0,0 @@ -`tests/` submodules should NOT be picked up by setupmeta for `packages` auto-fill. diff --git a/examples/direct/tests/__init__.py b/examples/direct/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/hierarchical/setup.py b/examples/hierarchical/setup.py index 99d61d2..22f748d 100644 --- a/examples/hierarchical/setup.py +++ b/examples/hierarchical/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="hierarchical", setup_requires=["setupmeta"], diff --git a/examples/single/setup.py b/examples/single/setup.py index 0eb6983..2fabf76 100644 --- a/examples/single/setup.py +++ b/examples/single/setup.py @@ -5,7 +5,6 @@ from setuptools import setup - setup( name="single", setup_requires=["setupmeta"], diff --git a/examples/via-cfg/setup.py b/examples/via-cfg/setup.py index 5691254..1ca3bad 100644 --- a/examples/via-cfg/setup.py +++ b/examples/via-cfg/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( setup_requires=["setupmeta"], ) diff --git a/pyproject.toml b/pyproject.toml index ba750a7..64c422d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,53 @@ [build-system] -requires = ["setuptools!=77.0.3"] # Temporarily avoid 77.0.3, see https://github.com/pypa/setuptools/issues/4902 +requires = ["setuptools"] build-backend = "setuptools.build_meta" + +[tool.ruff] +cache-dir = ".tox/.ruff_cache" +line-length = 140 + +[tool.ruff.lint] +ignore = ["RUF005", "RUF012"] +extend-select = [ + "A", # flake8-builtins + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "C90", # mccabe + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "ERA", # eradicate + "EXE", # flake8-executable + "F", # pyflakes + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "INT", # flake8-gettext + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PT", # flake8-pytest + "PYI", # flake8-pyi + "Q", # flake8-quotes + "RSE", # flake8-raise + "RUF", # ruff-specific + "S", #flake8-bandit + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "T10", # flake8-debugger + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "TD", # flake8-todos + "TRY", # tryceratops + "W", # pycodestyle warnings +] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # Sigh `bandit` devs... + +[tool.ruff.lint.isort] +order-by-type = false + +[tool.ruff.lint.mccabe] +max-complexity = 24 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" diff --git a/setup.py b/setup.py index d12ae7c..0d86ea6 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,11 @@ # This library is self-using and auto-bootstraps itself import os -import subprocess # nosec +import subprocess import sys import setuptools - HERE = os.path.dirname(os.path.abspath(__file__)) EGG = os.path.join(HERE, "setupmeta.egg-info") @@ -36,7 +35,7 @@ def decode(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) # nosec + p = subprocess.Popen([sys.executable, "setup.py", "egg_info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) # noqa: S603 output, error = p.communicate() if p.returncode: print(decode(output)) @@ -57,13 +56,13 @@ def complete_args(args): have_egg = os.path.isdir(EGG) # Explicit on entry points due to bootstrap - args = dict( - name="setupmeta", - entry_points=ENTRY_POINTS, - packages=["setupmeta"], - python_requires=">=3.7", - zip_safe=True, - classifiers=[ + args = { + "name": "setupmeta", + "entry_points": ENTRY_POINTS, + "packages": ["setupmeta"], + "python_requires": ">=3.7", + "zip_safe": True, + "classifiers": [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X", @@ -78,6 +77,7 @@ def complete_args(args): "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Build Tools", @@ -87,7 +87,7 @@ def complete_args(args): "Topic :: System :: Software Distribution", "Topic :: Utilities", ], - ) + } if have_egg: # We're bootstrapped, we can self-refer complete_args(args) diff --git a/setupmeta/__init__.py b/setupmeta/__init__.py index bdaa766..c122808 100644 --- a/setupmeta/__init__.py +++ b/setupmeta/__init__.py @@ -6,11 +6,12 @@ author: Zoran Simic zoran@simicweb.com """ +import contextlib import os import platform import re import shutil -import subprocess # nosec +import subprocess import sys import tempfile import warnings @@ -21,12 +22,10 @@ DEBUG = os.environ.get("SETUPMETA_DEBUG") VERSION_FILE = ".setupmeta.version" # File used to work with projects that are in a subfolder of a git checkout SCM_DESCRIBE = "SCM_DESCRIBE" # Name of env var used as pass-through for cases where git checkout is not available -TESTING = False # Set to True while running tests RE_SPACES = re.compile(r"\s+", re.MULTILINE) RE_VERSION_COMPONENT = re.compile(r"(\d+|[A-Za-z]+)") PLATFORM = platform.system().lower() -WINDOWS = PLATFORM.startswith("win") PKGID = "[A-Za-z0-9][-A-Za-z0-9_.]*" # Simplistic parsing of known formats used in requirements.txt @@ -54,6 +53,7 @@ def trace(message): """Output 'message' if tracing is on""" if not DEBUG: return + sys.stderr.write(":: %s\n" % message) sys.stderr.flush() @@ -72,7 +72,7 @@ def to_int(text, default=None): def short(text, c=None): - """ Short representation of 'text' """ + """Short representation of 'text'""" if not text: return "%s" % text @@ -82,9 +82,6 @@ def short(text, c=None): result = stringify(text).strip() result = result.replace(USER_HOME, "~") result = re.sub(RE_SPACES, " ", result) - if WINDOWS: # pragma: no cover - result = result.replace("\\", "/") - if c and len(result) > abs(c): if c < 0: return "%s..." % result[:-c] @@ -96,7 +93,7 @@ def short(text, c=None): summary = "%s items" % len(text) else: - return "%s..." % result[:c - 3] + return "%s..." % result[: c - 3] cutoff = c - len(summary) - 5 if cutoff <= 0: @@ -108,7 +105,7 @@ def short(text, c=None): def strip_dash(text): - """ Strip leading dashes from 'text' """ + """Strip leading dashes from 'text'""" if not text: return text @@ -116,9 +113,6 @@ def strip_dash(text): def is_executable(path): - if WINDOWS: # pragma: no cover - return path and os.path.isfile(path) and path.endswith(".exe") - return path and os.path.isfile(path) and os.access(path, os.X_OK) @@ -168,9 +162,6 @@ def which(program): if not program: return None - if WINDOWS and not program.endswith(".exe"): # pragma: no cover - program += ".exe" - if os.path.isabs(program): if is_executable(program): return program @@ -224,7 +215,6 @@ def run_program(program, *args, **kwargs): 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 @@ -236,22 +226,17 @@ def run_program(program, *args, **kwargs): return None if capture else 1 - if capture is None: + if capture in (None, "testing-scenarios"): print("Running: %s" % represented) - if TESTING: - # Avoid pass-through chatter in tests - kwargs["stdout"] = subprocess.PIPE - kwargs["stderr"] = subprocess.PIPE - else: + if capture is not None or capture == "testing-scenarios": kwargs["stdout"] = subprocess.PIPE kwargs["stderr"] = subprocess.PIPE - p = subprocess.Popen([full_path] + list(args), **kwargs) # nosec + 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() @@ -262,24 +247,18 @@ def run_program(program, *args, **kwargs): trace_msg = "%s, error: [%s]" % (trace_msg, error.strip()) trace(trace_msg) - if capture: - if p.returncode: - if not _should_ignore_run_fail(program, args, error): - warn("%s exited with error code %s\n%s" % (represented, p.returncode, error or "-no stderr-")) + 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-")) if capture == "all": return merged(output, error) - return merged(output, None) + return output if p.returncode: - if fatal or TESTING: - message = error - if TESTING: - message = "stdout: %s\nstderr: %s" % (output, error) - - print("%s exited with code %s:\n%s" % (represented, p.returncode, message)) + if fatal: + print("%s exited with code %s:\n%s" % (represented, p.returncode, error)) if fatal: sys.exit(p.returncode) @@ -308,7 +287,7 @@ def _should_ignore_run_fail(program, args, error): def decode(value): """Python 2/3 friendly decoding of output""" if isinstance(value, bytes): - return value.decode("utf-8") + value = value.decode("utf-8") return value @@ -387,7 +366,7 @@ def project_path(*relative_paths): def relative_path(full_path): """Relative path to current project_dir""" - return full_path[len(MetaDefs.project_dir) + 1:] if full_path and full_path.startswith(MetaDefs.project_dir) else full_path + return full_path[len(MetaDefs.project_dir) + 1 :] if full_path and full_path.startswith(MetaDefs.project_dir) else full_path def readlines(relative_path, limit=0): @@ -404,11 +383,13 @@ def readlines(relative_path, limit=0): result.append(line) trace("read %s lines from %s" % (len(result), relative_path)) - return result except IOError: return None + else: + return result + def requirements_from_text(text): """Transform contents of a requirements.txt file to an appropriate form for install_requires @@ -496,15 +477,15 @@ def __init__(self, parent, source_path, line_number, parent_section, line): if " #" in line: # Trailing comments can direct us to treat that particular line in a certain way regarding pinning i = line.index(" #") - self.local_section = self._set_comment(line[i + 2:]) + self.local_section = self._set_comment(line[i + 2 :]) line = line[:i].strip() - if line.startswith("-e ") or line.startswith("--editable "): + if line.startswith(("-e ", "--editable ")): self.editable = True p = line.partition(" ") line = p[2].strip() - elif line.startswith("-r ") or line.startswith("--requirement "): + elif line.startswith(("-r ", "--requirement ")): _, _, self.refers = line.partition(" ") self.refers = self.refers.strip() if self.refers: @@ -618,7 +599,7 @@ def iterate_req_txt(seen, parent, source_path, lines): class RequirementsFile: - """ Keeps track of where requirements came from """ + """Keeps track of where requirements came from""" def __init__(self, do_abstract=True): self.do_abstract = do_abstract @@ -684,7 +665,7 @@ def from_lines(cls, lines, do_abstract=False, source_path=None): def find_requirements(*relative_paths): - """ Read old-school requirements.txt type file """ + """Read old-school requirements.txt type file""" for path in relative_paths: if path: path = project_path(path) @@ -697,7 +678,7 @@ def find_requirements(*relative_paths): class Requirements: - """ Allows to auto-fill requires from requirements.txt """ + """Allows to auto-fill requires from requirements.txt""" def __init__(self, pkg_info): """ @@ -750,17 +731,14 @@ def __enter__(self): def __exit__(self, *args): os.chdir(self.old_cwd) - try: + with contextlib.suppress(OSError): shutil.rmtree(self.path) - except OSError: # pragma: no cover - pass - -def meta_command_init(self, dist, **kwargs): +def meta_command_init(self, dist, **_): """Custom __init__ injected to commands decorated with @MetaCommand""" self.setupmeta = getattr(dist, "_setupmeta", None) - super(self.__class__, self).__init__(dist, **kwargs) + super(self.__class__, self).__init__(dist) class UsageError(Exception): @@ -799,14 +777,14 @@ class MetaDefs: @classmethod def register_command(cls, command): - """ Register our own '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 x: None + command.initialize_options = lambda _: None if command.finalize_options == setuptools.Command.finalize_options: - command.finalize_options = lambda x: None + command.finalize_options = lambda _: None if not hasattr(command, "user_options"): command.user_options = [] @@ -817,7 +795,7 @@ def register_command(cls, command): @classmethod def dist_to_dict(cls, dist): """ - :param distutils.dist.Distribution dist: Distribution or attrs + :param setuptools.dist.Distribution dist: Distribution or attrs :return dict: """ if not dist or isinstance(dist, dict): @@ -873,7 +851,7 @@ 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() # nosec + cols = os.popen("tput cols", "r").read() # noqa: S605, S607 cols = decode(cols) cls._columns = to_int(cols, default=None) diff --git a/setupmeta/commands.py b/setupmeta/commands.py index e4f6b44..9aececb 100644 --- a/setupmeta/commands.py +++ b/setupmeta/commands.py @@ -12,7 +12,7 @@ import setupmeta - +CLEANABLE_EXTENSIONS = {"egg-info", "pyc", "pyo", "pyd"} flatten = chain.from_iterable @@ -115,9 +115,9 @@ def run(self): print(self.setupmeta.version) except setupmeta.UsageError as e: - from distutils.errors import DistutilsSetupError + from setuptools.errors import SetupError - raise DistutilsSetupError(e) + raise SetupError(e) from None @MetaCommand @@ -242,12 +242,7 @@ def show_expanded_python(self): if source and source != "explicit": comment = "# from %s" % setupmeta.short(source) rest, _, last_line = line.rpartition("\n") - if len(last_line) < longest: - padding = " " * (longest - len(last_line)) - - else: - padding = " " - + padding = " " * max(1, longest - len(last_line)) last_line = "%s%s%s" % (last_line, padding, comment) line = "%s\n%s" % (rest, last_line) if rest else last_line @@ -280,14 +275,13 @@ def run(self): if definitions: longest_key = min(30, max(len(key) for key in definitions)) - sources = sum((d.sources for d in definitions.values()), []) + sources = [s for d in definitions.values() for s in d.sources] longest_source = min(40, max(len(s.source) for s in sources)) form = "%%%ss: (%%%ss) %%s" % (longest_key, -longest_source) max_chars = max(60, self.chars - longest_key - longest_source - 5) for definition in sorted(definitions.values()): - count = 0 - for source in definition.sources: + for count, source in enumerate(definition.sources): if count: prefix = "\\_" @@ -300,7 +294,6 @@ def run(self): preview = setupmeta.short(source.value, c=max_chars) s = form % (prefix, setupmeta.short(source.source), preview) print(s) - count += 1 @MetaCommand @@ -357,11 +350,6 @@ def get_console_scripts(entry_points): class CleanCommand(setuptools.Command): """Clean build artifacts and virtual envs""" - direct = set(".cache .tox build dist venv".split()) - ignored = set(".git .gradle .idea .venv".split()) - dirs = set("__pycache__".split()) - extensions = set("egg-info pyc pyo pyd".split()) - deleted = 0 by_ext = None @@ -377,7 +365,7 @@ def delete(self, full_path): self.deleted += 1 def clean_direct(self): - for target in self.direct: + for target in (".cache", ".tox", "build", "dist", "venv"): full_path = setupmeta.project_path(target) if os.path.exists(full_path): self.delete(full_path) @@ -392,16 +380,16 @@ def run(self): for dirpath, dirnames, filenames in os.walk(setupmeta.MetaDefs.project_dir): remove = [] for dname in dirnames: - if dname in self.ignored: + if dname in (".git", ".gradle", ".idea", ".venv"): remove.append(dname) - elif dname in self.dirs: + elif dname == "__pycache__": remove.append(dname) self.delete(os.path.join(dirpath, dname)) else: ext = dname.rpartition(".")[2] - if ext in self.extensions: + if ext in CLEANABLE_EXTENSIONS: remove.append(dname) self.delete(os.path.join(dirpath, dname)) @@ -410,7 +398,7 @@ def run(self): for fname in filenames: ext = fname.rpartition(".")[2] - if ext in self.extensions: + if ext in CLEANABLE_EXTENSIONS: self.delete(os.path.join(dirpath, fname)) if self.by_ext: diff --git a/setupmeta/content.py b/setupmeta/content.py index bcf410c..9a0c834 100644 --- a/setupmeta/content.py +++ b/setupmeta/content.py @@ -8,7 +8,6 @@ import setupmeta - # Recognized README tokens RE_README_TOKEN = re.compile(r"(.?)\.\. \[\[([a-z]+) (.+)\]\](.)?") @@ -26,7 +25,7 @@ def load_contents(relative_path, limit=0): def load_readme(relative_path, limit=0): - """ Loader for README files """ + """Loader for README files""" lines = setupmeta.readlines(relative_path, limit=limit) if lines is not None: content = [] diff --git a/setupmeta/hook.py b/setupmeta/hook.py index b837c24..f12a144 100644 --- a/setupmeta/hook.py +++ b/setupmeta/hook.py @@ -2,10 +2,11 @@ Hook for setuptools/distutils """ -import distutils.dist import functools import warnings +import setuptools.dist + from setupmeta.model import MetaDefs, SetupMeta @@ -20,14 +21,12 @@ def finalize_dist(dist, setup_requires=None): """ setup_requires = setup_requires or dist.setup_requires setup_requires = setup_requires if isinstance(setup_requires, list) else [setup_requires] + if setup_requires and any(dep.startswith("setupmeta") for dep in setup_requires if hasattr(dep, "startswith")): + dist._setupmeta = SetupMeta().preprocess(dist) + MetaDefs.fill_dist(dist, dist._setupmeta.to_dict(only_meaningful=False)) - if setup_requires: - if any(dep.startswith("setupmeta") for dep in setup_requires if hasattr(dep, "startswith")): - dist._setupmeta = SetupMeta().preprocess(dist) - MetaDefs.fill_dist(dist, dist._setupmeta.to_dict(only_meaningful=False)) - - # Override parse_command_line for this instance only. - dist.parse_command_line = functools.partial(parse_command_line, dist) + # Override parse_command_line for this instance only. + dist.parse_command_line = functools.partial(parse_command_line, dist) # Make sure we are run before any other finalizer. @@ -35,12 +34,12 @@ def finalize_dist(dist, setup_requires=None): # See: https://github.com/pypa/setuptools/commit/6b210c65938527a4bbcea34942fe43971be3c014 finalize_dist.order = -100 -# Reference to original distutils.dist.Distribution.parse_command_line -parse_command_line_orig = distutils.dist.Distribution.parse_command_line +# Reference to original setuptools.dist.Distribution.parse_command_line +parse_command_line_orig = setuptools.dist.Distribution.parse_command_line -def parse_command_line(dist, *args, **kwargs): # noqa: E302 (keep override close to function it replaces) - """distutils.dist.Distribution.parse_command_line replacement +def parse_command_line(dist, *_, **__): + """setuptools.dist.Distribution.parse_command_line replacement This allows us to insert setupmeta's imputed values for various attributes after all configuration has interpreted and read from config files, and just @@ -49,8 +48,7 @@ def parse_command_line(dist, *args, **kwargs): # noqa: E302 (keep override clos """ dist._setupmeta.finalize(dist) MetaDefs.fill_dist(dist, dist._setupmeta.to_dict()) - - return parse_command_line_orig(dist, *args, **kwargs) + return parse_command_line_orig(dist) def register_keyword(dist, name, value): @@ -84,5 +82,6 @@ def register(dist, name, value): # pragma: no cover; Should not be used in norm "`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/license.py b/setupmeta/license.py index 6ff15ab..60e7cda 100644 --- a/setupmeta/license.py +++ b/setupmeta/license.py @@ -11,7 +11,6 @@ import re - RE_VERSION = re.compile(r"version (\d+(.\d+)?)", re.IGNORECASE) @@ -72,7 +71,7 @@ def determined_license(contents): :param str|None contents: Contents to determine license from :return str: Short license name """ - for license in KNOWN_LICENSES: - short = license.match(contents) + for license_spec in KNOWN_LICENSES: + short = license_spec.match(contents) if short: return short diff --git a/setupmeta/model.py b/setupmeta/model.py index 07b247e..2aa0a8e 100644 --- a/setupmeta/model.py +++ b/setupmeta/model.py @@ -10,13 +10,26 @@ import setuptools -from setupmeta import current_folder, get_words, listify, MetaDefs, PKGID, project_path, readlines, relative_path -from setupmeta import Requirements, requirements_from_file, RequirementsFile, short, trace, warn +from setupmeta import ( + current_folder, + get_words, + listify, + MetaDefs, + PKGID, + project_path, + readlines, + relative_path, + Requirements, + requirements_from_file, + RequirementsFile, + short, + trace, + warn, +) from setupmeta.content import find_contents, load_contents, load_readme, resolved_paths from setupmeta.license import determined_license from setupmeta.versioning import project_scm, Versioning - # Used to mark which key/values were provided explicitly in setup.py EXPLICIT = "explicit" READMES = ["README.rst", "README.md", "README*"] @@ -36,12 +49,20 @@ # Beautify short description RE_DESCRIPTION = re.compile(r"^[\W\s]*((([\w\-]+)\s*[:-])?\s*(.+))$", re.IGNORECASE) +PKG_CANONICAL_KEYS = { + "classifier": "classifiers", + "description": "long_description", + "description_content_type": "long_description_content_type", + "home_page": "url", + "summary": "description", +} +PKG_LIST_TYPES = {"classifiers", "long_description"} + def is_setup_py_path(path): - """ Is 'path' pointing to a setup.py module? """ + """Is 'path' pointing to a setup.py module?""" if path: - # Accept also setup.pyc - return os.path.basename(path).startswith("setup.py") + return os.path.basename(path).startswith("setup.py") # Accept also setup.pyc def content_type_from_filename(filename): @@ -49,13 +70,13 @@ def content_type_from_filename(filename): if filename: if filename.endswith(".rst"): return "text/x-rst" + if filename.endswith(".md"): return "text/markdown" - return None class DefinitionEntry: - """ Record of where a definition was found and where it came from """ + """Record of where a definition was found and where it came from""" def __init__(self, key, value, source): """ @@ -72,12 +93,12 @@ def __repr__(self): @property def is_explicit(self): - """ Did this entry come explicitly from setup(**attrs)? """ + """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 """ + """Record definitions for a given key, and where they were found""" def __init__(self, key): """ @@ -88,10 +109,7 @@ def __init__(self, key): self.sources = [] # type: list[DefinitionEntry] def __repr__(self): - if len(self.sources) == 1: - source = self.sources[0].source - else: - source = "%s sources" % len(self.sources) + source = self.sources[0].source if len(self.sources) == 1 else "%s sources" % len(self.sources) return "%s=%s from %s" % (self.key, short(self.value), source) def __eq__(self, other): @@ -109,21 +127,22 @@ def actual_source(self): @property def source(self): - """ Winning source """ + """Winning source""" if self.sources: return self.sources[0].source @property def is_explicit(self): - """ Did this entry come explicitly from setup(**attrs)? """ + """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' """ + """Record the fact that we saw this definition in 'sources'""" for entry in sources: if not self.value and entry.value: self.value = entry.value trace("[-- %s] %s=%s" % (entry.source, self.key, entry.value)) + self.sources.append(entry) def add(self, value, source, override=False): @@ -135,24 +154,27 @@ def add(self, value, source, override=False): if isinstance(source, list): self.merge_sources(source) return + if override or not self.value: self.value = value + entry = DefinitionEntry(self.key, value, source) if override: self.sources.insert(0, entry) trace("[<- %s] %s=%s" % (source, self.key, short(value))) + else: self.sources.append(entry) trace("[-> %s] %s=%s" % (source, self.key, short(value))) @property def is_meaningful(self): - """ Should this definition make it to the final setup attrs? """ + """Should this definition make it to the final setup attrs?""" return self.value is not None or self.is_explicit class Settings: - """ Collection of key/value pairs with info on where they came from """ + """Collection of key/value pairs with info on where they came from""" def __init__(self): self.definitions = {} # type: dict[str, Definition] @@ -162,16 +184,17 @@ def __repr__(self): return "%s definitions, %s" % (len(self.definitions), project_dir) def value(self, key): - """ Value currently associated to 'key', if any """ + """Value currently associated to 'key', if any""" definition = self.definitions.get(key) return definition and definition.value def to_dict(self, only_meaningful=True): - """ Resolved attributes to pass to setuptools """ + """Resolved attributes to pass to setuptools""" result = {} for definition in self.definitions.values(): if not only_meaningful or definition.is_meaningful: result[definition.key] = definition.value + return result def add_definition(self, key, value, source, override=False): @@ -184,21 +207,23 @@ def add_definition(self, key, value, source, override=False): if key and (value or override): if key in ("keywords", "setup_requires"): value = listify(value, separator=",") + definition = self.definitions.get(key) if definition is None: definition = Definition(key) self.definitions[key] = definition + definition.add(value, source, override=override) def merge(self, *others): - """ Merge settings from 'others' """ + """Merge settings from 'others'""" for other in others: for definition in other.definitions.values(): self.add_definition(definition.key, definition.value, definition.sources) class SimpleModule(Settings): - """ Simple settings extracted from a module, such as __about__.py """ + """Simple settings extracted from a module, such as __about__.py""" def __init__(self, *relative_paths): """ @@ -222,18 +247,23 @@ def __init__(self, *relative_paths): docstring_marker = None if docstring: self.scan_docstring(docstring, line_number=docstring_start - 1) + else: docstring.append(line) + continue - if line.startswith('"""') or line.startswith("'''"): + + if line.startswith(('"""', "'''")): docstring_marker = line[:3] if len(line) > 3 and line.endswith(docstring_marker): # Single docstring line edge case docstring_marker = None continue + docstring_start = line_number docstring.append(line[3:]) continue + self.scan_line(line, RE_PY_VALUE, line_number) def add_pair(self, key, value, line, **kwargs): @@ -241,25 +271,28 @@ def add_pair(self, key, value, line, **kwargs): source = self.relative_path if line: source = "%s:%s" % (source, line) + self.add_definition(key, value, source, **kwargs) def scan_docstring(self, lines, line_number=0): - """ Scan docstring for definitions """ + """Scan docstring for definitions""" if not lines[0]: # Disregard the 1st empty line, it's very common lines.pop(0) line_number += 1 - if lines and lines[0]: - if not RE_DOC_VALUE.match(lines[0]): - # Take first non-empty, non key-value line as docstring lead - line = lines.pop(0).rstrip() - line_number += 1 - if len(line) > 5 and line[0].isalnum(): - self.add_pair("docstring_lead", line, line_number) + + if lines and lines[0] and not RE_DOC_VALUE.match(lines[0]): + # Take first non-empty, non key-value line as docstring lead + line = lines.pop(0).rstrip() + line_number += 1 + if len(line) > 5 and line[0].isalnum(): + self.add_pair("docstring_lead", line, line_number) + if lines and not lines[0]: # Skip blank line after lead, if any lines.pop(0) line_number += 1 + for line in lines: line_number += 1 line = line.rstrip() @@ -268,53 +301,37 @@ def scan_docstring(self, lines, line_number=0): break def scan_line(self, line, regex, line_number): - """ Scan 'line' using 'regex', return True if no match found """ + """Scan 'line' using 'regex', return True if no match found""" m = regex.match(line) if m: key = m.group(1) value = m.group(2) self.add_pair(key, value, line_number) return False + return True -def get_pip(): # pragma: no cover, see https://github.com/pypa/setuptools/issues/2355 +def get_pip(): """ - Deprecated, see https://github.com/codrsquad/setupmeta/issues/49 + 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+ """ - try: - # pip >= 19.3 - from pip._internal.req import parse_requirements # noqa - from pip._internal.network.session import PipSession # noqa - - return parse_requirements, PipSession + 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: + mod_session = __import__(session_path, fromlist=["PipSession"]) + mod_req = __import__(req_path, fromlist=["parse_requirements"]) + + except ImportError: # pragma: no cover + continue - except ImportError: - pass - - try: - # pip >= 10.0 - from pip._internal.req import parse_requirements # noqa - from pip._internal.download import PipSession # noqa - - return parse_requirements, PipSession - - except ImportError: - pass - - try: - # pip < 10.0 - from pip.req import parse_requirements - from pip.download import PipSession - - return parse_requirements, PipSession - - except ImportError: - from setupmeta import warn - - warn("Can't find PipSession, won't auto-fill requirements") - return None, None + else: + return mod_req.parse_requirements, mod_session.PipSession def pythonified_name(name): @@ -328,15 +345,6 @@ def pythonified_name(name): class PackageInfo: """Retrieves info from PKG-INFO""" - _canonical_names = { - "classifier": "classifiers", - "description": "long_description", - "description_content_type": "long_description_content_type", - "home_page": "url", - "summary": "description", - } - _list_types = {"classifiers", "long_description"} - def __init__(self, root): self.path = os.path.join(root, "PKG-INFO") self.info = {} @@ -354,7 +362,7 @@ def __init__(self, root): m = RE_PKG_KEY_VALUE.match(line) if m: key = m.group(1).lower().replace("-", "_") - key = self._canonical_names.get(key, key) + key = PKG_CANONICAL_KEYS.get(key, key) value = m.group(2) if key == "requires_dist": # This code tries to support PEP-517, until setuptools retired @@ -367,7 +375,7 @@ def __init__(self, root): if key not in MetaDefs.all_fields: continue - if key in self._list_types: + if key in PKG_LIST_TYPES: if key not in self.info: self.info[key] = [] @@ -376,7 +384,7 @@ def __init__(self, root): else: self.info[key] = value - elif key in self._list_types: + elif key in PKG_LIST_TYPES: # Indented description applying to previous key self.info[key].append(line[8:].rstrip()) @@ -434,7 +442,7 @@ def checked_file(folder, basename): class SetupMeta(Settings): - """ Find usable definitions throughout a project SetupPy SetupMeta """ + """Find usable definitions throughout a project SetupPy SetupMeta""" pkg_info = None # type: PackageInfo requirements = None # type: Requirements @@ -446,29 +454,32 @@ def __init__(self): def preprocess(self, upstream): self.find_project_dir(MetaDefs.dist_to_dict(upstream).pop("_setup_py_path", None)) - for require_field in ("install_requires",): value = getattr(upstream, require_field) if isinstance(value, str) and value.startswith("@"): self.add_definition(require_field, value, EXPLICIT) self.add_definition(require_field, requirements_from_file(value[1:]) or [], source=value[1:], override=True) - if isinstance(upstream.extras_require, dict): - if any([isinstance(deps, str) and deps.startswith("@") for deps in upstream.extras_require.values()]): - self.add_definition("extras_require", upstream.extras_require, EXPLICIT) - self.add_definition("extras_require", { - extra: (requirements_from_file(deps[1:]) or []) if isinstance(deps, str) and deps.startswith("@") else deps - for extra, deps in upstream.extras_require.items() - }, "preprocessed", override=True) + if isinstance(upstream.extras_require, dict) and any( + isinstance(deps, str) and deps.startswith("@") for deps in upstream.extras_require.values() + ): + self.add_definition("extras_require", upstream.extras_require, EXPLICIT) + self.add_definition( + "extras_require", + { + extra: (requirements_from_file(deps[1:]) or []) if isinstance(deps, str) and deps.startswith("@") else deps + for extra, deps in upstream.extras_require.items() + }, + "preprocessed", + override=True, + ) return self def finalize(self, upstream): self.attrs.update(MetaDefs.dist_to_dict(upstream)) - self.find_project_dir(self.attrs.pop("_setup_py_path", None)) scm = self.attrs.pop("scm", None) - # Add definitions from setup()'s attrs (highest priority) for key, value in self.attrs.items(): if key not in self.definitions: @@ -492,7 +503,6 @@ def finalize(self, upstream): packages = self.attrs.get("packages", []) py_modules = self.attrs.get("py_modules", []) - if not packages and not py_modules and self.name: # Try to auto-determine a good default from 'self.name' name = self.pythonified_name @@ -622,10 +632,9 @@ def find_project_dir(setup_py_path): trace("setup.py found from call stack: %s" % setup_py_path) break - if not setup_py_path and sys.argv: - if is_setup_py_path(sys.argv[0]): - setup_py_path = sys.argv[0] - trace("setup.py found from sys.argv: %s" % setup_py_path) + if not setup_py_path and sys.argv and is_setup_py_path(sys.argv[0]): + setup_py_path = sys.argv[0] + trace("setup.py found from sys.argv: %s" % setup_py_path) if is_setup_py_path(setup_py_path): setup_py_path = os.path.abspath(setup_py_path) @@ -641,10 +650,11 @@ def extract_short_description(self, contents): size = len(description) if 4 <= size <= 256: m = RE_DESCRIPTION.match(description) - candidates = set([s.lower() for s in (self.name, self.pythonified_name) if s]) + candidates = {s.lower() for s in (self.name, self.pythonified_name) if s} if m: lead = m.group(3) description = m.group(4 if lead and lead.lower() in candidates else 1) + if len(description) >= 4 and description.lower() not in candidates: return description @@ -653,6 +663,7 @@ def auto_fill_long_description(self): docstring_lead = self.definitions.pop("docstring_lead", None) if docstring_lead and not self.value("description"): self.auto_fill("description", docstring_lead.value, source=docstring_lead.source) + best_content_type = None best_readme = None best_long = None @@ -660,21 +671,25 @@ def auto_fill_long_description(self): value = load_readme(readme) if not value: continue + short_desc = self.extract_short_description(value) if not best_long or len(best_long) < 512 <= len(value): # The best README is the 1st one found best_content_type = content_type_from_filename(readme) best_readme = readme best_long = value + if short_desc: self.auto_fill("description", short_desc, source="%s:1" % readme) break + self.add_definition("long_description", best_long, best_readme) self.add_definition("long_description_content_type", best_content_type, best_readme) def auto_fill_entry_points(self, key="entry_points"): if self.pkg_info.entry_points_txt: self.add_definition(key, load_contents(self.pkg_info.entry_points_txt), relative_path(self.pkg_info.entry_points_txt)) + path = "%s.ini" % key self.add_definition(key, load_contents(path), path) @@ -716,20 +731,21 @@ def auto_fill(self, field, value, source="auto-fill", override=False): self.add_definition(field, value, source, override=override) def auto_adjust(self, field, adjust): - """ Auto-adjust 'field' using 'adjust' function """ + """Auto-adjust 'field' using 'adjust' function""" for key, value in adjust(field): self.add_definition(key, value, "auto-adjust", override=True) def extract_email(self, field): - """ Convenience: one line user+email specification """ + """Convenience: one line user+email specification""" field_email = field + "_email" user_email = self.value(field_email) if user_email: - # Caller already separated email, nothing to do - return + return # Caller already separated email, nothing to do + user = self.value(field) if not user: return + m = RE_EMAIL.match(user) if m: yield field, m.group(1) diff --git a/setupmeta/scm.py b/setupmeta/scm.py index 8a66ea5..c581611 100644 --- a/setupmeta/scm.py +++ b/setupmeta/scm.py @@ -3,7 +3,6 @@ import setupmeta - RE_BRANCH_STATUS = re.compile(r"^## (.+)\.\.\.(([^/]+)/)?([^ ]+)\s*(\[(.+)])?$") RE_GIT_DESCRIBE = re.compile(r"^v?([0-9]+\.[0-9]+.+?)(-\d+)?(-g\w+)?(-dirty)?$", re.IGNORECASE) # Output expected from git describe @@ -94,6 +93,9 @@ def run(self, commit, *args, **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) @@ -271,15 +273,15 @@ class Version: Version broken down for setupmeta usage purposes """ - text = None # type: str # Full text of version as received + text = None # type: str # Full text of version as received - major = 0 # type: int # Major part of version - minor = 0 # type: int # Minor part of version - patch = 0 # type: int # Patch part of version - distance = 0 # type: int # Number of commits since last version tag - commitid = None # type: str # Commit id - dirty = "" # type: str # Dirty marker - additional = "" # type: str # Additional version markers (if any) + major = 0 # type: int # Major part of version + minor = 0 # type: int # Minor part of version + patch = 0 # type: int # Patch part of version + distance = 0 # type: int # Number of commits since last version tag + commitid = None # type: str # Commit id + dirty = "" # type: str # Dirty marker + additional = "" # type: str # Additional version markers (if any) def __init__(self, main=None, distance=0, commitid=None, dirty=False, text=None): """ diff --git a/setupmeta/versioning.py b/setupmeta/versioning.py index 3c30376..23edb25 100644 --- a/setupmeta/versioning.py +++ b/setupmeta/versioning.py @@ -5,11 +5,10 @@ import setupmeta from setupmeta.scm import Git, Snapshot, Version - BUMPABLE = {"major", "minor", "patch"} DEFAULT_BRANCHES = "main,master" MAIN_BITS = {"{major}", "{minor}", "{patch}", "{distance}", "{post}", "{dev}"} -RE_VERSIONING = re.compile(r"^(branch(\([\w\s,\-]+\))?:)?(.*?)([ +@#%^/]!?(.*))?(;(.*))?$") +RE_VERSIONING = re.compile(r"^(branch(\([\w\s,\-]+\))?:)?(.*?)([ +@#%^/;]!?(.*))?$") RE_BITS = re.compile(r"{[^}]*}") PRECONFIGURED = { "post": "{major}.{minor}.{patch}{post}+{dirty}", @@ -19,8 +18,7 @@ "build-id": "{major}.{minor}.{distance}+h{$*BUILD_ID:local}.{commitid}{dirty}", } PRECONFIGURED_ALIAS = { - "": "post", - "changes": "distance", + "": "dev", "default": "post", "tag": "post", } @@ -85,12 +83,7 @@ def __repr__(self): if self.alternative: text = "%s:%s" % (text, self.alternative) - if self.constant: - text = "'%s'" % text - - else: - text = "{%s}" % text - + text = ("'%s'" if self.constant else "{%s}") % text if self.problem: text = " [%s]" % self.problem @@ -119,21 +112,21 @@ def rendered_attr(self, version): """ return getattr(version, self.text, None) - def rendered_constant(self, version): + def rendered_constant(self, version): # noqa: ARG002 """ :param Version version: Version to render :return str: Rendered version bit """ return self.text - def rendered_env_var(self, version): + def rendered_env_var(self, version): # noqa: ARG002 """ :param Version version: Version to render :return str: Rendered version bit """ i = self.text.index("$") prefix = self.text[:i] - env_var = self.text[i + 1:] + env_var = self.text[i + 1 :] if env_var.startswith("*") and env_var.endswith("*"): env_var = env_var[1:-1] candidates = [n for n in os.environ if env_var in n] @@ -178,7 +171,7 @@ def rendered(self, version): class Strategy: - def __init__(self, main, extra, branches, hook, **kwargs): + def __init__(self, main, extra, branches, **kwargs): self.main = main self.extra = extra self.version_tag = kwargs.pop("version_tag", None) @@ -194,7 +187,6 @@ def __init__(self, main, extra, branches, hook, **kwargs): self.extra_bits = self.bits(extra) self.branches = branches - self.hook = hook if self.branches and hasattr(self.branches, "lstrip"): self.branches = self.branches.lstrip("(").rstrip(")") @@ -272,7 +264,7 @@ def rendered(self, version, extra=True, auto_bumped=True): bits = self.main_bits if isinstance(bits, list) and len(bits) > 1 and not version.additional and (version.distance > 0 or version.dirty): # Support for '.dev' versioning scheme: apply it only for: - # - regular versioning (no special hook, no additional version bits given) + # - regular versioning (no additional version bits given) # - only if it's "simple enough", ie: last bit is "dev", and the bit before that is bumpable bits = list(bits) prelast, last = bits[-2:] @@ -337,8 +329,8 @@ def from_meta(cls, given): if not given: return None - main, extra, branches, hook, rest_from_upstream = _parsed_versioning(given) - return cls(main, extra, branches, hook, **rest_from_upstream) + main, extra, branches, rest_from_upstream = _parsed_versioning(given) + return cls(main, extra, branches, **rest_from_upstream) def _parsed_versioning(given): @@ -346,7 +338,6 @@ def _parsed_versioning(given): main = "post" extra = "{dirty}" branches = DEFAULT_BRANCHES - hook = None rest_from_upstream = {} if isinstance(given, dict): # User wants advanced mode: passed a dict as versioning= in setup.py @@ -354,7 +345,6 @@ def _parsed_versioning(given): main = given.pop("main", main) extra = given.pop("extra", extra) branches = given.pop("branches", branches) - hook = given.pop("hook", hook) rest_from_upstream = given given = main @@ -370,9 +360,6 @@ def _parsed_versioning(given): if isinstance(main, str) and isinstance(extra, str): extra = _parsed_extra(m.group(4), extra) - if m.group(7): - hook = m.group(7) - to_be_moved = [] for bit in RE_BITS.findall(main): if bit not in MAIN_BITS: @@ -386,7 +373,7 @@ def _parsed_versioning(given): main = main.strip(".") extra = extra.strip(".") - return main, extra, branches, hook, rest_from_upstream + return main, extra, branches, rest_from_upstream def _parsed_extra(given, default): @@ -546,13 +533,6 @@ def bump(self, what, commit=False, push=False, simulate_branch=None): self.scm.apply_tag(commit, push, next_version, branch) - if not self.strategy.hook: - return - - hook = setupmeta.project_path(self.strategy.hook) - if setupmeta.is_executable(hook): - setupmeta.run_program(hook, self.meta.name, branch, next_version, fatal=True, dryrun=not commit, cwd=setupmeta.project_path()) - def update_sources(self, next_version, commit, push, vdefs): modified = [] for vdef in vdefs.sources: @@ -615,6 +595,6 @@ def updated_line(line, next_version): comment = "" if "#" in value: i = value.index("#") - comment = " #%s" % value[i + 1:] + comment = " #%s" % value[i + 1 :] return "%s%s%s%s%s%s%s\n" % (key, sep, space, quote, next_version, quote, comment) diff --git a/tests/__init__.py b/tests/__init__.py index 70d720f..0b671df 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -""" Unit test package for setupmeta """ +"""Unit test package for setupmeta""" diff --git a/tests/conftest.py b/tests/conftest.py index e770021..9582d0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,17 +8,15 @@ import pytest import setupmeta -from setupmeta import decode from setupmeta.model import SetupMeta from setupmeta.scm import Git - TESTS = os.path.abspath(os.path.dirname(__file__)) PROJECT_DIR = os.path.dirname(TESTS) setupmeta.MetaDefs.project_dir = PROJECT_DIR -setupmeta.TESTING = True os.environ["PYTHONDONTWRITEBYTECODE"] = "1" +os.environ["SETUPMETA_RUNNING_SCENARIOS"] = "1" sys.dont_write_bytecode = True @@ -28,7 +26,11 @@ def resource(*relative_path): def relative_path(full_path): - return full_path[len(PROJECT_DIR) + 1:] + return full_path[len(PROJECT_DIR) + 1 :] + + +def ignore_warning(*_, **__): + pass def print_warning(message, *_, **__): @@ -41,17 +43,7 @@ def run_program(program, *args, **kwargs): fatal = kwargs.pop("fatal", True) represented = "%s %s" % (program, setupmeta.represented_args(args)) print("Running: %s" % represented) - if not setupmeta.WINDOWS and "PYCHARM_HOSTED" in os.environ and "python" in program and args and args[0].startswith("-m"): - # Temporary workaround for https://youtrack.jetbrains.com/issue/PY-40692 - wrapper = os.path.join(os.path.dirname(__file__), "pydev-wrapper.sh") - args = [wrapper, program] + list(args) - program = "/bin/sh" - output = setupmeta.run_program(program, *args, capture=capture, fatal=fatal, **kwargs) - if output and capture: - print("output:") - print(output) - return output @@ -66,22 +58,17 @@ def run_git(*args, **kwargs): @pytest.fixture def sample_project(): """Yield a sample git project, seeded with files from tests/sample""" - old_cd = os.getcwd() - try: - with setupmeta.temp_resource() as temp: - source = resource("sample") - dest = os.path.join(temp, "sample") - shutil.copytree(source, dest) - files = os.listdir(dest) - run_git("init", cwd=dest) - run_git("add", *files, cwd=dest) - run_git("commit", "-m", "Initial commit", cwd=dest) - os.chdir(dest) + with setupmeta.temp_resource() as temp: + source = resource("sample") + dest = os.path.join(temp, "sample") + shutil.copytree(source, dest) + files = os.listdir(dest) + run_git("init", cwd=dest) + run_git("add", *files, cwd=dest) + run_git("commit", "-m", "Initial commit", cwd=dest) + with setupmeta.current_folder(dest): yield dest - finally: - os.chdir(old_cd) - class TestMeta: def __init__(self, setup=None, **upstream): @@ -97,6 +84,25 @@ def __exit__(self, *args): setupmeta.MetaDefs.project_dir = self.old_pd +class capture_warnings: + """ + Context manager allowing to temporarily silence setuptools warnings, capture only setupmeta's warnings. + """ + + def __init__(self): + self.old_setupmeta_warnings = setupmeta.warn + self.old_warnings = warnings.warn + + def __enter__(self): + setupmeta.warn = print_warning + warnings.warn = ignore_warning + return self + + def __exit__(self, *args): + setupmeta.warn = self.old_setupmeta_warnings + warnings.warn = self.old_warnings + + class capture_output: """ Context manager allowing to temporarily grab stdout/stderr output. @@ -109,27 +115,24 @@ class capture_output: assert "some message" in logged """ - def __init__(self, stdout=True, stderr=True, ownwarn=False): + def __init__(self, stdout=True, stderr=True): """ :param bool stdout: If True, capture stdout :param bool stderr: If True, capture stderr - :param bool ownwarn: If True, capture only setupmeta's warnings (drop the rest) """ + self.capture_warn = capture_warnings() self.old_out = sys.stdout if stdout else None self.old_err = sys.stderr if stderr else None - self.ownwarn = ownwarn - self.old_warnings = warnings.warn - self.old_setupmeta_warnings = setupmeta.warn self.out_buffer = None self.err_buffer = None def __repr__(self): result = "" if self.out_buffer: - result += decode(self.out_buffer.getvalue()) + result += setupmeta.decode(self.out_buffer.getvalue()) if self.err_buffer: - result += decode(self.err_buffer.getvalue()) + result += setupmeta.decode(self.err_buffer.getvalue()) return result.rstrip() @@ -140,14 +143,7 @@ def __enter__(self): if self.old_err is not None: sys.stderr = self.err_buffer = StringIO() - if self.ownwarn: - # Only let setupmeta's own warning through - setupmeta.warn = print_warning - warnings.warn = lambda *_, **__: None - - else: - warnings.warn = print_warning - + self.capture_warn.__enter__() return self def __exit__(self, *args): @@ -159,47 +155,25 @@ def __exit__(self, *args): self.out_buffer = None self.err_buffer = None - warnings.warn = self.old_warnings - setupmeta.warn = self.old_setupmeta_warnings + self.capture_warn.__exit__(*args) def __contains__(self, item): return item in str(self) - def __len__(self): - return len(str(self)) - - def __add__(self, other): - return "%s %s" % (self, other) - - -def should_ignore_output(line): - if not line: - return True - - if line.startswith("pydev debugger:"): - return True - if "Module setupmeta was already imported" in line: - # Edge case when pinning setupmeta itself to a certain version - return True +def simplified_output_path(line, representation, path): + if line and path: + rp = os.path.realpath(path) + if rp not in line: + rp = path - -def simplified_temp_path(line, *paths): - if line: - for path in paths: - if path: - p = os.path.realpath(path) - if p in line: - return line.replace(p, "") - - if path in line: - return line.replace(path, "") + return line.replace(rp, representation) return line def cleaned_output(text, folder=None): - text = decode(text) + text = setupmeta.decode(text) if not text: return text @@ -207,48 +181,47 @@ def cleaned_output(text, folder=None): cwd = os.getcwd() for line in text.splitlines(): line = line.rstrip() - if should_ignore_output(line): + 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 run_setup_py(folder, *args): - if folder == setupmeta.project_path() or not os.path.isabs(folder): - output = run_program(sys.executable, os.path.join(folder, "setup.py"), "-q", *args, capture="all") - return cleaned_output(output) +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 - return run_internal_setup_py(folder, *args) +def invoke_setup_py(folder, *args): + """Run `setup.py` from `folder` in-process if possible, to record coverage properly""" + if folder == setupmeta.project_path(): + return spawn_setup_py(folder, *args) -def run_internal_setup_py(folder, *args): - """Run setup.py without an external process, to record coverage properly""" - old_cd = os.getcwd() old_argv = sys.argv old_pd = setupmeta.MetaDefs.project_dir setupmeta.DEBUG = False - fp = None try: - os.chdir(folder) setup_py = os.path.join(folder, "setup.py") - with capture_output(ownwarn=True) as logged: - sys.argv = [setup_py, "-q"] + list(args) + 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) - except SystemExit as e: + except (SystemExit, setupmeta.UsageError) as e: run_output += "'setup.py %s' exited with code 1:\n" % " ".join(args) run_output += "%s\n" % e @@ -256,12 +229,8 @@ def run_internal_setup_py(folder, *args): return cleaned_output(run_output, folder=folder) finally: - if fp: - fp.close() - setupmeta.MetaDefs.project_dir = old_pd sys.argv = old_argv - os.chdir(old_cd) class MockGit(Git): diff --git a/tests/pydev-wrapper.sh b/tests/pydev-wrapper.sh deleted file mode 100644 index 2b83bb0..0000000 --- a/tests/pydev-wrapper.sh +++ /dev/null @@ -1 +0,0 @@ -exec "$@" diff --git a/tests/sample/setup.py b/tests/sample/setup.py index 5d5db14..4c6bdbd 100644 --- a/tests/sample/setup.py +++ b/tests/sample/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="sample", setup_requires=["setupmeta"], diff --git a/tests/scenarios.py b/tests/scenarios.py index a8d0188..b86a157 100644 --- a/tests/scenarios.py +++ b/tests/scenarios.py @@ -1,4 +1,5 @@ import argparse +import contextlib import io import logging import os @@ -10,6 +11,7 @@ if __name__ == "__main__": import conftest + else: from . import conftest @@ -26,13 +28,13 @@ def valid_scenarios(folder): full_path = os.path.join(folder, name) setup_py = os.path.join(full_path, "setup.py") if os.path.isdir(full_path) and os.path.isfile(setup_py): - if not setupmeta.WINDOWS or not os.path.exists(os.path.join(full_path, ".hooks")): - result.append(conftest.relative_path(full_path)) + result.append(conftest.relative_path(full_path)) + return result def scenario_paths(): - """ Available scenario names """ + """Available scenario names""" return valid_scenarios(SCENARIOS) + valid_scenarios(EXAMPLES) @@ -43,21 +45,22 @@ def copytree(src, dst): if os.path.isdir(s): if os.path.isdir(d): copytree(s, d) + else: shutil.copytree(s, d) + else: shutil.copy2(s, d) class Scenario: - - folder = None # type: str # Folder where scenario is defined + 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) + 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) - temp = None # type: str # Optional temp folder used - origin = None # type: str # Temp SCM origin to use + temp = None # type: str # Optional temp folder used + origin = None # type: str # Temp SCM origin to use def __init__(self, relative_path, in_place=False): self.short_name = relative_path @@ -72,9 +75,12 @@ def __init__(self, relative_path, in_place=False): fdest = os.path.join(os.getcwd(), fname) if os.path.isdir(fsrc): shutil.copytree(fsrc, fdest) + else: shutil.copy(fsrc, fdest) + shutil.copystat(fsrc, fdest) + self.folder = os.getcwd() self.target = self.folder @@ -86,10 +92,11 @@ def __init__(self, relative_path, in_place=False): self.target = None with io.open(extra_commands, "rt") as fh: for line in fh: - line = str(conftest.decode(line).strip()) # coerce to str() to not confuse py2 with unicode + line = setupmeta.decode(line).strip() if line: if line.startswith(":"): self.preparation.append(line[1:]) + else: self.commands.append(line) @@ -97,6 +104,7 @@ 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 @@ -117,16 +125,7 @@ def prepare(self): copytree(self.folder, self.target) for command in self.preparation: - if command.startswith("mv"): - # Unfortunately there is no 'mv' on Windows - _, source, dest = command.split() - source = os.path.join(self.target, source) - dest = os.path.join(self.target, dest) - shutil.copytree(source, dest) - shutil.rmtree(source) - - else: - setupmeta.run_program(*command.split(), cwd=self.target) + setupmeta.run_program(*command.split(), cwd=self.target) self.run_git("add", ".") self.run_git("commit", "-m", "Initial commit") @@ -139,17 +138,15 @@ def clean(self): del os.environ[setupmeta.SCM_DESCRIBE] return - try: + with contextlib.suppress(OSError): shutil.rmtree(self.temp) - except OSError: - pass def replay(self): try: self.prepare() result = [] for command in self.commands: - output = ":: %s\n%s" % (command, conftest.run_setup_py(self.target, *command.split())) + output = ":: %s\n%s" % (command, conftest.spawn_setup_py(self.target, *command.split())) result.append(output) return "\n\n".join(result).rstrip() @@ -165,11 +162,12 @@ def expected_contents(self): return content and content.strip() def refresh_example(self, dryrun): - logging.info("Refreshing %s" % self) + logging.info("Refreshing %s", self) output = self.replay() if dryrun: print(output) return + with io.open(self.expected_path(), "wt") as fh: fh.write("%s\n" % output) @@ -181,6 +179,7 @@ def main(): parser = argparse.ArgumentParser(description=main.__doc__.strip()) parser.add_argument("--debug", action="store_true", help="Show debug info") parser.add_argument("--dryrun", "-n", action="store_true", help="Print output rather, don't update expected.txt") + parser.add_argument("command", choices=("replay", "regen"), help="replay or regen") parser.add_argument("scenario", nargs="*", help="Scenarios to regenerate (default: all)") args = parser.parse_args() @@ -191,11 +190,29 @@ def main(): if not args.scenario: args.scenario = scenario_paths() - os.chdir(conftest.PROJECT_DIR) - - for folder in args.scenario: - scenario = Scenario(folder, in_place=True) - scenario.refresh_example(args.dryrun) + os.environ["SETUPMETA_RUNNING_SCENARIOS"] = "1" + with setupmeta.current_folder(conftest.PROJECT_DIR): + for folder in args.scenario: + if args.command == "regen": + scenario = Scenario(folder, in_place=True) + scenario.refresh_example(args.dryrun) + + 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() + print("----") + if expected == output: + print("OK, no diffs found") + + else: + print("Diffs found:") + diff = difflib.ndiff(expected.splitlines(), output.splitlines()) + print("\n".join(diff)) if __name__ == "__main__": diff --git a/tests/scenarios/So complex/.hooks/bump b/tests/scenarios/So complex/.hooks/bump deleted file mode 100755 index 87d96e1..0000000 --- a/tests/scenarios/So complex/.hooks/bump +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -echo .hooks/bump ran with $* diff --git a/tests/scenarios/So complex/expected.txt b/tests/scenarios/So complex/expected.txt index f0eb337..0b8c45c 100644 --- a/tests/scenarios/So complex/expected.txt +++ b/tests/scenarios/So complex/expected.txt @@ -1,5 +1,4 @@ :: explain -c180 -r -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' author: (auto-adjust ) Someone \_: (setup.py:2 ) Someone someone@example.com author_email: (auto-adjust ) someone@example.com @@ -8,7 +7,7 @@ WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' 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 - download_url: (auto-fill ) https://example.com/complex/archive/1.2.3.tar.gz + 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 @@ -16,7 +15,7 @@ WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' 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:13 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] + \_: (setup.py:12 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] \_: (src/My_cplx_nm_here/__version__.py:6) ["complex", "version"] \_: (src/My_cplx_nm_here/__init__.py:7 ) ["src", "complex", "init"] license: (explicit ) foo @@ -26,21 +25,20 @@ long_description: (README ) My cplx-nm_here - Sampl \_: (src/My_cplx_nm_here/__version__.py:4) Someone me@example.com \_: (src/My_cplx_nm_here/__init__.py:11 ) Someone me2@example.com maintainer_email: (auto-adjust ) me@example.com - name: (setup.py:14 ) My cplx-nm_here + name: (setup.py:13 ) My cplx-nm_here package_dir: (auto-fill ) {: src} packages: (auto-fill ) ["My_cplx_nm_here", "My_cplx_nm_here.submodule"] setup_requires: (explicit ) ["setupmeta"] - title*: (setup.py:14 ) My cplx-nm_here + title*: (setup.py:13 ) My cplx-nm_here url: (setup.py:3 ) https://example.com/complex - version: (git ) 1.2.3 + version: (git ) 1.2.3+hlocal \_: (setup.py:6 ) 1.0a1 - \_: (setup.py:12 ) 1.0b1 + \_: (setup.py:11 ) 1.0b1 \_: (src/My_cplx_nm_here/__version__.py:3) 1.2.3 \_: (src/My_cplx_nm_here/__init__.py:8 ) 1.3.0 - versioning: (explicit ) dev;.hooks/bump + versioning: (explicit ) branch(release,main):{major}.{minor}.{patch}+h{$*BUILD_ID:local}.{dirty} :: explain -d -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' # This reflects only auto-fill, doesn't look at explicit settings from your setup.py install_requires=[ "a", # from pinned.txt:2 @@ -55,19 +53,18 @@ WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' ], :: explain --expand -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' """ Generated by https://pypi.org/project/setupmeta/ """ from setuptools import setup -__version__ = "1.2.3" +__version__ = "1.2.3+hlocal" setup( - author="Someone", # from setup.py:2 + author="Someone", # from setup.py:2 author_email="someone@example.com", classifiers=["Programming Language :: Python"], - 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 + 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 download_url="https://example.com/complex/archive/%s.tar.gz" % __version__, # from setup.py:4 entry_points={"console_scripts": "a=b"}, extras_require={ @@ -93,47 +90,41 @@ setup( "command" ], license="foo", - long_description=open("README").read(), # from README - maintainer="Someone", # from src/My_cplx_nm_here/__version__.py:4 + long_description=open("README").read(), # from README + maintainer="Someone", # from src/My_cplx_nm_here/__version__.py:4 maintainer_email="me@example.com", - name="My cplx-nm_here", # from setup.py:14 + name="My cplx-nm_here", # from setup.py:13 package_dir={"": "src"}, packages=["My_cplx_nm_here", "My_cplx_nm_here.submodule"], - url="https://example.com/complex", # from setup.py:3 - version=__version__, # from git - # versioning="dev;.hooks/bump", + url="https://example.com/complex", # from setup.py:3 + version=__version__, # from git + # versioning="branch(release,main):{major}.{minor}.{patch}+h{$*BUILD_ID:local}.{dirty}", ) :: check -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' [setupmeta] install_requires: 4 abstracted, 3 ignored, 5 untouched :: entrypoints -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' a=b :: version -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' -1.2.3 +1.2.3+hlocal :: version --bump patch -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' 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:12 with: __version__ = "1.2.4" +Would update setup.py:11 with: __version__ = "1.2.4" 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 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 tag -a v1.2.4 -m "Version 1.2.4" -Would run: /.hooks/bump "My cplx-nm_here" main 1.2.4 :: version --bump minor --push -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' Not committing bump, use --commit to commit Would update setup.py:6 with: version: 1.3.0 -Would update setup.py:12 with: __version__ = "1.3.0" +Would update setup.py:11 with: __version__ = "1.3.0" 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 @@ -141,29 +132,24 @@ Would run: git commit -m "Version 1.3.0" --no-verify Would run: git push origin Would run: git tag -a v1.3.0 -m "Version 1.3.0" Would run: git push --tags origin -Would run: /.hooks/bump "My cplx-nm_here" main 1.3.0 :: version --bump major -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' 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:12 with: __version__ = "2.0.0" +Would update setup.py:11 with: __version__ = "2.0.0" 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 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 tag -a v2.0.0 -m "Version 2.0.0" -Would run: /.hooks/bump "My cplx-nm_here" main 2.0.0 :: version --bump minor --commit -WARNING: In setup.py:6 version should be '1.2.3', not '1.0a1' 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 tag -a v1.3.0 -m "Version 1.3.0" -Running: /.hooks/bump "My cplx-nm_here" main 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 @@ -171,10 +157,9 @@ Running: git commit -m "Version 2.0.0" --no-verify Running: git push origin Running: git tag -a v2.0.0 -m "Version 2.0.0" Running: git push --tags origin -Running: /.hooks/bump "My cplx-nm_here" main 2.0.0 :: version -2.0.0 +2.0.0+hlocal :: explain -c180 author: (auto-adjust ) Someone @@ -185,7 +170,7 @@ Running: /.hooks/bump "My cplx-nm_here" main 2.0.0 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 - download_url: (auto-fill ) https://example.com/complex/archive/2.0.0.tar.gz + 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 @@ -193,7 +178,7 @@ Running: /.hooks/bump "My cplx-nm_here" main 2.0.0 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:13 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] + \_: (setup.py:12 ) ["some", "list", "of", "keywords", "here", "long", "enough", "to", "be", "abbreviated", "by", "the", "explain", "command"] \_: (src/My_cplx_nm_here/__version__.py:6) ["complex", "version"] \_: (src/My_cplx_nm_here/__init__.py:7 ) ["src", "complex", "init"] license: (explicit ) foo @@ -203,18 +188,18 @@ long_description: (README ) My cplx-nm_here - Sampl \_: (src/My_cplx_nm_here/__version__.py:4) Someone me@example.com \_: (src/My_cplx_nm_here/__init__.py:11 ) Someone me2@example.com maintainer_email: (auto-adjust ) me@example.com - name: (setup.py:14 ) My cplx-nm_here + name: (setup.py:13 ) My cplx-nm_here package_dir: (auto-fill ) {: src} packages: (auto-fill ) ["My_cplx_nm_here", "My_cplx_nm_here.submodule"] setup_requires: (explicit ) ["setupmeta"] - title*: (setup.py:14 ) My cplx-nm_here + title*: (setup.py:13 ) My cplx-nm_here url: (setup.py:3 ) https://example.com/complex - version: (git ) 2.0.0 + version: (git ) 2.0.0+hlocal \_: (setup.py:6 ) 2.0.0 - \_: (setup.py:12 ) 2.0.0 + \_: (setup.py:11 ) 2.0.0 \_: (src/My_cplx_nm_here/__version__.py:3) 2.0.0 \_: (src/My_cplx_nm_here/__init__.py:8 ) 2.0.0 - versioning: (explicit ) dev;.hooks/bump + versioning: (explicit ) branch(release,main):{major}.{minor}.{patch}+h{$*BUILD_ID:local}.{dirty} :: --name My cplx-nm_here diff --git a/tests/scenarios/So complex/tests/extra-reqs.txt b/tests/scenarios/So complex/extras/extra-reqs.txt similarity index 100% rename from tests/scenarios/So complex/tests/extra-reqs.txt rename to tests/scenarios/So complex/extras/extra-reqs.txt diff --git a/tests/scenarios/So complex/tests/some-more-reqs.txt b/tests/scenarios/So complex/extras/some-more-reqs.txt similarity index 100% rename from tests/scenarios/So complex/tests/some-more-reqs.txt rename to tests/scenarios/So complex/extras/some-more-reqs.txt diff --git a/tests/scenarios/So complex/pinned.txt b/tests/scenarios/So complex/pinned.txt index 392b81a..f882688 100644 --- a/tests/scenarios/So complex/pinned.txt +++ b/tests/scenarios/So complex/pinned.txt @@ -18,4 +18,4 @@ i==1 # pinned (explicitly pinned in an abstract section) j==1 k>=1 --r tests/extra-reqs.txt # No-op, extra-reqs.txt brings nothing new +-r extras/extra-reqs.txt # No-op, extra-reqs.txt brings nothing new diff --git a/tests/scenarios/So complex/setup.py b/tests/scenarios/So complex/setup.py index 02adc96..ddb36ab 100644 --- a/tests/scenarios/So complex/setup.py +++ b/tests/scenarios/So complex/setup.py @@ -8,26 +8,23 @@ from setuptools import setup - __version__ = "1.0b1" __keywords__ = "some,list,of,keywords,here,long,enough,to,be,abbreviated,by,the,explain,command" __title__ = "My cplx-nm_here" setup( - versioning="dev;.hooks/bump", + versioning="branch(release,main):{major}.{minor}.{patch}+h{$*BUILD_ID:local}.{dirty}", setup_requires=["setupmeta"], # This will overshadow classifiers.txt classifiers=["Programming Language :: Python"], - - extras_require=dict( - bar=["docutils"], - baz=["some", "long", "list-of", "requirements"], - foo=["long", "enough", "to-be", "abbreviated"], - ), - + extras_require={ + "bar": ["docutils"], + "baz": ["some", "long", "list-of", "requirements"], + "foo": ["long", "enough", "to-be", "abbreviated"], + }, # Edge case galore keywords=__keywords__.split(","), - entry_points=dict(console_scripts="a=b"), + entry_points={"console_scripts": "a=b"}, license="foo", ) diff --git a/tests/scenarios/So complex/src/My_cplx_nm_here/__init__.py b/tests/scenarios/So complex/src/My_cplx_nm_here/__init__.py index 3ab4522..cda1dfb 100644 --- a/tests/scenarios/So complex/src/My_cplx_nm_here/__init__.py +++ b/tests/scenarios/So complex/src/My_cplx_nm_here/__init__.py @@ -5,7 +5,7 @@ """ __keywords__ = "src, complex, init" -__version__ = '1.3.0' +__version__ = "1.3.0" # New style __maintainer__ = "Someone me2@example.com" diff --git a/tests/scenarios/So complex/tests/test_complex.py b/tests/scenarios/So complex/tests/test_complex.py deleted file mode 100644 index b7db254..0000000 --- a/tests/scenarios/So complex/tests/test_complex.py +++ /dev/null @@ -1 +0,0 @@ -# Empty diff --git a/tests/scenarios/bogus/expected.txt b/tests/scenarios/bogus/expected.txt index d119122..6f8e500 100644 --- a/tests/scenarios/bogus/expected.txt +++ b/tests/scenarios/bogus/expected.txt @@ -1,5 +1,4 @@ :: explain -c180 -r -WARNING: patch version component should be .0 for versioning strategy... author: (missing ) - Consider specifying 'author' description: (README.md:1) bogus versioning spec download_url: (missing ) - Consider specifying 'download_url' @@ -13,12 +12,10 @@ long_description_content_type: (README.md ) text/markdown versioning: (explicit ) {extra: [], main: function 'main_part'} :: explain -d -WARNING: patch version component should be .0 for versioning strategy... # This reflects only auto-fill, doesn't look at explicit settings from your setup.py install_requires=None, # no auto-fill :: explain --expand -WARNING: patch version component should be .0 for versioning strategy... """ Generated by https://pypi.org/project/setupmeta/ """ @@ -34,26 +31,21 @@ setup( ) :: check -WARNING: patch version component should be .0 for versioning strategy... + :: entrypoints -WARNING: patch version component should be .0 for versioning strategy... + :: version -WARNING: patch version component should be .0 for versioning strategy... 1.0 :: version -WARNING: patch version component should be .0 for versioning strategy... 1.0 :: version --bump patch -WARNING: patch version component should be .0 for versioning strategy... -'setup.py version --bump patch' exited with code 1: -error: Main format is not a list: function 'main_part' + :: version -WARNING: patch version component should be .0 for versioning strategy... 1.0 :: --name diff --git a/tests/scenarios/bogus/setup.py b/tests/scenarios/bogus/setup.py index 32189f2..5ce1d92 100644 --- a/tests/scenarios/bogus/setup.py +++ b/tests/scenarios/bogus/setup.py @@ -1,7 +1,7 @@ from setuptools import setup -def main_part(version): +def main_part(_): return "1.0" diff --git a/tests/scenarios/complex-reqs/setup.py b/tests/scenarios/complex-reqs/setup.py index 36f127b..5f3b408 100644 --- a/tests/scenarios/complex-reqs/setup.py +++ b/tests/scenarios/complex-reqs/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="complex-reqs", setup_requires=["setupmeta"], diff --git a/tests/scenarios/disabled/setup.py b/tests/scenarios/disabled/setup.py index 93a4933..26bfcce 100644 --- a/tests/scenarios/disabled/setup.py +++ b/tests/scenarios/disabled/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="disabled", ) diff --git a/tests/scenarios/packaged/setup.py b/tests/scenarios/packaged/setup.py index e051248..b90787d 100644 --- a/tests/scenarios/packaged/setup.py +++ b/tests/scenarios/packaged/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="pre-packaged", setup_requires=["setupmeta"], diff --git a/tests/scenarios/pinned/setup.py b/tests/scenarios/pinned/setup.py index bbdf1e5..f6f3310 100644 --- a/tests/scenarios/pinned/setup.py +++ b/tests/scenarios/pinned/setup.py @@ -2,8 +2,8 @@ """ This is a package that has setupmeta pinned to an explicit version range. """ -from setuptools import setup +from setuptools import setup setup( name="pinned", diff --git a/tests/scenarios/readmes/expected.txt b/tests/scenarios/readmes/expected.txt index 756b2c4..123c491 100644 --- a/tests/scenarios/readmes/expected.txt +++ b/tests/scenarios/readmes/expected.txt @@ -7,9 +7,11 @@ long_description: (README.md ) readmes: foo No useful short description on 1st line here This scenario tests edge cases around finding a suitable short description ... long_description_content_type: (README.md ) text/markdown name: (explicit ) readmes + py_modules: (explicit ) ["s2"] setup_requires: (explicit ) ["setupmeta"] url: (missing ) - Consider specifying 'url' - version: (missing ) - Consider specifying 'version', you can use setupmeta's versioning='...' + version: (snapshot ) 2.3.1.dev3 + versioning: (explicit ) dev :: explain -d # This reflects only auto-fill, doesn't look at explicit settings from your setup.py @@ -20,6 +22,7 @@ long_description_content_type: (README.md ) text/markdown Generated by https://pypi.org/project/setupmeta/ """ from setuptools import setup +__version__ = "2.3.1.dev3" setup( description="Several readme files", # from README:1 download_url="http://example.com/readmes", # from setup.py:2 @@ -27,6 +30,9 @@ setup( long_description=open("README.md").read(), # from README.md long_description_content_type="text/markdown", # from README.md name="readmes", + py_modules=["s2"], + version=__version__, # from snapshot + # versioning="dev", ) :: check @@ -36,4 +42,4 @@ setup( :: version -None +2.3.1.dev3 diff --git a/tests/scenarios/readmes/setup.py b/tests/scenarios/readmes/setup.py index cb1c9f4..4264c00 100644 --- a/tests/scenarios/readmes/setup.py +++ b/tests/scenarios/readmes/setup.py @@ -5,8 +5,9 @@ from setuptools import setup - setup( name="readmes", setup_requires="setupmeta", + versioning="dev", + py_modules=["s2"], ) diff --git a/tests/scenarios/simple-src/setup.py b/tests/scenarios/simple-src/setup.py index 7074739..b4b9d3b 100644 --- a/tests/scenarios/simple-src/setup.py +++ b/tests/scenarios/simple-src/setup.py @@ -1,6 +1,5 @@ from setuptools import setup - setup( name="my-app", setup_requires="setupmeta", diff --git a/tests/scenarios/via_req_files/expected.txt b/tests/scenarios/via_req_files/expected.txt index c7920a6..24062c4 100644 --- a/tests/scenarios/via_req_files/expected.txt +++ b/tests/scenarios/via_req_files/expected.txt @@ -10,6 +10,7 @@ long_description: (README.md ) # via_req_files spec This scenario tests using references to requirement files from the the `setup.py` spec. long_description_content_type: (README.md ) text/markdown name: (explicit ) via_req_files + py_modules: (explicit ) ["foo"] setup_requires: (explicit ) ["setupmeta"] url: (missing ) - Consider specifying 'url' version: (missing ) - Consider specifying 'version', you can use setupmeta's versioning='...' @@ -34,6 +35,7 @@ setup( long_description=open("README.md").read(), # from README.md long_description_content_type="text/markdown", # from README.md name="via_req_files", + py_modules=["foo"], ) :: check diff --git a/tests/scenarios/via_req_files/setup.py b/tests/scenarios/via_req_files/setup.py index 59bd0f8..4ccf029 100644 --- a/tests/scenarios/via_req_files/setup.py +++ b/tests/scenarios/via_req_files/setup.py @@ -1,10 +1,10 @@ from setuptools import setup - setup( name="via_req_files", setup_requires="setupmeta", install_requires="@requirements.txt", + py_modules=["foo"], extras_require={ "feature": "@requirements.txt", "extra": "@requirements-extra.txt", diff --git a/tests/test_commands.py b/tests/test_commands.py index 377f4c8..302d4a6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -9,7 +9,7 @@ def run_setup_py(args, expected, folder=None): expected = expected.splitlines() - output = conftest.run_setup_py(folder or os.getcwd(), *args) + output = conftest.invoke_setup_py(folder or os.getcwd(), *args) for line in expected: line = line.strip() if line: @@ -19,10 +19,10 @@ def run_setup_py(args, expected, folder=None): def test_check(sample_project): # First sample_project is a pristine git checkout, check should pass - output = conftest.run_setup_py(sample_project, "explain") + output = conftest.invoke_setup_py(sample_project, "explain") assert 'install_requires: (req1.txt ) ["click>7.0"]' in output - output = conftest.run_setup_py(sample_project, "check") + output = conftest.invoke_setup_py(sample_project, "check") assert not output # Now let's modify one of the files @@ -30,12 +30,12 @@ def test_check(sample_project): fh.write("print('hello')\n") # check should report that as a pending change - output = conftest.run_setup_py(sample_project, "check") + output = conftest.invoke_setup_py(sample_project, "check") assert "Pending changes:" in output def test_explain(): - """ Test setupmeta's own setup.py """ + """Test setupmeta's own setup.py""" run_setup_py( ["explain"], """ @@ -49,7 +49,7 @@ def test_explain(): ) -def test_version(sample_project): +def test_version(sample_project): # noqa: ARG001, fixture run_setup_py(["version", "--bump", "major", "--simulate-branch=HEAD"], "Can't bump branch 'HEAD'") run_setup_py( @@ -78,6 +78,7 @@ def test_version(sample_project): run_setup_py(["version", "--show-next", "major"], "[\\d.]+") run_setup_py(["version", "--show-next", "minor"], "[\\d.]+") + run_setup_py(["version", "--show-next", "foo"], "Can't bump 'foo'") run_setup_py(["version", "-a", "patch"], "out of scope of main format") run_setup_py(["version", "-a", "patch"], "[\\d.]+", folder=conftest.PROJECT_DIR) diff --git a/tests/test_content.py b/tests/test_content.py index c1881e9..8e9946a 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -27,7 +27,7 @@ def test_shortening(): assert setupmeta.short("found in %s" % path) == "found in ~/foo/bar" - assert setupmeta.short(dict(foo="bar"), c=8) == "1 keys" + assert setupmeta.short({"foo": "bar"}, c=8) == "1 keys" assert setupmeta.merged("a", None) == "a" assert setupmeta.merged(None, "a") == "a" @@ -70,7 +70,7 @@ 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("pip") + assert setupmeta.which("python3") def test_run_program(): @@ -112,9 +112,3 @@ def test_stringify(): assert setupmeta.listify("a b") == ["a", "b"] assert sorted(setupmeta.listify(set("ab"))) == ["a", "b"] assert setupmeta.listify(("a", "b")) == ["a", "b"] - - -def test_meta_command_init(): - with pytest.raises(Exception): - obj = setupmeta.MetaDefs() - setupmeta.meta_command_init(obj, {}) diff --git a/tests/test_license.py b/tests/test_license.py index 560a994..f354b91 100644 --- a/tests/test_license.py +++ b/tests/test_license.py @@ -1,5 +1,4 @@ -from setupmeta import license - +import setupmeta.license BSD_SAMPLE = """ ... @@ -10,13 +9,13 @@ def test_license(): - assert license.determined_license(None) is None - assert license.determined_license("") is None - assert license.determined_license("blah blah version 5") is None - assert license.determined_license("... Version 2.0 http://www.apache.org/licenses/ ...") == "Apache 2.0" - assert license.determined_license(BSD_SAMPLE) == "BSD" - assert license.determined_license("MIT License ...") == "MIT" - assert license.determined_license("Mozilla Public License Version 2.0 ...") == "MPL" - assert license.determined_license("GNU AFFERO GENERAL PUBLIC LICENSE Version 3 ...") == "AGPLv3" - assert license.determined_license("GNU GENERAL PUBLIC LICENSE Version 3 ...") == "GPLv3" - assert license.determined_license("GNU LESSER GENERAL PUBLIC LICENSE Version 3 ...") == "LGPLv3" + assert setupmeta.license.determined_license(None) is None + assert setupmeta.license.determined_license("") is None + assert setupmeta.license.determined_license("blah blah version 5") is None + assert setupmeta.license.determined_license("... Version 2.0 http://www.apache.org/licenses/ ...") == "Apache 2.0" + assert setupmeta.license.determined_license(BSD_SAMPLE) == "BSD" + assert setupmeta.license.determined_license("MIT License ...") == "MIT" + assert setupmeta.license.determined_license("Mozilla Public License Version 2.0 ...") == "MPL" + assert setupmeta.license.determined_license("GNU AFFERO GENERAL PUBLIC LICENSE Version 3 ...") == "AGPLv3" + assert setupmeta.license.determined_license("GNU GENERAL PUBLIC LICENSE Version 3 ...") == "GPLv3" + assert setupmeta.license.determined_license("GNU LESSER GENERAL PUBLIC LICENSE Version 3 ...") == "LGPLv3" diff --git a/tests/test_model.py b/tests/test_model.py index 191ff8a..9cda027 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,7 +3,7 @@ from unittest.mock import patch import setupmeta -from setupmeta.model import Definition, DefinitionEntry, is_setup_py_path +from setupmeta.model import Definition, DefinitionEntry, get_pip, is_setup_py_path from . import conftest @@ -22,6 +22,18 @@ def test_first_word(): assert setupmeta.first_word("123") == "123" +def test_get_pip(): + with conftest.capture_output(): + result = get_pip() + if result is None: + assert "pip" not in sys.modules + + else: + parse_requirements, PipSession = result + assert parse_requirements + assert PipSession + + def test_setup_py_determination(): with conftest.capture_output(): initial = sys.argv[0] @@ -107,35 +119,32 @@ def test_requirements(): def test_empty(): - with conftest.capture_output(): - with conftest.TestMeta(setup="/dev/null/shouldnotexist/setup.py") as meta: - assert not meta.attrs - assert not meta.definitions - assert not meta.name - assert isinstance(meta.requirements, setupmeta.Requirements) - assert not meta.requirements.install_requires - assert not meta.version - assert not meta.versioning.enabled - assert meta.versioning.problem == "setupmeta versioning not enabled" - assert not meta.versioning.scm - assert not meta.versioning.strategy - assert str(meta).startswith("0 definitions, ") + with conftest.capture_output(), conftest.TestMeta(setup="/dev/null/shouldnotexist/setup.py") as meta: + assert not meta.attrs + assert not meta.definitions + assert not meta.name + assert isinstance(meta.requirements, setupmeta.Requirements) + assert not meta.requirements.install_requires + assert not meta.version + assert not meta.versioning.enabled + assert meta.versioning.problem == "setupmeta versioning not enabled" + assert not meta.versioning.scm + assert not meta.versioning.strategy + assert str(meta).startswith("0 definitions, ") @patch.dict(os.environ, {"PYGRADLE_PROJECT_VERSION": "1.2.3"}) def test_pygradle_version(): - with conftest.capture_output() as logged: - with 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 - assert "WARNING: No 'packages' or 'py_modules' defined" in logged + 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(): diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 42aa4c7..878bfa2 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -2,11 +2,14 @@ Verify that ../examples/*/setup.py behave as expected """ +import subprocess +import sys + import pytest import setupmeta -from . import scenarios +from . import conftest, scenarios @pytest.fixture(params=scenarios.scenario_paths()) @@ -18,7 +21,17 @@ def scenario_folder(request): def test_scenario(scenario_folder): """Check that 'scenario' yields expected explain output""" - scenario = scenarios.Scenario(scenario_folder) - expected = scenario.expected_contents() - output = scenario.replay() - assert output == expected + with conftest.capture_output(): + scenario = scenarios.Scenario(scenario_folder) + assert str(scenario) == scenario_folder + expected = scenario.expected_contents() + output = scenario.replay() + assert output == expected + + +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 + assert result.returncode == 0 + output = setupmeta.decode(result.stdout) + assert "OK, no diffs found" in output diff --git a/tests/test_versioning.py b/tests/test_versioning.py index bea2ea2..48ff9db 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1,5 +1,6 @@ import os import sys +from pathlib import Path from unittest.mock import patch import pytest @@ -12,13 +13,12 @@ from . import conftest - DEFAULT_BRANCH_SPEC = f"branch({setupmeta.versioning.DEFAULT_BRANCHES})" def new_meta(versioning, name="just-testing", scm=None, setup_py=None, **kwargs): setup_py = setup_py or conftest.resource("setup.py") - upstream = dict(versioning=versioning, scm=scm, _setup_py_path=setup_py) + upstream = {"versioning": versioning, "scm": scm, "_setup_py_path": setup_py} if name: # Allow to test "missing name" case upstream["name"] = name @@ -57,7 +57,7 @@ def test_disabled(): versioning = meta.versioning assert not versioning.enabled assert versioning.problem == "setupmeta versioning not enabled" - with pytest.raises(Exception): + with pytest.raises(setupmeta.UsageError, match="versioning not enabled"): versioning.bump("major", commit=False) @@ -74,26 +74,28 @@ def test_project_scm(sample_project): def test_snapshot_with_version_file(): - with setupmeta.temp_resource() as temp: - with conftest.capture_output() as logged: - with open(os.path.join(temp, setupmeta.VERSION_FILE), "w") as fh: - fh.write("v1.2.3-4-g1234567") + with setupmeta.temp_resource() as temp, conftest.capture_output(): + version_file = Path(temp) / setupmeta.VERSION_FILE + with open(version_file, "w") as fh: + fh.write("v1.2.3-4-g1234567") - setup_py = os.path.join(temp, "setup.py") - meta = SetupMeta().finalize(dict(_setup_py_path=setup_py, name="just-testing", versioning="post", setup_requires="setupmeta")) + setup_py = os.path.join(temp, "setup.py") + meta = SetupMeta().finalize( + {"_setup_py_path": setup_py, "name": "just-testing", "versioning": "post", "setup_requires": "setupmeta"} + ) - 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" + 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" - # Trigger artificial rewriting of version file - versioning.generate_version_file = True - versioning.auto_fill_version() - assert "WARNING: No 'packages' or 'py_modules' defined" in logged + # Trigger artificial rewriting of version file + versioning.generate_version_file = True + versioning.auto_fill_version() + assert version_file.read_text() == "v1.2.3-4-g1234567" @patch.dict(os.environ, {setupmeta.SCM_DESCRIBE: "1.0"}) @@ -112,9 +114,8 @@ def check_render(v, expected, main="1.0", distance=None, cid=None, dirty=False): assert v.strategy.rendered(version) == expected -@patch("setupmeta.model.project_scm", return_value=None) -def test_no_scm(_, monkeypatch): - with conftest.capture_output() as logged: +def test_no_scm(monkeypatch): + with conftest.capture_output() as logged, patch("setupmeta.model.project_scm", return_value=None): fmt = "branch(a,b):{major}.{minor}.{patch}{post}+{.$*FOO*}.{$BAR1*:}{$*BAR2:}{$BAZ:z}{dirty}" meta = new_meta(fmt) versioning = meta.versioning @@ -139,7 +140,7 @@ def test_no_scm(_, monkeypatch): monkeypatch.setenv("TEST_FOO2", "baz") check_render(versioning, "1.0.0.post2+bar.z.dirty", distance=2, dirty=True) - with pytest.raises(setupmeta.UsageError): + with pytest.raises(setupmeta.UsageError, match="project not under a supported SCM"): versioning.bump("patch") @@ -196,7 +197,6 @@ def test_versioning_variants(*_): quick_check("post+build-id", "0.1.2.post5+h543.g123.dirty") # Aliases - quick_check("changes", "0.1.5+dirty") quick_check("default", "0.1.2.post5+dirty") quick_check("tag", "0.1.2.post5+dirty") @@ -292,7 +292,7 @@ def extra_version(version): def test_invalid_part(): with conftest.capture_output() as logged: - versioning = dict(foo="bar", main="{foo}.{major}.{minor}{", extra=extra_version) + versioning = {"foo": "bar", "main": "{foo}.{major}.{minor}{", "extra": extra_version} meta = new_meta(versioning, scm=conftest.MockGit()) versioning = meta.versioning assert "invalid" in str(versioning.strategy.main_bits) @@ -305,16 +305,13 @@ def test_invalid_part(): assert "Ignored fields for 'versioning': {'foo': 'bar'}" in logged - with pytest.raises(setupmeta.UsageError): - versioning.bump("minor") - - with pytest.raises(setupmeta.UsageError): + with pytest.raises(setupmeta.UsageError, match="invalid versioning part 'foo'"): versioning.get_bump("minor") def test_invalid_main(): with conftest.capture_output() as logged: - meta = new_meta(dict(main=extra_version, extra=""), scm=conftest.MockGit()) + meta = new_meta({"main": extra_version, "extra": ""}, scm=conftest.MockGit()) versioning = meta.versioning check_strategy(versioning, "function 'extra_version'") check_render(versioning, "") @@ -327,18 +324,17 @@ def test_invalid_main(): def test_malformed(): - with conftest.capture_output() as logged: - meta = new_meta(dict(main=None, extra=""), name="", scm=conftest.MockGit()) + with conftest.capture_output(): + meta = new_meta({"main": None, "extra": ""}, name="", scm=conftest.MockGit()) versioning = meta.versioning assert meta.version is None assert not versioning.enabled assert versioning.problem == "No versioning format specified" - assert "WARNING: 'name' not specified in setup.py" in logged def test_custom_version_tag(): with conftest.capture_output(): - meta = new_meta(dict(main="distance", extra="", version_tag="v*.*"), scm=conftest.MockGit(describe="v0.1.2-3-g123")) + meta = new_meta({"main": "distance", "extra": "", "version_tag": "v*.*"}, scm=conftest.MockGit(describe="v0.1.2-3-g123")) versioning = meta.versioning assert versioning.strategy.version_tag == "v*.*" assert versioning.scm.version_tag == "v*.*" @@ -359,7 +355,7 @@ def test_distance_marker(): def test_preconfigured_build_id(): """Verify that short notations expand to the expected format""" check_preconfigured("{major}.{minor}.{patch}{post}+{dirty}", "post", "default", "tag") - check_preconfigured("{major}.{minor}.{distance}+{dirty}", "distance", "changes") + check_preconfigured("{major}.{minor}.{distance}+{dirty}", "distance") check_preconfigured("{major}.{minor}.{distance}+h{$*BUILD_ID:local}.{commitid}{dirty}", "build-id") check_preconfigured("{major}.{minor}.{patch}{dev}+h{$*BUILD_ID:local}.{commitid}{dirty}", "dev+build-id") check_preconfigured("{major}.{minor}.{patch}{post}+h{$*BUILD_ID:local}.{commitid}{dirty}", "post+build-id") @@ -449,14 +445,6 @@ def check_bump(versioning): versioning.bump("foo") -def check_get_bump(versioning): - assert versioning.get_bump("major") == "1.0.0" - assert versioning.get_bump("minor") == "0.2.0" - - with pytest.raises(setupmeta.UsageError): - versioning.get_bump("foo") - - def write_to_file(path, text): with open(path, "w") as fh: fh.write(text) @@ -506,7 +494,7 @@ def test_brand_new_project(): check_version_output("0.0.1") -def test_git_versioning(sample_project): +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" @@ -529,7 +517,7 @@ def test_git_versioning(sample_project): assert output == "0.1.0" # Modify existing file makes checkout dirty - write_to_file("sample.py", "print('hello')") + 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" @@ -546,9 +534,12 @@ def test_git_versioning(sample_project): # 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(): diff --git a/tox.ini b/tox.ini index 11d9a98..646a318 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ [tox] -envlist = py{37,38,39,310,311,312,313}, coverage, docs, style, security +# Running tests against min/max only to speed up local iterations (all non-EOL versions tested in GH actions) +envlist = py{37,314}, coverage, docs, style # Pointing to pypi.org mirror explicitly to avoid using internal mirrors in place at some companies # This is usually not necessary, but can be useful in some cases (eg: latest setuptools not on internal mirror) indexserver = @@ -9,7 +10,8 @@ indexserver = setenv = COVERAGE_FILE={toxworkdir}/.coverage.{envname} usedevelop = True deps = -rtests/requirements.txt -commands = pytest {posargs:-vv --cov=setupmeta/ --cov-report=xml tests/} + py37: pip +commands = pytest {posargs:-vv --cov=setupmeta --cov-report=xml tests} [testenv:coverage] setenv = COVERAGE_FILE={toxworkdir}/.coverage @@ -20,12 +22,8 @@ commands = coverage combine coverage xml coverage html -[testenv:black] -skip_install = True -deps = black -commands = black {posargs:-l140 examples/ setupmeta/ tests/ setup.py} - [testenv:docs] +setenv = PYTHONWARNINGS=ignore skip_install = True deps = check-manifest readme-renderer @@ -34,14 +32,15 @@ commands = check-manifest [testenv:style] skip_install = True -deps = flake8 - flake8-import-order -commands = flake8 {posargs:examples/ setupmeta/ tests/ setup.py} +deps = ruff +commands = ruff check + ruff format --diff -[testenv:security] +[testenv:reformat] skip_install = True -deps = bandit -commands = bandit {posargs:-r examples/ setupmeta/ setup.py} +deps = ruff +commands = ruff check --fix + ruff format [testenv:venv] envdir = .venv @@ -50,7 +49,7 @@ commands = {posargs:python --version} [testenv:refreshscenarios] usedevelop = True -commands = python tests/scenarios.py +commands = python tests/scenarios.py regen [check-manifest] ignore-bad-ideas = PKG-INFO @@ -60,11 +59,3 @@ ignore = .setupmeta.version output = .tox/test-reports/coverage.xml [coverage:html] directory = .tox/test-reports/htmlcov - -[flake8] -max-line-length = 140 -max-complexity = 24 -show-source = True -# See https://github.com/PyCQA/flake8-import-order -import-order-style = edited -application-import-names = setupmeta