Repository: twisted/towncrier

Issue:
Remove dependency on incremental in favor of general compatible solution
https://github.com/twisted/towncrier/pull/297#pullrequestreview-556014893
> For `get_version` I think that we should have this logic
> 
> See if version is a tuple and join them with a dot.
> For anything else, just call `str(version_object)` and strip it.
> 
> In this way it will work with incremental and any other version objects.
> 
> towncrier shouldn't care about comparing versions...
> 
> It can try to be smart and detect that you are not adding a version older than the latest one in the file... but I think that is too much...and again it should be implemented with a naive compare
> 
> So I am +1 for remove the incremental dependency :) and use simple version strings for towncrier :)
> 
> I am using a fork of towncrier in which incremental is removed as at least 3 years ago, incremental was not playing nice with private PYPI repos due to a bug in setup tools

Sounds like we could remove the dependency specifically on incremental.  Give it a shot if we get a chance.

Discussion:
sigma67: @adiroiban just to know a timeline, is there any ETA on the next release that could include this change?
adiroiban: We can do a release.
There is already a ticket for that here https://github.com/twisted/towncrier/issues/616

the important part is to have someone who cares for a new release and has some time to test the release
adiroiban: Thanks for fixing all the checks.

As part of this PR we need to update the RELEASE.rst ... if we just merge, the information for RELEASE.rst will no longer be valid

I now see that RELEASE.rst changes were removed from this PR

https://github.com/twisted/towncrier/pull/491/files#diff-0a92214d83ea13ad864f4b1d6c0b5703956cf4bd927c116e75f68d4048e0744f

--------

My hope was that with this PR we can have `pyproject.toml` with

```
[metadata]
version = attr: towncrier.__version__
```

adiroiban: I made a few small changes to the PR description... the important one is linking to the issue, so that when we merge this PR, the issue is automatically closed.
sigma67: Ok, I'm a bit confused admittedly because in the prior MR it was stated explicitly to keep the version file.

Here: https://github.com/twisted/towncrier/pull/491#discussion_r1221613227

If we still want to move to pyproject.toml I'd prefer to do that in a separate MR.

What do you want changed in RELEASE.rst ? Should I update the version as well or is that done by the release manager?
adiroiban: Right now the content of the RELEASE.rst is

https://github.com/twisted/towncrier/blob/4b58be5da5b1fabf38f47d5b777eb9c328046cda/RELEASE.rst?plain=1#L21-L24


----------

I think it should change to

```rst
 Update the version to the release candidate with the first being ``rc1`` (as opposed to 0). 
 In ``src/towncrier/_version.py`` the version is set using PEP440 a compliant string, such as:: 
  
     __version__ = "24.7.0rc1" 
```
sigma67: Ok my bad, I guess it only needed some changes instead of a revert. RELEASE.rst is updated.
adiroiban: I have enabled auto-merge on this.

and once this is merge I plan to do a new release.

Thanks again.

I am very happy to see that we no longer use incremental.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 850af7a..050f37b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -254,7 +254,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        python -m pip install --upgrade pip setuptools wheel
+        python -m pip install --upgrade pip wheel
         python -m pip install pep517
 
     - name: Display structure of files to be pushed
diff --git a/RELEASE.rst b/RELEASE.rst
index 8b457aa..d661153 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -15,13 +15,13 @@ Release candidate
 -----------------
 
 Create a release branch with a name of the form ``release-19.9.0`` starting from the main branch.
-The same branch is used for the release candidated and the final release.
+The same branch is used for the release candidate and the final release.
 In the end, the release branch is merged into the main branch.
 
 Update the version to the release candidate with the first being ``rc1`` (as opposed to 0).
-In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as::
+In ``src/towncrier/_version.py`` the version is set using a PEP440 compliant string:
 
-    __version__ = Version('towncrier', 19, 9, 0, release_candidate=1)
+    __version__ = "19.9.0rc1"
 
 Run ``venv/bin/towncrier build --yes`` to generate the news release NEWS file.
 Commit and push to the primary repository, not a fork.
@@ -60,20 +60,20 @@ In the future we might create a separate Markdown version.
 
 
 Final release
---------------
+-------------
 
 Once the PR is approved, you can trigger the final release.
 
 Update the version to the final version.
-In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as::
+In ``src/towncrier/_version.py`` the version is set using a PEP440 compliant string:
 
-    __version__ = Version('towncrier', 19, 9, 0)
+    __version__ = "19.9.0"
 
 Manually update the `NEWS.rst` file to include the final release version and date.
 Usually it will look like this.
 This will replace the release candidate section::
 
-    towncrier 19.9.0 (2019-09-29)
+    towncrier 19.9.0 (2019-09-01)
     =============================
 
     No significant changes since the previous release candidate.
@@ -87,7 +87,7 @@ Similar to the release candidate, with the difference:
 
 * tag will be named `19.9.0`
 * the target is the same branch
-* Title will be `towncrier 19.0.0`
+* Title will be `towncrier 19.9.0`
 * Content can be the content of the final release (RST format).
 * Check **Set as the latest release**.
 * Check **Create a discussion for this release**.
@@ -96,9 +96,9 @@ Similar to the release candidate, with the difference:
 No need for another review request.
 
 Update the version to the development version.
-In ``src/towncrier/_version.py`` the version is set using ``incremental`` such as::
+In ``src/towncrier/_version.py`` the version is set using a PEP440 compliant string:
 
-    __version__ = Version('towncrier', 19, 9, 1, dev=0)
+    __version__ = "19.9.0.dev0"
 
 Commit and push the changes.
 
@@ -111,6 +111,6 @@ With a squash merge, the whole branch history is lost.
 This causes the `pre-commit autoupdate` to fail.
 See `PR590 <https://github.com/twisted/towncrier/pull/590>`_ for more details.
 
-You can announce the release over IRC or Gitter.
+You can announce the release over IRC, Gitter, or Twisted mailing list.
 
 Done.
diff --git a/admin/canonicalize_version.py b/admin/canonicalize_version.py
deleted file mode 100644
index 671b8c2..0000000
--- a/admin/canonicalize_version.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import click
-import incremental
-import packaging.utils
-
-
-@click.command()
-@click.argument("version")
-def cli(version):
-    """Canonicalizes the passed version according to incremental."""
-
-    parsed_version = packaging.utils.Version(version)
-
-    release_candidate = None
-    if parsed_version.pre is not None:
-        if parsed_version.pre[0] == "rc":
-            release_candidate = parsed_version.pre[1]
-
-    incremental_version = incremental.Version(
-        package="",
-        major=parsed_version.major,
-        minor=parsed_version.minor,
-        micro=parsed_version.micro,
-        release_candidate=release_candidate,
-        post=parsed_version.post,
-        dev=parsed_version.dev,
-    )
-
-    click.echo(incremental_version.public())
-
-
-if __name__ == "__main__":
-    cli()
diff --git a/docs/conf.py b/docs/conf.py
index 9f2098b..a59bfbf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -55,7 +55,7 @@ _today = date.today()
 project = "Towncrier"
 copyright = "{}, Towncrier contributors. Ver {}".format(
     _today.year,
-    towncrier_version.public(),
+    towncrier_version,
 )
 author = "Amber Brown"
 
@@ -63,11 +63,9 @@ author = "Amber Brown"
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 # The short X.Y version.
-version = "{}.{}.{}".format(
-    towncrier_version.major, towncrier_version.minor, towncrier_version.micro
-)
+version = ".".join(towncrier_version.split(".")[:3])
 # The full version, including alpha/beta/rc tags.
-release = towncrier_version.public()
+release = towncrier_version
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
diff --git a/pyproject.toml b/pyproject.toml
index a76f3c8..01f9924 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     "hatchling",
-    "incremental == 22.10.0",
+    "wheel",
 ]
 build-backend = "hatchling.build"
 
@@ -32,8 +32,7 @@ dependencies = [
     "click",
     "importlib-resources>=5; python_version<'3.10'",
     "importlib-metadata>=4.6; python_version<'3.10'",
-    "incremental",
-    "jinja2>=3",
+    "jinja2",
     "tomli; python_version<'3.11'",
 ]
 
@@ -153,12 +152,6 @@ module = 'towncrier.click_default_group'
 # Vendored module without type annotations.
 ignore_errors = true
 
-[[tool.mypy.overrides]]
-module = 'incremental'
-# No released version with type hints.
-ignore_missing_imports = true
-
-
 [tool.coverage.run]
 parallel = true
 branch = true
diff --git a/src/towncrier/__init__.py b/src/towncrier/__init__.py
index 47a8140..a7578e1 100644
--- a/src/towncrier/__init__.py
+++ b/src/towncrier/__init__.py
@@ -7,13 +7,11 @@ towncrier, a builder for your news files.
 
 from __future__ import annotations
 
-from incremental import Version
-
 
 __all__ = ["__version__"]
 
 
-def __getattr__(name: str) -> Version:
+def __getattr__(name: str) -> str:
     if name != "__version__":
         raise AttributeError(f"module {__name__} has no attribute {name}")
 
diff --git a/src/towncrier/_project.py b/src/towncrier/_project.py
index 53e72e9..7b3a98c 100644
--- a/src/towncrier/_project.py
+++ b/src/towncrier/_project.py
@@ -7,14 +7,12 @@ Responsible for getting the version and name from a project.
 
 from __future__ import annotations
 
+import contextlib
 import sys
 
 from importlib import import_module
 from importlib.metadata import version as metadata_version
 from types import ModuleType
-from typing import Any
-
-from incremental import Version as IncrementalVersion
 
 
 if sys.version_info >= (3, 10):
@@ -63,7 +61,7 @@ def get_version(package_dir: str, package: str) -> str:
     Try to extract the version from the distribution version metadata that matches
     `package`, then fall back to looking for the package in `package_dir`.
     """
-    version: Any
+    version: str | None
 
     # First try to get the version from the package metadata.
     if version := _get_metadata_version(package):
@@ -71,9 +69,7 @@ def get_version(package_dir: str, package: str) -> str:
 
     # When no version if found, fall back to looking for the package in `package_dir`.
     module = _get_package(package_dir, package)
-
     version = getattr(module, "__version__", None)
-
     if not version:
         raise Exception(
             f"No __version__ or metadata version info for the '{package}' package."
@@ -82,37 +78,30 @@ def get_version(package_dir: str, package: str) -> str:
     if isinstance(version, str):
         return version.strip()
 
-    if isinstance(version, IncrementalVersion):
-        # FIXME:https://github.com/twisted/incremental/issues/81
-        # Incremental uses `.rcN`.
-        # importlib uses `rcN` (without a dot separation).
-        # Here we make incremental work like importlib.
-        return version.base().strip().replace(".rc", "rc")
-
     if isinstance(version, tuple):
         return ".".join(map(str, version)).strip()
 
+    # Try duck-typing as an Incremental version.
+    if hasattr(version, "base"):
+        try:
+            version = str(version.base()).strip()
+            # Incremental uses `X.Y.rcN`.
+            # Standardize on importlib (and PEP440) use of `X.YrcN`:
+            return version.replace(".rc", "rc")  # type: ignore
+        except TypeError:
+            pass
+
     raise Exception(
-        "I only know how to look at a __version__ that is a str, "
-        "an Increment Version, or a tuple. If you can't provide "
-        "that, use the --version argument and specify one."
+        "Version must be a string, tuple, or an Incremental Version."
+        " If you can't provide that, use the --version argument and specify one."
     )
 
 
 def get_project_name(package_dir: str, package: str) -> str:
     module = _get_package(package_dir, package)
-
     version = getattr(module, "__version__", None)
+    # Incremental has support for package names, try duck-typing it.
+    with contextlib.suppress(AttributeError):
+        return str(version.package)  # type: ignore
 
-    if not version:
-        # welp idk
-        return package.title()
-
-    if isinstance(version, str):
-        return package.title()
-
-    if isinstance(version, IncrementalVersion):
-        # Incremental has support for package names
-        return version.package
-
-    raise TypeError(f"Unsupported type for __version__: {type(version)}")
+    return package.title()
diff --git a/src/towncrier/_shell.py b/src/towncrier/_shell.py
index 7aac172..6e9ae17 100644
--- a/src/towncrier/_shell.py
+++ b/src/towncrier/_shell.py
@@ -19,7 +19,7 @@ from .create import _main as _create_cmd
 
 
 @click.group(cls=DefaultGroup, default="build", default_if_no_args=True)
-@click.version_option(__version__.public())
+@click.version_option(__version__)
 def cli() -> None:
     """
     Towncrier is a utility to produce useful, summarised news files for your project.
diff --git a/src/towncrier/_version.py b/src/towncrier/_version.py
index 020bb62..93736ba 100644
--- a/src/towncrier/_version.py
+++ b/src/towncrier/_version.py
@@ -2,18 +2,11 @@
 Provides towncrier version information.
 """
 
-# This file is auto-generated! Do not edit!
-# Use `python -m incremental.update towncrier` to change this file.
-
-from incremental import Version
-
-
-# For dev   - Version('towncrier', 23, 8, 1, dev=0)
-# For RC    - Version('towncrier', 23, 9, 0, release_candidate=1)
-# For final - Version('towncrier', 23, 9, 0)
-__version__ = Version("towncrier", 23, 11, 1, dev=0)
-# The version is exposed in string format to be
-# available for the hatching build tools.
-_hatchling_version = __version__.short()
-
-__all__ = ["__version__", "_hatchling_version"]
+# For dev   - 23.11.1.dev0
+# For RC    - 23.11.1.rc1
+# For final - 23.11.1
+# make sure to follow PEP440
+__version__ = "23.11.1.dev0"
+
+_hatchling_version = __version__
+__all__ = ["_hatchling_version"]
diff --git a/src/towncrier/newsfragments/491.misc b/src/towncrier/newsfragments/491.misc
new file mode 100644
index 0000000..1fc092d
--- /dev/null
+++ b/src/towncrier/newsfragments/491.misc
@@ -0,0 +1,3 @@
+Remove incremental dependency from towncrier.
+
+Towncrier can still read incremental versions, it just doesn't rely on the package itself any more.


Test patch:
diff --git a/src/towncrier/test/test_packaging.py b/src/towncrier/test/test_packaging.py
index 3355ad5..199adff 100644
--- a/src/towncrier/test/test_packaging.py
+++ b/src/towncrier/test/test_packaging.py
@@ -1,22 +1,26 @@
 # Copyright (c) Amber Brown, 2015
 # See LICENSE for details.
 
-from incremental import Version
 from twisted.trial.unittest import TestCase
 
-from towncrier._version import _hatchling_version
+import towncrier
 
 
 class TestPackaging(TestCase):
-    def test_version_warning(self):
+    def test_version_attr(self):
         """
-        Import __version__ from towncrier returns an Incremental version object
-        and raises a warning.
+        towncrier.__version__ was deprecated, but still exists for now.
         """
-        with self.assertWarnsRegex(
-            DeprecationWarning, "Accessing towncrier.__version__ is deprecated.*"
-        ):
-            from towncrier import __version__
 
-        self.assertIsInstance(__version__, Version)
-        self.assertEqual(_hatchling_version, __version__.short())
+        def access__version():
+            return towncrier.__version__
+
+        expected_warning = (
+            "Accessing towncrier.__version__ is deprecated and will be "
+            "removed in a future release. Use importlib.metadata directly "
+            "to query for towncrier's packaging metadata."
+        )
+
+        self.assertWarns(
+            DeprecationWarning, expected_warning, __file__, access__version
+        )
diff --git a/src/towncrier/test/test_project.py b/src/towncrier/test/test_project.py
index 62ec3d7..a93ef19 100644
--- a/src/towncrier/test/test_project.py
+++ b/src/towncrier/test/test_project.py
@@ -5,7 +5,6 @@ import os
 import sys
 
 from importlib.metadata import version as metadata_version
-from unittest import mock
 
 from click.testing import CliRunner
 from twisted.trial.unittest import TestCase
@@ -47,30 +46,69 @@ class VersionFetchingTests(TestCase):
 
     def test_incremental(self):
         """
-        An incremental Version __version__  is picked up.
+        An incremental-like Version __version__  is picked up.
         """
-        pkg = "../src"
+        temp = self.mktemp()
+        os.makedirs(temp)
+        os.makedirs(os.path.join(temp, "mytestprojinc"))
 
-        with self.assertWarnsRegex(
-            DeprecationWarning, "Accessing towncrier.__version__ is deprecated.*"
-        ):
-            # Previously this triggered towncrier.__version__ but now the first version
-            # check is from the package metadata. Let's mock out that part to ensure we
-            # can get incremental versions from __version__ still.
-            with mock.patch(
-                "towncrier._project._get_metadata_version", return_value=None
-            ):
-                version = get_version(pkg, "towncrier")
+        with open(os.path.join(temp, "mytestprojinc", "__init__.py"), "w") as f:
+            f.write(
+                """
+class Version:
+    '''
+    This is emulating a Version object from incremental.
+    '''
 
-            version = get_version(pkg, "towncrier")
+    def __init__(self, *version_parts):
+        self.version = version_parts
+        self.package = "mytestprojinc"
 
-        with self.assertWarnsRegex(
-            DeprecationWarning, "Accessing towncrier.__version__ is deprecated.*"
-        ):
-            name = get_project_name(pkg, "towncrier")
+    def base(self):
+        return '.'.join(map(str, self.version))
 
-        self.assertEqual(metadata_version("towncrier"), version)
-        self.assertEqual("towncrier", name)
+__version__ = Version(1, 3, 12, "rc1")
+                """
+            )
+
+        version = get_version(temp, "mytestprojinc")
+        self.assertEqual(version, "1.3.12rc1")
+
+        project = get_project_name(temp, "mytestprojinc")
+        self.assertEqual(project, "mytestprojinc")
+
+    def test_not_incremental(self):
+        """
+        An exception is raised when the version could not be detected.
+        For this test we use an incremental-like object,
+        that has the `base` method, but that method
+        does not match the return type for `incremental`.
+        """
+        temp = self.mktemp()
+        os.makedirs(os.path.join(temp, "mytestprojnotinc"))
+
+        with open(os.path.join(temp, "mytestprojnotinc", "__init__.py"), "w") as f:
+            f.write(
+                """
+class WeirdVersion:
+    def base(self, some_arg):
+        return "shouldn't get here"
+
+
+__version__ = WeirdVersion()
+"""
+            )
+        with self.assertRaises(Exception) as e:
+            get_version(temp, "mytestprojnotinc")
+
+        self.assertEqual(
+            (
+                "Version must be a string, tuple, or an Incremental Version. "
+                "If you can't provide that, use the --version argument and "
+                "specify one.",
+            ),
+            e.exception.args,
+        )
 
     def test_version_from_metadata(self):
         """
@@ -129,7 +167,7 @@ class VersionFetchingTests(TestCase):
 
         self.assertRaises(Exception, get_version, temp, "mytestprojb")
 
-        self.assertRaises(TypeError, get_project_name, temp, "mytestprojb")
+        self.assertEqual("Mytestprojb", get_project_name(temp, "mytestprojb"))
 
     def test_import_fails(self):
         """