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