diff --git a/.coveragerc b/.coveragerc index ac2cbb9e..bfe9d69b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,10 @@ source_pkgs = incremental # List of directory names. source = tests branch = True +parallel = True +# This must be an absolute path because the example tests +# run Python processes with alternative working directories. +data_file = ${TOX_INI_DIR-.}/.coverage [paths] source = diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 91a5a5ea..db25f29e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -86,13 +86,13 @@ jobs: run: | # Assign the coverage file a name unique to this job so that the # uploads don't collide. - mv .coverage ".coverage-job-${{ matrix.python-version }}-${{ matrix.job-name }}" + mv .coverage ".coverage-job-${{ matrix.python-version }}-${{ matrix.tox-env }}" - name: Store coverage file if: ${{ !cancelled() && !matrix.skip-coverage }} uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.python-version }}-${{ matrix.job-name }} + name: coverage-${{ matrix.python-version }}-${{ matrix.tox-env }} path: .coverage-job-* coverage-report: diff --git a/MANIFEST.in b/MANIFEST.in index dcb89166..c228f404 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,17 +1,20 @@ -include .coveragerc include LICENSE include NEWS.rst -include towncrier.ini +include SECURITY.md include tox.ini -exclude mypy.ini -include src/incremental/py.typed +include _build_meta.py recursive-include src/incremental *.py +include src/incremental/py.typed +prune src/incremental/newsfragments -prune .travis -prune tests +global-exclude .coverage* +include .coveragerc -exclude examplesetup.py -prune src/exampleproj +graft tests +prune tests/example_*/src/*.egg-info +prune tests/example_*/build +prune tests/example_*/dist + +global-exclude __pycache__ *.py[cod] *~ -prune src/incremental/newsfragments diff --git a/README.rst b/README.rst index 7a20d077..6f5c925d 100644 --- a/README.rst +++ b/README.rst @@ -7,39 +7,51 @@ Incremental Incremental is a small library that versions your Python projects. -API documentation can be found `here `_. +API documentation can be found `here `_. Quick Start ----------- -Add this to your ``setup.py``\ 's ``setup()`` call, removing any other versioning arguments: +In your ``pyproject.toml``, add Incremental to your build requirements: -.. code:: +.. code-block:: toml - setup( - use_incremental=True, - setup_requires=['incremental'], - install_requires=['incremental'], # along with any other install dependencies - ... - } + [build-system] + requires = ["setuptools", "incremental>=NEXT"] + build-backend = "setuptools.build_meta" + +Specify the project's version as dynamic: + +.. code-block:: toml + + [project] + name = "" + dynamic = ["version"] + +Remove any ``version`` line and any ``[tool.setuptools.dynamic] version = `` entry. + +Add this empty block to activate Incremental's setuptools plugin: + +.. code-block:: toml + [tool.incremental] Install Incremental to your local environment with ``pip install incremental[scripts]``. Then run ``python -m incremental.update --create``. It will create a file in your package named ``_version.py`` and look like this: -.. code:: +.. code:: python from incremental import Version - __version__ = Version("widgetbox", 17, 1, 0) + __version__ = Version("", 24, 1, 0) __all__ = ["__version__"] Then, so users of your project can find your version, in your root package's ``__init__.py`` add: -.. code:: +.. code:: python from ._version import __version__ @@ -47,6 +59,23 @@ Then, so users of your project can find your version, in your root package's ``_ Subsequent installations of your project will then use Incremental for versioning. +Using ``setup.py`` +~~~~~~~~~~~~~~~~~~ + +Incremental may be used from ``setup.py`` instead of ``pyproject.toml``. +Add this to your ``setup()`` call, removing any other versioning arguments: + +.. code:: python + + setup( + use_incremental=True, + setup_requires=['incremental'], + install_requires=['incremental'], # along with any other install dependencies + ... + } + +Then proceed with the ``incremental.update`` command above. + Incremental Versions -------------------- diff --git a/_build_meta.py b/_build_meta.py new file mode 100644 index 00000000..a5bd38cc --- /dev/null +++ b/_build_meta.py @@ -0,0 +1,18 @@ +""" +Comply with PEP 517's restictions on in-tree backends. + +We use setuptools to package Incremental and want to activate +the in-tree Incremental plugin to manage its own version. To do +this we specify ``backend-path`` in our ``pyproject.toml``, +but PEP 517 requires that when ``backend-path`` is specified: + +> The backend code MUST be loaded from one of the directories +> specified in backend-path (i.e., it is not permitted to +> specify backend-path and not have in-tree backend code). + +We comply by re-publishing setuptools' ``build_meta``. +""" + +from setuptools import build_meta + +__all__ = ["build_meta"] diff --git a/pyproject.toml b/pyproject.toml index 8783b9d5..d80bd8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,57 @@ [build-system] requires = [ - "setuptools >= 44.1.1", - "wheel >= 0.36.2", + # Keep this aligned with the project dependencies. + "setuptools >= 61.0", + "tomli; python_version < '3.11'", ] -build-backend = "setuptools.build_meta" +backend-path = [".", "./src"] # See _build_meta.py +build-backend = "_build_meta:build_meta" + +[project] +name = "incremental" +dynamic = ["version"] +maintainers = [ + {name = "Amber Brown", email = "hawkowl@twistedmatrix.com"}, +] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Framework :: Setuptools Plugin", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.8" +description = "A small library that versions your Python projects." +readme = "README.rst" +dependencies = [ + "setuptools >= 61.0", + "tomli; python_version < '3.11'", +] + +[project.optional-dependencies] +scripts = [ + "click>=6.0", +] +mypy = [ + "mypy==0.812", +] + +[project.urls] +Homepage = "https://github.com/twisted/incremental" +Documentation = "https://twisted.org/incremental/docs/" +Issues = "https://github.com/twisted/incremental/issues" +Changelog = "https://github.com/twisted/incremental/blob/trunk/NEWS.rst" + +[project.entry-points."distutils.setup_keywords"] +use_incremental = "incremental:_get_distutils_version" +[project.entry-points."setuptools.finalize_distribution_options"] +incremental = "incremental:_get_setuptools_version" + +[tool.incremental] [tool.black] target-version = ['py36', 'py37', 'py38'] @@ -12,3 +60,4 @@ target-version = ['py36', 'py37', 'py38'] filename = "NEWS.rst" package_dir = "src/" package = "incremental" +issue_format = "`#{issue} `__" diff --git a/setup.cfg b/setup.cfg index 8695240f..25e25122 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,48 +1,3 @@ -[metadata] -name = incremental -version = attr: incremental._setuptools_version -maintainer = Amber Brown -maintainer_email = hawkowl@twistedmatrix.com -url = https://github.com/twisted/incremental -classifiers = - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 -license = MIT -description = "A small library that versions your Python projects." -long_description = file: README.rst -install_requires = - setuptools - -[options] -packages = find: -package_dir = =src -zip_safe = False - -[options.packages.find] -where = src -exclude = exampleproj - -[options.package_data] -incremental = py.typed - -[options.entry_points] -distutils.setup_keywords = - use_incremental = incremental:_get_version - -[options.extras_require] -scripts = - click>=6.0 - twisted>=16.4.0 -mypy = - %(scripts)s - mypy==0.812 - [flake8] max-line-length = 88 extend-ignore = diff --git a/setup.py b/setup.py deleted file mode 100644 index d6490095..00000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup # type: ignore[import] - -setup() diff --git a/src/exampleproj/_version.py b/src/exampleproj/_version.py deleted file mode 100644 index 85b39a7c..00000000 --- a/src/exampleproj/_version.py +++ /dev/null @@ -1,8 +0,0 @@ -# This file is auto-generated! Do not edit! -# Use `python -m incremental.update exampleproj` to change this file. - -from incremental import Version - -__version__ = Version("exampleproj", 1, 2, 3) - -__all__ = ["__version__"] diff --git a/src/incremental/__init__.py b/src/incremental/__init__.py index c0c06c0a..9e89b8a8 100644 --- a/src/incremental/__init__.py +++ b/src/incremental/__init__.py @@ -9,44 +9,37 @@ from __future__ import division, absolute_import +import os import sys import warnings -from typing import TYPE_CHECKING, Any, TypeVar, Union, Optional, Dict - -# -# Compat functions -# - -_T = TypeVar("_T", contravariant=True) +from typing import TYPE_CHECKING, Any, TypeVar, Union, Optional, Dict, BinaryIO +from dataclasses import dataclass if TYPE_CHECKING: + import io from typing_extensions import Literal from distutils.dist import Distribution as _Distribution -else: - _Distribution = object - -if sys.version_info > (3,): - - def _cmp(a, b): # type: (Any, Any) -> int - """ - Compare two objects. +# +# Compat functions +# - Returns a negative number if C{a < b}, zero if they are equal, and a - positive number if C{a > b}. - """ - if a < b: - return -1 - elif a == b: - return 0 - else: - return 1 +def _cmp(a, b): # type: (Any, Any) -> int + """ + Compare two objects. -else: - _cmp = cmp # noqa: F821 + Returns a negative number if C{a < b}, zero if they are equal, and a + positive number if C{a > b}. + """ + if a < b: + return -1 + elif a == b: + return 0 + else: + return 1 # @@ -71,19 +64,17 @@ def __cmp__(self, other): # type: (object) -> int return 0 return 1 - if sys.version_info >= (3,): - - def __lt__(self, other): # type: (object) -> bool - return self.__cmp__(other) < 0 + def __lt__(self, other): # type: (object) -> bool + return self.__cmp__(other) < 0 - def __le__(self, other): # type: (object) -> bool - return self.__cmp__(other) <= 0 + def __le__(self, other): # type: (object) -> bool + return self.__cmp__(other) <= 0 - def __gt__(self, other): # type: (object) -> bool - return self.__cmp__(other) > 0 + def __gt__(self, other): # type: (object) -> bool + return self.__cmp__(other) > 0 - def __ge__(self, other): # type: (object) -> bool - return self.__cmp__(other) >= 0 + def __ge__(self, other): # type: (object) -> bool + return self.__cmp__(other) >= 0 _inf = _Inf() @@ -167,7 +158,7 @@ def prerelease(self): # type: () -> Optional[int] "Version.release_candidate instead.", DeprecationWarning, stacklevel=2, - ), + ) return self.release_candidate def public(self): # type: () -> str @@ -206,7 +197,6 @@ def public(self): # type: () -> str local = public def __repr__(self): # type: () -> str - if self.release_candidate is None: release_candidate = "" else: @@ -236,7 +226,7 @@ def __repr__(self): # type: () -> str def __str__(self): # type: () -> str return "[%s, version %s]" % (self.package, self.short()) - def __cmp__(self, other): # type: (Version) -> int + def __cmp__(self, other): # type: (object) -> int """ Compare two versions, considering major versions, minor versions, micro versions, then release candidates, then postreleases, then dev @@ -310,43 +300,41 @@ def __cmp__(self, other): # type: (Version) -> int ) return x - if sys.version_info >= (3,): - - def __eq__(self, other): # type: (Any) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c == 0 - - def __ne__(self, other): # type: (Any) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c != 0 - - def __lt__(self, other): # type: (Version) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c < 0 - - def __le__(self, other): # type: (Version) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c <= 0 - - def __gt__(self, other): # type: (Version) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c > 0 - - def __ge__(self, other): # type: (Version) -> bool - c = self.__cmp__(other) - if c is NotImplemented: - return c # type: ignore[return-value] - return c >= 0 + def __eq__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c == 0 + + def __ne__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c != 0 + + def __lt__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c < 0 + + def __le__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c <= 0 + + def __gt__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c > 0 + + def __ge__(self, other): # type: (object) -> bool + c = self.__cmp__(other) + if c is NotImplemented: + return c # type: ignore[return-value] + return c >= 0 def getVersionString(version): # type: (Version) -> str @@ -360,36 +348,173 @@ def getVersionString(version): # type: (Version) -> str return result -def _get_version(dist, keyword, value): # type: (_Distribution, object, object) -> None +def _findPath(path, package): # type: (str, str) -> str + """ + Determine the package root directory. + + The result is one of: + + - src/{package} + - {package} + + Where {package} is downcased. + """ + src_dir = os.path.join(path, "src", package.lower()) + current_dir = os.path.join(path, package.lower()) + + if os.path.isdir(src_dir): + return src_dir + elif os.path.isdir(current_dir): + return current_dir + else: + raise ValueError( + "Can't find the directory of package {}: I looked in {} and {}".format( + package, src_dir, current_dir + ) + ) + + +def _existing_version(path): # type: (str) -> Version """ - Get the version from the package listed in the Distribution. + Load the current version from {path}/_version.py. """ - if not value: + version_info = {} # type: Dict[str, Version] + + versionpath = os.path.join(path, "_version.py") + with open(versionpath, "r") as f: + exec(f.read(), version_info) + + return version_info["__version__"] + + +def _get_setuptools_version(dist): # type: (_Distribution) -> None + """ + Setuptools integration: load the version from the working directory + + This function is registered as a setuptools.finalize_distribution_options + entry point [1]. It is a no-op unless there is a pyproject.toml containing + an empty [tool.incremental] section. + + @param dist: An empty C{setuptools.Distribution} instance to mutate. + + [1]: https://setuptools.pypa.io/en/latest/userguide/extension.html#customizing-distribution-options + """ + config = _load_pyproject_toml("./pyproject.toml") + if not config: return - from distutils.command import build_py + dist.metadata.version = _existing_version(config.path).public() + + +def _get_distutils_version(dist, keyword, value): # type: (_Distribution, object, object) -> None + """ + Distutils integration: get the version from the package listed in the Distribution. + + This function is invoked when a C{setup.py} calls C{setup(use_incremental=True)}. + + @see: https://setuptools.pypa.io/en/latest/userguide/extension.html#adding-arguments + """ + if not value: # use_incremental=False + return # pragma: no cover + + from setuptools.command import build_py # type: ignore sp_command = build_py.build_py(dist) sp_command.finalize_options() - for item in sp_command.find_all_modules(): # type: ignore[attr-defined] + for item in sp_command.find_all_modules(): if item[1] == "_version": - version_file = {} # type: Dict[str, Version] + package_path = os.path.dirname(item[2]) + dist.metadata.version = _existing_version(package_path).public() + return - with open(item[2]) as f: - exec(f.read(), version_file) + raise Exception("No _version.py found.") # pragma: no cover - dist.metadata.version = version_file["__version__"].public() - return None - raise Exception("No _version.py found.") +def _load_toml(f): # type: (BinaryIO) -> Any + """ + Read the content of a TOML file. + """ + # This import is deferred to avoid a hard dependency on tomli + # when no pyproject.toml is present. + if sys.version_info > (3, 11): + import tomllib + else: + import tomli as tomllib + return tomllib.load(f) -from ._version import __version__ # noqa: E402 + +@dataclass(frozen=True) +class _IncrementalConfig: + """ + @ivar package: The package name, capitalized as in the package metadata. + + @ivar path: Path to the package root + """ + + package: str + path: str -def _setuptools_version(): # type: () -> str - return __version__.public() +def _load_pyproject_toml(toml_path): # type: (str) -> Optional[_IncrementalConfig] + """ + Does the pyproject.toml file contain a [tool.incremental] + section? This indicates that the package has opted-in to Incremental + versioning. + + If the [tool.incremental] section is empty we take the project name + from the [project] section. Otherwise we require only a C{name} key + specifying the project name. Other keys are forbidden to allow future + extension and catch typos. + """ + try: + with open(toml_path, "rb") as f: + data = _load_toml(f) + except FileNotFoundError: + return None + + if "tool" not in data: + return None + if "incremental" not in data["tool"]: + return None + + tool_incremental = data["tool"]["incremental"] + if not isinstance(tool_incremental, dict): + raise ValueError("[tool.incremental] must be a table") + + if tool_incremental == {}: + try: + package = data["project"]["name"] + except KeyError: + raise ValueError("""\ +Couldn't extract the package name from pyproject.toml. Specify it like: + + [project] + name = "Foo" + +Or: + + [tool.incremental] + name = "Foo" +""") + elif tool_incremental.keys() == {"name"}: + package = tool_incremental["name"] + else: + raise ValueError("Unexpected key(s) in [tool.incremental]") + + if not isinstance(package, str): + raise TypeError( + "Package name must be a string, but found {}".format(type(package)) + ) + + return _IncrementalConfig( + package=package, + path=_findPath(os.path.dirname(toml_path), package), + ) + + +from ._version import __version__ # noqa: E402 __all__ = ["__version__", "Version", "getVersionString"] diff --git a/src/incremental/newsfragments/80.bugfix b/src/incremental/newsfragments/80.bugfix new file mode 100644 index 00000000..6d87126f --- /dev/null +++ b/src/incremental/newsfragments/80.bugfix @@ -0,0 +1 @@ +Incremental's tests are now included in the sdist release artifact. diff --git a/src/incremental/newsfragments/88.removal b/src/incremental/newsfragments/88.removal new file mode 100644 index 00000000..2351050c --- /dev/null +++ b/src/incremental/newsfragments/88.removal @@ -0,0 +1 @@ +``incremental[scripts]`` no longer depends on Twisted. diff --git a/src/incremental/newsfragments/90.feature b/src/incremental/newsfragments/90.feature new file mode 100644 index 00000000..d17cf875 --- /dev/null +++ b/src/incremental/newsfragments/90.feature @@ -0,0 +1 @@ +Incremental can now be configured using ``pyproject.toml``. diff --git a/src/incremental/tests/test_pyproject.py b/src/incremental/tests/test_pyproject.py new file mode 100644 index 00000000..de3b2693 --- /dev/null +++ b/src/incremental/tests/test_pyproject.py @@ -0,0 +1,117 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +"""Test handling of ``pyproject.toml`` configuration""" + +import os +from twisted.trial.unittest import TestCase + +from incremental import _load_pyproject_toml, _IncrementalConfig + + +class VerifyPyprojectDotTomlTests(TestCase): + """Test the `_load_pyproject_toml` helper function""" + + def test_fileNotFound(self): + """ + Verification fails when no ``pyproject.toml`` file exists. + """ + path = os.path.join(self.mktemp(), "pyproject.toml") + self.assertFalse(_load_pyproject_toml(path)) + + def test_noToolIncrementalSection(self): + """ + Verification fails when there isn't a ``[tool.incremental]`` section. + """ + path = self.mktemp() + for toml in [ + "\n", + "[tool]\n", + "[tool.notincremental]\n", + '[project]\nname = "foo"\n', + ]: + with open(path, "w") as f: + f.write(toml) + self.assertIsNone(_load_pyproject_toml(path)) + + def test_nameMissing(self): + """ + `ValueError` is raised when ``[tool.incremental]`` is present but + he project name isn't available. + """ + path = self.mktemp() + for toml in [ + "[tool.incremental]\n", + "[project]\n[tool.incremental]\n", + ]: + with open(path, "w") as f: + f.write(toml) + + self.assertRaises(ValueError, _load_pyproject_toml, path) + + def test_nameInvalid(self): + """ + `TypeError` is raised when the project name isn't a string. + """ + path = self.mktemp() + for toml in [ + "[tool.incremental]\nname = -1\n", + "[tool.incremental]\n[project]\nname = 1.0\n", + ]: + with open(path, "w") as f: + f.write(toml) + + self.assertRaises(TypeError, _load_pyproject_toml, path) + + def test_toolIncrementalInvalid(self): + """ + `ValueError` is raised when the ``[tool.incremental]`` section isn't + a dict. + """ + path = self.mktemp() + for toml in [ + "[tool]\nincremental = false\n", + "[tool]\nincremental = 123\n", + "[tool]\nincremental = null\n", + ]: + with open(path, "w") as f: + f.write(toml) + + self.assertRaises(ValueError, _load_pyproject_toml, path) + + def test_toolIncrementalUnexpecteKeys(self): + """ + Raise `ValueError` when the ``[tool.incremental]`` section contains + keys other than ``"name"`` + """ + path = self.mktemp() + for toml in [ + "[tool.incremental]\nfoo = false\n", + '[tool.incremental]\nname = "OK"\nother = false\n', + ]: + with open(path, "w") as f: + f.write(toml) + + self.assertRaises(ValueError, _load_pyproject_toml, path) + + def test_ok(self): + """ + The package has opted-in to Incremental version management when + the ``[tool.incremental]`` section is an empty dict. + """ + root = self.mktemp() + path = os.path.join(root, "src", "foo") + os.makedirs(path) + toml_path = os.path.join(root, "pyproject.toml") + + for toml in [ + '[project]\nname = "Foo"\n[tool.incremental]\n', + '[tool.incremental]\nname = "Foo"\n', + ]: + with open(toml_path, "w") as f: + f.write(toml) + + self.assertEqual( + _load_pyproject_toml(toml_path), + _IncrementalConfig(package="Foo", path=path), + ) diff --git a/src/incremental/update.py b/src/incremental/update.py index 64a5cc84..f3e33ee0 100644 --- a/src/incremental/update.py +++ b/src/incremental/update.py @@ -6,53 +6,9 @@ import click import os import datetime -from typing import TYPE_CHECKING, Dict, Optional, Callable, Iterable +from typing import Dict, Optional, Callable -from incremental import Version - -if TYPE_CHECKING: - from typing_extensions import Protocol - - class _ReadableWritable(Protocol): - def read(self): # type: () -> bytes - pass - - def write(self, v): # type: (bytes) -> object - pass - - def __enter__(self): # type: () -> _ReadableWritable - pass - - def __exit__(self, *args, **kwargs): # type: (object, object) -> Optional[bool] - pass - - # FilePath is missing type annotations - # https://twistedmatrix.com/trac/ticket/10148 - class FilePath(object): - def __init__(self, path): # type: (str) -> None - self.path = path - - def child(self, v): # type: (str) -> FilePath - pass - - def isdir(self): # type: () -> bool - pass - - def isfile(self): # type: () -> bool - pass - - def getContent(self): # type: () -> bytes - pass - - def open(self, mode): # type: (str) -> _ReadableWritable - pass - - def walk(self): # type: () -> Iterable[FilePath] - pass - - -else: - from twisted.python.filepath import FilePath +from incremental import Version, _findPath, _existing_version _VERSIONPY_TEMPLATE = '''""" Provides {package} version information. @@ -70,35 +26,6 @@ def walk(self): # type: () -> Iterable[FilePath] _YEAR_START = 2000 -def _findPath(path, package): # type: (str, str) -> FilePath - - cwd = FilePath(path) - - src_dir = cwd.child("src").child(package.lower()) - current_dir = cwd.child(package.lower()) - - if src_dir.isdir(): - return src_dir - elif current_dir.isdir(): - return current_dir - else: - raise ValueError( - "Can't find under `./src` or `./`. Check the " - "package name is right (note that we expect your " - "package name to be lower cased), or pass it using " - "'--path'." - ) - - -def _existing_version(path): # type: (FilePath) -> Version - version_info = {} # type: Dict[str, Version] - - with path.child("_version.py").open("r") as f: - exec(f.read(), version_info) - - return version_info["__version__"] - - def _run( package, # type: str path, # type: Optional[str] @@ -112,17 +39,14 @@ def _run( _getcwd=None, # type: Optional[Callable[[], str]] _print=print, # type: Callable[[object], object] ): # type: (...) -> None - if not _getcwd: _getcwd = os.getcwd if not _date: _date = datetime.date.today() - if type(package) != str: - package = package.encode("utf8") # type: ignore[assignment] - - _path = FilePath(path) if path else _findPath(_getcwd(), package) + if not path: + path = _findPath(_getcwd(), package) if ( newversion @@ -156,7 +80,7 @@ def _run( if newversion: from pkg_resources import parse_version - existing = _existing_version(_path) + existing = _existing_version(path) st_version = parse_version(newversion)._version # type: ignore[attr-defined] release = list(st_version.release) @@ -185,7 +109,7 @@ def _run( existing = v elif rc and not patch: - existing = _existing_version(_path) + existing = _existing_version(path) if existing.release_candidate: v = Version( @@ -199,7 +123,7 @@ def _run( v = Version(package, _date.year - _YEAR_START, _date.month, 0, 1) elif patch: - existing = _existing_version(_path) + existing = _existing_version(path) v = Version( package, existing.major, @@ -209,7 +133,7 @@ def _run( ) elif post: - existing = _existing_version(_path) + existing = _existing_version(path) if existing.post is None: _post = 0 @@ -219,7 +143,7 @@ def _run( v = Version(package, existing.major, existing.minor, existing.micro, post=_post) elif dev: - existing = _existing_version(_path) + existing = _existing_version(path) if existing.dev is None: _dev = 0 @@ -236,7 +160,7 @@ def _run( ) else: - existing = _existing_version(_path) + existing = _existing_version(path) if existing.release_candidate: v = Version(package, existing.major, existing.minor, existing.micro) @@ -254,41 +178,43 @@ def _run( _print("Updating codebase to %s" % (v.public())) - for x in _path.walk(): - - if not x.isfile(): - continue - - original_content = x.getContent() - content = original_content + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + with open(filepath, "rb") as f: + original_content = f.read() + content = original_content + + # Replace previous release_candidate calls to the new one + if existing.release_candidate: + content = content.replace( + existing_version_repr_bytes, version_repr_bytes + ) + content = content.replace( + (package.encode("utf8") + b" " + existing.public().encode("utf8")), + (package.encode("utf8") + b" " + v.public().encode("utf8")), + ) + + # Replace NEXT Version calls with the new one + content = content.replace(NEXT_repr_bytes, version_repr_bytes) + content = content.replace( + NEXT_repr_bytes.replace(b"'", b'"'), version_repr_bytes + ) - # Replace previous release_candidate calls to the new one - if existing.release_candidate: - content = content.replace(existing_version_repr_bytes, version_repr_bytes) + # Replace NEXT with content = content.replace( - (package.encode("utf8") + b" " + existing.public().encode("utf8")), + package.encode("utf8") + b" NEXT", (package.encode("utf8") + b" " + v.public().encode("utf8")), ) - # Replace NEXT Version calls with the new one - content = content.replace(NEXT_repr_bytes, version_repr_bytes) - content = content.replace( - NEXT_repr_bytes.replace(b"'", b'"'), version_repr_bytes - ) - - # Replace NEXT with - content = content.replace( - package.encode("utf8") + b" NEXT", - (package.encode("utf8") + b" " + v.public().encode("utf8")), - ) - - if content != original_content: - _print("Updating %s" % (x.path,)) - with x.open("w") as f: - f.write(content) + if content != original_content: + _print("Updating %s" % (filepath,)) + with open(filepath, "wb") as f: + f.write(content) - _print("Updating %s/_version.py" % (_path.path)) - with _path.child("_version.py").open("w") as f: + versionpath = os.path.join(path, "_version.py") + _print("Updating %s" % (versionpath,)) + with open(versionpath, "wb") as f: f.write( ( _VERSIONPY_TEMPLATE.format(package=package, version_repr=version_repr) diff --git a/examplesetup.py b/tests/example_setuppy/setup.py similarity index 50% rename from examplesetup.py rename to tests/example_setuppy/setup.py index a8294a77..42793030 100644 --- a/examplesetup.py +++ b/tests/example_setuppy/setup.py @@ -1,10 +1,12 @@ from setuptools import setup setup( - name="exampleproj", + name="example_setuppy", package_dir={"": "src"}, - packages=["exampleproj"], + packages=["example_setuppy"], use_incremental=True, zip_safe=False, - setup_requires=["incremental"], + setup_requires=[ + "incremental", + ], ) diff --git a/src/exampleproj/__init__.py b/tests/example_setuppy/src/example_setuppy/__init__.py similarity index 50% rename from src/exampleproj/__init__.py rename to tests/example_setuppy/src/example_setuppy/__init__.py index c1887490..cb566be2 100644 --- a/src/exampleproj/__init__.py +++ b/tests/example_setuppy/src/example_setuppy/__init__.py @@ -1,7 +1,7 @@ """ -An example project. +An example project that uses setup.py -@added: exampleproj NEXT +@added: example_setuppy NEXT """ from incremental import Version @@ -9,5 +9,5 @@ __all__ = ["__version__"] -if Version("exampleproj", "NEXT", 0, 0) > __version__: +if Version("example_setuppy", "NEXT", 0, 0) > __version__: print("Unreleased!") diff --git a/tests/example_setuppy/src/example_setuppy/_version.py b/tests/example_setuppy/src/example_setuppy/_version.py new file mode 100644 index 00000000..e39c43ff --- /dev/null +++ b/tests/example_setuppy/src/example_setuppy/_version.py @@ -0,0 +1,8 @@ +# This file is auto-generated! Do not edit! +# Use `python -m incremental.update example_setuppy` to change this file. + +from incremental import Version + +__version__ = Version("example_setuppy", 1, 2, 3) + +__all__ = ["__version__"] diff --git a/tests/example_setuptools/pyproject.toml b/tests/example_setuptools/pyproject.toml new file mode 100644 index 00000000..beca60e1 --- /dev/null +++ b/tests/example_setuptools/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = [ + "setuptools", + "incremental", +] +build-backend = "setuptools.build_meta" + +[project] +name = "example_setuptools" +dependencies = [ + "incremental", +] +dynamic = ["version"] + +[tool.incremental] diff --git a/tests/example_setuptools/src/example_setuptools/__init__.py b/tests/example_setuptools/src/example_setuptools/__init__.py new file mode 100644 index 00000000..b5f6ee26 --- /dev/null +++ b/tests/example_setuptools/src/example_setuptools/__init__.py @@ -0,0 +1,13 @@ +""" +An example project that uses setuptools in pyproject.toml + +@added: example_setuptools NEXT +""" + +from incremental import Version +from ._version import __version__ + +__all__ = ["__version__"] + +if Version("example_setuptools", "NEXT", 0, 0) > __version__: + print("Unreleased!") diff --git a/tests/example_setuptools/src/example_setuptools/_version.py b/tests/example_setuptools/src/example_setuptools/_version.py new file mode 100644 index 00000000..2d606e4c --- /dev/null +++ b/tests/example_setuptools/src/example_setuptools/_version.py @@ -0,0 +1,8 @@ +# This file is auto-generated! Do not edit! +# Use `python -m incremental.update example_setuptools` to change this file. + +from incremental import Version + +__version__ = Version("example_setuptools", 2, 3, 4) + +__all__ = ["__version__"] diff --git a/tests/test_exampleproj.py b/tests/test_exampleproj.py deleted file mode 100644 index 6561e782..00000000 --- a/tests/test_exampleproj.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) Twisted Matrix Laboratories. -# See LICENSE for details. - -""" -Tests for L{incremental._versioning}. -""" - -from __future__ import division, absolute_import - -from twisted.trial.unittest import TestCase - - -class ExampleProjTests(TestCase): - def test_version(self): - """ - exampleproj has a version of 1.2.3. - """ - import exampleproj - - self.assertEqual(exampleproj.__version__.base(), "1.2.3") diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 00000000..40048507 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,49 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Tests for the packaging examples. +""" + +import os +from importlib import metadata +from subprocess import run + +from build import ProjectBuilder +from twisted.python.filepath import FilePath +from twisted.trial.unittest import TestCase + + +TEST_DIR = FilePath(os.path.abspath(os.path.dirname(__file__))) + + +def build_and_install(path): # type: (FilePath) -> None + builder = ProjectBuilder(path.path) + pkgfile = builder.build("wheel", output_directory=os.environ["PIP_FIND_LINKS"]) + + # Force reinstall in case tox reused the venv. + run(["pip", "install", "--force-reinstall", pkgfile], check=True) + + +class ExampleTests(TestCase): + def test_setuppy_version(self): + """ + example_setuppy has a version of 1.2.3. + """ + build_and_install(TEST_DIR.child("example_setuppy")) + + import example_setuppy + + self.assertEqual(example_setuppy.__version__.base(), "1.2.3") + self.assertEqual(metadata.version("example_setuppy"), "1.2.3") + + def test_setuptools_version(self): + """ + example_setuptools has a version of 2.3.4. + """ + build_and_install(TEST_DIR.child("example_setuptools")) + + import example_setuptools + + self.assertEqual(example_setuptools.__version__.base(), "2.3.4") + self.assertEqual(metadata.version("example_setuptools"), "2.3.4") diff --git a/tox.ini b/tox.ini index 81f968e0..948d0fb6 100644 --- a/tox.ini +++ b/tox.ini @@ -15,13 +15,24 @@ envlist = wheel = true wheel_build_env = build deps = + tests: build tests: coverage + tests: coverage-p + tests,mypy: twisted apidocs: pydoctor lint: pre-commit extras = - mypy: mypy + mypy: mypy,scripts tests: scripts +setenv = + ; Suppress pointless chatter in the output. + PIP_DISABLE_PIP_VERSION_CHECK=yes + + tests: TOX_INI_DIR={toxinidir} + tests: COVERAGE_PROCESS_START={toxinidir}/.coveragerc + tests: PIP_FIND_LINKS={distdir} + commands = python -V @@ -32,9 +43,8 @@ commands = tests: coverage --version tests: {envbindir}/trial --version tests: coverage erase - tests: coverage run -p {envbindir}/trial incremental - tests: coverage run -p examplesetup.py install - tests: coverage run -p {envbindir}/trial tests/test_exampleproj.py + tests: coverage run {envbindir}/trial incremental + tests: coverage run {envbindir}/trial tests/test_examples.py tests: coverage combine tests: coverage report tests: coverage html