Skip to content

packaging.version as a CLI tool#795

Open
mgaitan wants to merge 8 commits intopypa:mainfrom
mgaitan:version_cli
Open

packaging.version as a CLI tool#795
mgaitan wants to merge 8 commits intopypa:mainfrom
mgaitan:version_cli

Conversation

@mgaitan
Copy link

@mgaitan mgaitan commented Apr 16, 2024

I want to propose this simple CLI tool in the packaging.version module to perform semantic version comparisons directly from the command line. This utility is modeled after dpkg --compare-versions but designed to be platform-agnostic, filling a gap for non-Debian users and providing a Python-native solution.

Version comparison is a common requirement in deployment scripts, package management, and development workflows. Currently, developers resort to complex shell scripts or third-party tools to compare versions. Examples of community solutions include intricate bash functions and snippets found on Stack Overflow and GitHub Gists that, while functional for basic cases, vary in reliability and can be unnecessarily complex 1, 2, 3.

My proposal leverages the robustness of the packaging.version.Version class for parsing and comparing semantic versions, supporting standard comparison operators (e.g., lt, gt, eq) and provide straightforward syntax that resembles the mentioned dpkg command

Usage

$ python -m packaging.version --help
usage: version.py [-h] version1 {lt,le,eq,ne,ge,gt,lt-nl,le-nl,ge-nl,gt-nl,<,<<,<=,=,>=,>>,>} version2

Compare two semantic versions.

positional arguments:
  version1              First version to compare
  {lt,le,eq,ne,ge,gt,lt-nl,le-nl,ge-nl,gt-nl,<,<<,<=,=,>=,>>,>}
                        Comparison operator
  version2              Second version to compare

options:
  -h, --help            show this help message and exit

$ python -m packaging.version 1.0b gt 0.9   
$ echo $?
0

$ python -m packaging.version 1.0b eq 0.9   # Should exit with status 1
$ echo $?
1

$ python -m packaging.version 1.0b foo 0.9
usage: version.py [-h] version1 {lt,le,eq,ne,ge,gt,lt-nl,le-nl,ge-nl,gt-nl,<,<<,<=,=,>=,>>,>} version2
version.py: error: argument operator: invalid choice: 'foo' (choose from 'lt', 'le', 'eq', 'ne', 'ge', 'gt', 'lt-nl', 'le-nl', 'ge-nl', 'gt-nl', '<', '<<', '<=', '=', '>=', '>>', '>')
$ echo $?
2

$ python -m packaging.version non-version eq 0.9
usage: version.py [-h] version1 {lt,le,eq,ne,ge,gt,lt-nl,le-nl,ge-nl,gt-nl,<,<<,<=,=,>=,>>,>} version2
version.py: error: argument version1: invalid Version value: 'non-version'
$ echo $?
2

@brettcannon
Copy link
Member

What does "nl" mean for those variant comparators? And why do they mean the left-hand side is less than the right-hand no matter what? And what no e.g., eq-nl? And why both e.g., lt and < supported?

@mgaitan
Copy link
Author

mgaitan commented Sep 9, 2024

Hi @brettcannon,

The "nl" in those variant comparators comes from dpkg --compare-versions, which I originally intended to support as a drop-in replacement. From the manpage:

There are two groups of operators, which differ in how they treat an empty ver1 or ver2. These treat an empty version as earlier than any version: lt le eq ne ge gt. These treat an empty version as later than any version: lt-nl le-nl ge-nl gt-nl.

Regarding the lack of an eq-nl operator, it's likely because version equality doesn't depend on whether one of the versions is empty—if both versions are non-empty, eq handles it naturally.

However, I decided to remove support for *-nl in this tool because the version arguments are mandatory. If an invalid version is provided, the tool will exit with code 2. If needed, this can still be converted to exit code 0 using shell tools.

I also removed the non-textual operators <, <<, <=, =, >=, >>, >.

So, while the tool is still inspired by dpkg, it’s not a compatible API anymore, but it is simpler and still useful.

@uranusjr
Copy link
Member

I don’t really mind, but aren’t non-textual operators more readable than lt ge etc?

@henryiii
Copy link
Contributor

henryiii commented Feb 12, 2026

I feel if we do this we should have a subcommand compare, since you also might want to use it to normalize a version, for example. Also needs tests (and docs, but that could just be the --help output rendered into the docs).

henryiii and others added 2 commits February 12, 2026 21:47
Updated the argument parser description and replaced sys.exit with raise SystemExit.
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
@henryiii
Copy link
Contributor

henryiii commented Feb 13, 2026

I also used AI in CoPilot to add a CLI output (for a followup). It took a few prompts to get the 3.14+ color & dark mode looking nice:

Followup PR could be based on:
diff --git a/docs/conf.py b/docs/conf.py
index 9419c41..bc034f7 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -3,6 +3,10 @@
 # for complete details.

 import os
+import re
+import subprocess
+from docutils import nodes
+from docutils.parsers.rst import Directive

 # -- Project information loading ----------------------------------------------

@@ -104,3 +108,111 @@ intersphinx_mapping = {
     "python": ("https://docs.python.org/3/", None),
     "pypug": ("https://packaging.python.org/", None),
 }
+
+
+# -- Custom directives --------------------------------------------------------
+
+
+def ansi_to_html(text):
+    """Convert ANSI color codes to HTML."""
+    # ANSI color code to HTML color mapping
+    ansi_colors = {
+        "30": "#000000",  # black
+        "31": "#dc3545",  # red
+        "32": "#28a745",  # green
+        "33": "#ffc107",  # yellow
+        "34": "#007bff",  # blue
+        "35": "#6f42c1",  # magenta
+        "36": "#17a2b8",  # cyan
+        "37": "#f8f9fa",  # white
+        "90": "#6c757d",  # bright black (gray)
+        "91": "#ff6b6b",  # bright red
+        "92": "#51cf66",  # bright green
+        "93": "#ffd43b",  # bright yellow
+        "94": "#4d7fff",  # bright blue
+        "95": "#da77f2",  # bright magenta
+        "96": "#15aabf",  # bright cyan
+        "97": "#ffffff",  # bright white
+    }
+
+    # Pattern to match ANSI escape sequences
+    ansi_escape_pattern = re.compile(r"\x1b\[([0-9;]*)m")
+
+    def replace_ansi(match):
+        codes = match.group(1).split(";") if match.group(1) else ["0"]
+        html_parts = []
+
+        for code in codes:
+            if code == "0":
+                # Reset
+                return "</span>"
+            elif code == "1":
+                # Bold
+                html_parts.append('<span style="font-weight: bold;">')
+            elif code in ansi_colors:
+                # Foreground color
+                color = ansi_colors[code]
+                html_parts.append(f'<span style="color: {color};">')
+
+        return "".join(html_parts) if html_parts else ""
+
+    # Replace ANSI codes with HTML
+    html = ansi_escape_pattern.sub(replace_ansi, text)
+
+    # Clean up any remaining unclosed spans
+    html = html.replace("</span></span>", "</span>")
+
+    return html
+
+
+class ShowCliDirective(Directive):
+    """Include the output of a CLI command in the documentation."""
+
+    has_content = False
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+
+    def run(self):
+        """Execute the command and return its output as a code block."""
+        command = self.arguments[0]
+        try:
+            # Run the command and capture output
+            result = subprocess.run(
+                command,
+                shell=True,
+                capture_output=True,
+                text=True,
+                cwd=_BASE_DIR,
+                env={**os.environ, "FORCE_COLOR": "1"},
+            )
+            output = result.stdout or result.stderr
+        except Exception as e:
+            return [
+                nodes.error(
+                    None,
+                    nodes.paragraph(text=f"Error running command: {command}\n{e}"),
+                )
+            ]
+
+        # Convert ANSI codes to HTML
+        html_output = ansi_to_html(output)
+
+        # Create a raw HTML node with the colored output
+        raw_html = nodes.raw("", html_output, format="html")
+        literal_block = nodes.literal_block(output, raw_html)
+        literal_block["language"] = "text"
+
+        # Return as a container with pre styling
+        container = nodes.container()
+        container += nodes.raw(
+            "",
+            f'<pre style="background-color: var(--color-background-secondary, #f5f5f5); color: var(--color-foreground-primary, inherit); padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 0.9em; font-weight: normal; line-height: 1.4;">{html_output}</pre>',
+            format="html",
+        )
+        return [container]
+
+
+def setup(app):
+    """Register custom directives."""
+    app.add_directive("show-cli", ShowCliDirective)
diff --git a/docs/version.rst b/docs/version.rst
index 2adf336..7210a3d 100644
--- a/docs/version.rst
+++ b/docs/version.rst
@@ -46,9 +46,21 @@ Usage
     True


+Command Line Interface
+----------------------
+
+The ``packaging.version`` module can be used as a command-line tool:
+
+.. show-cli:: python -m packaging.version --help
+
+You can compare two versions:
+
+.. show-cli:: python -m packaging.version compare --help
+
 Reference
 ---------

 .. automodule:: packaging.version
     :members:
     :special-members:
Screenshot 2026-02-13 at 2 16 58 PM Screenshot 2026-02-13 at 2 17 16 PM

Edit: I have a better version of this (without the bold bug) in scikit-build/scikit-build-core#1218.

@henryiii henryiii force-pushed the version_cli branch 2 times, most recently from 0f002ce to 28eab80 Compare February 14, 2026 15:33
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

Comments