From 9bfea50a5ca2c3f2d0278217179ca755ec18b28a Mon Sep 17 00:00:00 2001 From: set Date: Thu, 23 Mar 2023 11:18:22 +0000 Subject: [PATCH 1/4] version manipulation --- setup.cfg | 2 + src/spetlrtools/manipulate_version.py | 133 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/spetlrtools/manipulate_version.py diff --git a/setup.cfg b/setup.cfg index ac4a07c..1c49944 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ install_requires = requests dateparser pytest + packaging [options.packages.find] where=src @@ -55,6 +56,7 @@ console_scripts = spetlr-freeze-req = spetlrtools.requirements:main spetlr-az-databricks-token = spetlrtools.az_databricks_token.main:main spetlr-test-job = spetlrtools.test_job.main:main + spetlr-manipulate-version = spetlrtools.manipulate_version:main [flake8] diff --git a/src/spetlrtools/manipulate_version.py b/src/spetlrtools/manipulate_version.py new file mode 100644 index 0000000..d3a3545 --- /dev/null +++ b/src/spetlrtools/manipulate_version.py @@ -0,0 +1,133 @@ +import argparse +import configparser +import json +import sys +from typing import IO +from urllib.request import urlopen + +import requests +from packaging.version import parse, Version + + +def main(): + """Main CLI function, not to be used directly.""" + parser = argparse.ArgumentParser( + description="Automatically set the version for upload to pypi" + ) + parser.add_argument( + "-t", + dest="prerelease", + action="store_true", + help="prepare pre-release version for test.pypi", + ) + parser.add_argument("--name", help="Package name, if different from setup.cfg") + parser.add_argument( + "--version-file", + default="src/VERSION.txt", + help="location of version to manipulate", + ) + + args = parser.parse_args() + name = args.name + if not name: + config = configparser.ConfigParser() + config.read("setup.cfg") + name = config["metadata"]["name"] + + try: + with open(args.version_file, "r+", encoding="utf-8") as v_file: + mainpulate_version( + package_name=name, version_file=v_file, pre_release=args.prerelease + ) + + except FileNotFoundError: + print(f"Version file {args.version_file} not found.", file=sys.stderr) + parser.print_usage() + exit(-1) + + +def mainpulate_version(package_name: str, version_file: IO, pre_release: bool): + """Query the indices, manipuate the version file and print the vesions to screen.""" + + # get all relevant versions for manipulation + test_v = get_remote_version("test.pypi.org", package_name) + pypi_v = get_remote_version("pypi.org", package_name) + local_v = get_local_version(version_file) + + next_version = _decide_next_version(pypi_v, test_v, local_v, pre_release) + + version_file.seek(0) + version_file.truncate() + version_file.write(str(next_version)) + print(str(next_version)) + + +class InvalidVersionManipulation(Exception): + pass + + +def _decide_next_version( + pypi_v: Version, test_v: Version, local_v: Version, pre_release: bool +): + """Returns the next version to be published in the local flow. + See the documentation for how versions are manipulated. + """ + + # simple incrementation yields the naive next release + next_incremental_release = parse(f"{pypi_v.major}.{pypi_v.minor}.{pypi_v.micro+1}") + + # if local_v is higher than a simple increment, that is what we use + next_release = max(next_incremental_release, local_v) + + if not pre_release: + return next_release + + # in case of pre-release, we need more work. + if parse(test_v.base_version) < next_release: + # the last test.pypi version relates to a previous release candidate + # start a new serios of pre-releases + pre_n = 1 + elif test_v.release == next_release.release and test_v < next_release: + # we have previously released a pre-release version to test_pypi. + # just use the next one + if test_v.pre is not None: + abrc, n = test_v.pre + if abrc == "rc": + # in case of a(lpha) or b(eta) release, still count the rc from 1 + pre_n = n + 1 + else: + raise InvalidVersionManipulation( + f"Test Pypi has {test_v}, " + f"and next final release is {next_release}. " + f"Releasing a release candidate for {next_release} " + "would not make that release caindiate the highest on test.pypi. " + "Please increment you package version to above the test.pypi version " + "and try again." + ) + + return parse(f"{next_release.base_version}rc{pre_n}") + + +def get_remote_version(server: str, package: str): + """Query the repository and return the Version (PEP-0440) + that is the highest of any kind, not just final versions. + In case of any issues, return Version("0"), which is lower + than any other version and therefores usitable for the rest of the logic.""" + try: + return max( + parse(k) + for k in requests.get(f"https://{server}/pypi/{package}/json") + .json()["releases"] + .keys() + ) + # return parse(remote["info"]["version"]) + except: # noqa: E722 # bare except is fine in this simple case + return parse("0") + + +def get_local_version(v_file: IO): + """read the version from the file. Only keep the base part of the version string.""" + v_file.seek(0) + v = parse(v_file.read()) + v = parse(v.base_version) # remove any suffices + return v From 2b7c862689a149702cef59df0bd137417fb8b294 Mon Sep 17 00:00:00 2001 From: set Date: Thu, 23 Mar 2023 14:11:17 +0000 Subject: [PATCH 2/4] formatted Readme --- docs/tools/README.md | 123 ++++++++++++++++++++------ tests/unit/test_manipulate_version.py | 0 2 files changed, 95 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_manipulate_version.py diff --git a/docs/tools/README.md b/docs/tools/README.md index 031c316..b916366 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -2,7 +2,8 @@ ## ValidateCamelCasedCols -The function takes a dataframe and validates if all columns or a given subset of columns are camelCased. +The function takes a dataframe and validates if all columns or a given subset of +columns are camelCased. The algorithm is simple, where the following must hold: * Column name must be camelCased. * Column name must NOT contain two or more recurrent characters. @@ -32,11 +33,15 @@ OUTPUT: True *This is just a tool for investigating - not for production purposes.* -Some files - like eventhub capture files - contains a binary encoded *Body* column. The `ExtractEventhubBody` class can help decode the column. -Either one can get the encoded schema as a json schema (`extract_json_schema`) or transform the dataframe using `transform_df`. +Some files - like eventhub capture files - contains a binary encoded *Body* column. +The `ExtractEventhubBody` class can help decode the column. +Either one can get the encoded schema as a json schema (`extract_json_schema`) or +transform the dataframe using `transform_df`. -Be aware, that the schema extraction can be a slow process, so it is not recommended to use the extractor in a production setting. -*HINT: You should in stead find a way to have a static schema definition. Either as a json schema, pyspark struct +Be aware, that the schema extraction can be a slow process, so it is not recommended +to use the extractor in a production setting. +*HINT: You should in stead find a way to have a static schema definition. Either as +a json schema, pyspark struct or read the schema from a target table - and use that for decode the Body.* ``` python @@ -65,9 +70,20 @@ OUTPUT: ## ModuleHelper -The `ModuleHelper` class provides developers with a useful tool for interacting with modules in Python. Its primary purpose is to allow developers to retrieve all modules from a given package or module in a flexible manner, without requiring detailed knowledge of the module structure. Additionally, the `ModuleHelper` class enables developers to retrieve classes and/or subclasses of a specified type from a package or module, further simplifying the process of working with multiple modules. - -For example, consider a scenario where a developer is working on a large-scale Python project with numerous modules, many of which may not be directly related to the current task at hand. By using the `ModuleHelper` class, the developer can quickly and easily retrieve all relevant modules or classes/subclasses, without needing to know the precise structure or location of each individual module/class/subclass. This can save significant time and effort, as well as making the code more maintainable and easier to understand. +The `ModuleHelper` class provides developers with a useful tool for interacting with +modules in Python. Its primary purpose is to allow developers to retrieve all +modules from a given package or module in a flexible manner, without requiring +detailed knowledge of the module structure. Additionally, the `ModuleHelper` class +enables developers to retrieve classes and/or subclasses of a specified type from a +package or module, further simplifying the process of working with multiple modules. + +For example, consider a scenario where a developer is working on a large-scale +Python project with numerous modules, many of which may not be directly related to +the current task at hand. By using the `ModuleHelper` class, the developer can +quickly and easily retrieve all relevant modules or classes/subclasses, without +needing to know the precise structure or location of each individual +module/class/subclass. This can save significant time and effort, as well as making +the code more maintainable and easier to understand. ### Example - `get_modules()` method @@ -86,7 +102,9 @@ Consider the following project: └── __init__.py ``` -The modules `dataplatform.foo.main` and `dataplatform.bar.sub` can be retrieved using the `get_modules()` method (if either module had any submodules those would be retrieved as well): +The modules `dataplatform.foo.main` and `dataplatform.bar.sub` can be retrieved +using the `get_modules()` method (if either module had any submodules those would be +retrieved as well): ```python from spetlrtools.helpers import ModuleHelper @@ -96,7 +114,9 @@ denmark_modules = ModuleHelper.get_modules( ) ``` -The above returns a dictionary, where the each key point to the location of a module. The values are the respective module of type `ModuleType` (from the builtin types library): +The above returns a dictionary, where the each key point to the location of a module +The values are the respective module of type `ModuleType` (from the builtin types +library): ```python { @@ -129,9 +149,14 @@ class D: ... # implementation of class D ``` -We have that `/main.py` defines a `class A`. And `class B` and `class C` are subclasses (inherited) hereof. Keep in mind, `class C` is inherits from `class A` and that `class A` is imported from the `dataplatform.foo.main` module. `class D` just sits in `dataplatform.bar.sub` but is not a subclass of any of the other classes. +We have that `/main.py` defines a `class A`. And `class B` and `class C` are +subclasses (inherited) hereof. Keep in mind, `class C` is inherits from `class A` +and that `class A` is imported from the `dataplatform.foo.main` module. `class D` +just sits in `dataplatform.bar.sub` but is not a subclass of any of the other classes. -Using the `get_classes_of_type()` method from the `ModuleHelper` all definitions of `class A` can be retrieved together with its subclasses `class B` and `class C` (and not `class D`): +Using the `get_classes_of_type()` method from the `ModuleHelper` all definitions of +`class A` can be retrieved together with its subclasses `class B` and `class C` (and +not `class D`): ```python from spetlrtools.helpers import ModuleHelper @@ -143,17 +168,35 @@ classes_and_subclasses_of_type_A = ModuleHelper.get_classes_of_type( ) ``` -The above returns a dictionary, where the keys point to the location of the classes. The values are a respective dictionary containing information about the module that the class is associated with and the class itself: +The above returns a dictionary, where the keys point to the location of the classes. +The values are a respective dictionary containing information about the module that +the class is associated with and the class itself: ```python { - "dataplatform.foo.main.A": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type}, - "dataplatform.foo.main.B": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type}, - "dataplatform.bar.sub.C": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type}, + "dataplatform.foo.main.A": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + }, + "dataplatform.foo.main.B": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + }, + "dataplatform.bar.sub.C": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + }, } ``` -The `get_classes_of_type()` method is configurable such that only classes of the `obj` type is returned and not its subclasses: +The `get_classes_of_type()` method is configurable such that only classes of the +`obj` type is returned and not its subclasses: ```python from spetlrtools.helpers import ModuleHelper from dataplatform.foo.main import A @@ -168,7 +211,12 @@ only_main_classes_of_type_A = ModuleHelper.get_classes_of_type( The above returns: ```python { - "dataplatform.foo.main.A": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type} + "dataplatform.foo.main.A": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + } } ``` @@ -188,14 +236,25 @@ only_main_classes_of_type_A = ModuleHelper.get_classes_of_type( The above returns: ```python { - "dataplatform.foo.main.B": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type}, - "dataplatform.bar.sub.C": {"module_name": str, "module": ModuleType, "cls_name": str, "cls", type}, + "dataplatform.foo.main.B": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + }, + "dataplatform.bar.sub.C": { + "module_name": str, + "module": ModuleType, + "cls_name": str, + "cls": type + }, } ``` ## TaskEntryPointHelper -The `TaskEntryPointHelper` provides the method `get_all_task_entry_points()`, which uses the ModuleHelper (see the documentation above) to retrieve all `task()` methods of the subclasses of the class `TaskEntryPoint`. Note that `TaskEntryPoint` is an abstract base class from atc-dataplatform, see the documentation over there. +The `TaskEntryPointHelper` provides the method `get_all_task_entry_points()`, which +uses the ModuleHelper (see the documentation above) to retrieve all `task()` methods of the subclasses of the class `TaskEntryPoint`. Note that `TaskEntryPoint` is an abstract base class from atc-dataplatform, see the documentation over there. ### Example - `get_all_task_entry_points()` method @@ -236,7 +295,8 @@ class Second(TaskEntryPoint): ... # implementation of the task here ``` -Now, by utilizing the `get_all_task_entry_points()` method all the `task()` class methods can automatically be discovered as entry points: +Now, by utilizing the `get_all_task_entry_points()` method all the `task()` class +methods can automatically be discovered as entry points: ```python from spetlrtools.entry_points import TaskEntryPointHelper @@ -256,9 +316,12 @@ This returns a dictionary: } ``` -The developer can add this key-value pair to their setup of their package. When new subclasses of the `TaskEntryPoint` class are added then this function automatically discover the entry points for their `task()` methods. +The developer can add this key-value pair to their setup of their package. When new +subclasses of the `TaskEntryPoint` class are added then this function automatically +discover the entry points for their `task()` methods. -If the developer wants to see the entry points, a path to a txt file can be added when executing the method: +If the developer wants to see the entry points, a path to a txt file can be added +when executing the method: ```python from spetlrtools.entry_points import TaskEntryPointHelper @@ -275,11 +338,15 @@ dataplatform.foo.main.First = dataplatform.foo.main:First.task dataplatform.bar.sub.Second = dataplatform.bar.sub:Second.task ``` -This way it is easy to verify and check entry points manually if the developers workflow depends on this. +This way it is easy to verify and check entry points manually if the developers +workflow depends on this. ### Example - Using the `get_all_task_entry_points()` method with a different base class -The `get_all_task_entry_points()` method is tied closely with the atc-dataplatform `TaskEntryPoint` class. If there is a use case for implementing other custom base classes (with a `task()` abstract class method) then a `entry_point_objects` list variable can be set to look for a different base classes. See below example: +The `get_all_task_entry_points()` method is tied closely with the atc-dataplatform +`TaskEntryPoint` class. If there is a use case for implementing other custom base +classes (with a `task()` abstract class method) then a `entry_point_objects` list +variable can be set to look for a different base classes. See below example: ```python from abc import ABC, abstractmethod @@ -323,5 +390,5 @@ TaskEntryPointHelper.get_all_task_entry_points( ) ``` -This returns a dictionary of entry points pointing to `A`, `B`, and `C` as they are children of the new `OtherBaseClass` and `AnotherBaseClass` classes. -``` \ No newline at end of file +This returns a dictionary of entry points pointing to `A`, `B`, and `C` as they are +children of the new `OtherBaseClass` and `AnotherBaseClass` classes. diff --git a/tests/unit/test_manipulate_version.py b/tests/unit/test_manipulate_version.py new file mode 100644 index 0000000..e69de29 From 3c256a29e26f311e74d49f515e0fc32748b0160a Mon Sep 17 00:00:00 2001 From: set Date: Thu, 23 Mar 2023 14:11:53 +0000 Subject: [PATCH 3/4] version manipulation test and documentation --- docs/tools/README.md | 40 +++++++ src/spetlrtools/manipulate_version.py | 4 +- tests/unit/test_manipulate_version.py | 143 ++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/docs/tools/README.md b/docs/tools/README.md index b916366..d544568 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -392,3 +392,43 @@ TaskEntryPointHelper.get_all_task_entry_points( This returns a dictionary of entry points pointing to `A`, `B`, and `C` as they are children of the new `OtherBaseClass` and `AnotherBaseClass` classes. + + +## Manipulate Versions + +In our release pipelines, we pursue a stategy of combined manual and automated +version handling. The file `src/VERSION.txt` contains a version of the form `major. +minor.micro` in conformance with [Python PEP-0440](https://peps.python.org/pep-0440/). +We provide a tool to automatically increment the micro and release candidate version so +that it is higher with respect to PyPI and test.PyPI, so that uploads can happen +automatically. + +The intention is that all release candidates are uploaded only to test.PyPi, while +all final versions are uploaded to PyPI proper. + +The tool supports this manipulation in when used as follows: +``` +usage: spetlr-manipulate-version [-h] [-t] [--name NAME] [--version-file VERSION_FILE] + +Automatically set the version for upload to pypi + +optional arguments: + -h, --help show this help message and exit + -t prepare pre-release version for test.pypi + --name NAME Package name, if different from name in setup.cfg + --version-file VERSION_FILE + location of version to manipulate +``` + +In the current repo, it can be used without arguments. The manipulations are best +illustrated by this example: + +| situation | VERSION.txt | pypi.org | test.pypi.org | cli flags | new version | +|--------------------------------|-------------|----------|---------------|-----------|-------------| +| post-integration version 0.2.8 | 0.2.8 | 0.1.34 | 0.1.34rc4 | -t | 0.2.8rc1 | +| release new version 0.2.8 | 0.2.8 | 0.1.34 | 0.2.8rc1 | | 0.2.8 | +| normal post-integration | 0.2.8 | 0.2.8 | 0.2.8rc1 | -t | 0.2.9rc1 | +| second post-integration | 0.2.8 | 0.2.8 | 0.2.9rc1 | -t | 0.2.9rc2 | +| normal release | 0.2.8 | 0.2.8 | 0.2.9rc1 | | 0.2.9 | +| re-run of release | 0.2.8 | 0.2.9 | 0.2.9rc1 | | 0.2.10 | + diff --git a/src/spetlrtools/manipulate_version.py b/src/spetlrtools/manipulate_version.py index d3a3545..014433d 100644 --- a/src/spetlrtools/manipulate_version.py +++ b/src/spetlrtools/manipulate_version.py @@ -20,7 +20,9 @@ def main(): action="store_true", help="prepare pre-release version for test.pypi", ) - parser.add_argument("--name", help="Package name, if different from setup.cfg") + parser.add_argument( + "--name", help="Package name, if different from name in setup.cfg" + ) parser.add_argument( "--version-file", default="src/VERSION.txt", diff --git a/tests/unit/test_manipulate_version.py b/tests/unit/test_manipulate_version.py index e69de29..99b8464 100644 --- a/tests/unit/test_manipulate_version.py +++ b/tests/unit/test_manipulate_version.py @@ -0,0 +1,143 @@ +import unittest + +from spetlrtools.manipulate_version import ( + _decide_next_version, + InvalidVersionManipulation, +) +from packaging.version import parse + + +class VersioningTest(unittest.TestCase): + def test_01_local_version_highest_final_release(self): + local_v = parse("0.1.16") + + # local absolutely highest + pypi_v = parse("0.1.15") + test_v = parse("0.1.15rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.16", str(next_version)) + + # local above rc + pypi_v = parse("0.1.15") + test_v = parse("0.1.16rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.16", str(next_version)) + + # local below rc + pypi_v = parse("0.1.15") + test_v = parse("0.1.17rc5") + + # in the case of fina release, the test version does not matter + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.16", str(next_version)) + + def test_02_local_version_highest_pre_release(self): + local_v = parse("0.1.16") + + # local absolutely highest + pypi_v = parse("0.1.15") + test_v = parse("0.1.15rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) + + self.assertEqual("0.1.16rc1", str(next_version)) + + # local above rc + pypi_v = parse("0.1.15") + test_v = parse("0.1.16rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) + + self.assertEqual("0.1.16rc6", str(next_version)) + + # local below rc + pypi_v = parse("0.1.15") + test_v = parse("0.1.17rc5") + + # in the case of pre-release, the test version does matter + with self.assertRaises(InvalidVersionManipulation): + _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) + + def test_03_auto_increment_final_release(self): + local_v = parse("0.1.0") + + # auto increment standard situation + pypi_v = parse("0.1.14") + test_v = parse("0.1.15rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.15", str(next_version)) + + # auto increment, test.pypi is behind + pypi_v = parse("0.1.14") + test_v = parse("0.1.12rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.15", str(next_version)) + + # auto increment, test.pypi is far ahead + pypi_v = parse("0.1.14") + test_v = parse("0.1.17rc5") + + # in the case of fina release, the test version does not matter + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=False + ) + + self.assertEqual("0.1.15", str(next_version)) + + def test_04_auto_increment_pre_release(self): + local_v = parse("0.1.0") + + # auto increment standard situation + pypi_v = parse("0.1.14") + test_v = parse("0.1.15rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) + + self.assertEqual("0.1.15rc6", str(next_version)) + + # auto increment, test.pypi is behind + pypi_v = parse("0.1.14") + test_v = parse("0.1.12rc5") + + next_version = _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) + + self.assertEqual("0.1.15rc1", str(next_version)) + + # auto increment, test.pypi is far ahead + pypi_v = parse("0.1.14") + test_v = parse("0.1.17rc5") + + # in the case of pre-release, the test version does matter + with self.assertRaises(InvalidVersionManipulation): + _decide_next_version( + pypi_v=pypi_v, test_v=test_v, local_v=local_v, pre_release=True + ) From 9b158a70d675ca4ee44ddf8d9e667fc0e15759c5 Mon Sep 17 00:00:00 2001 From: set Date: Thu, 23 Mar 2023 14:14:44 +0000 Subject: [PATCH 4/4] formatting --- src/spetlrtools/manipulate_version.py | 4 +--- tests/unit/test_manipulate_version.py | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/spetlrtools/manipulate_version.py b/src/spetlrtools/manipulate_version.py index 014433d..1356271 100644 --- a/src/spetlrtools/manipulate_version.py +++ b/src/spetlrtools/manipulate_version.py @@ -1,12 +1,10 @@ import argparse import configparser -import json import sys from typing import IO -from urllib.request import urlopen import requests -from packaging.version import parse, Version +from packaging.version import Version, parse def main(): diff --git a/tests/unit/test_manipulate_version.py b/tests/unit/test_manipulate_version.py index 99b8464..31e9245 100644 --- a/tests/unit/test_manipulate_version.py +++ b/tests/unit/test_manipulate_version.py @@ -1,10 +1,11 @@ import unittest +from packaging.version import parse + from spetlrtools.manipulate_version import ( - _decide_next_version, InvalidVersionManipulation, + _decide_next_version, ) -from packaging.version import parse class VersioningTest(unittest.TestCase):