Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
Client picks 0.11.0a2 over 0.11.0 when using the ` --task-name` option without a version specified
The current 1.3.4 client defaults to using the 0.11.0a2 pre-release tasks over the 0.11.0 released tasks when the task is specified as e.g. `fractal --batch workflow add-task $PRJ_ID $WF_ID --task-name "Convert Yokogawa to OME-Zarr"` 

I have removed the .cache folder, the behavior remains the same. It would be great to improve the heuristics of task version picking for the CLI client, so that it always picks the most recent available task without having to use the `--task-version` option


Discussion / hints:
This is the relevant line:
https://github.com/fractal-analytics-platform/fractal/blob/main/fractal/cmd/_aux_task_caching.py#L156C1-L156C80
together with
```python
Python 3.10.12 (main, Jun 11 2023, 05:26:28) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> "0.11.0" > "0.11.0a2"
False
```

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f77a54078..f0cb027c7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
     hooks:
     -   id: black
         args: [--line-length=79]
--   repo: https://gitlab.com/PyCQA/flake8
+-   repo: https://github.com/PyCQA/flake8
     rev: 3.9.2
     hooks:
     -   id: flake8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f7331bb3..c7f4ace21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 **Note**: Numbers like (\#123) point to closed Pull Requests on the fractal repository.
 
+# 1.3.5
+
+* Implement more robust sorting of versions (e.g. in the presence of pre-releases), via `packaging.version` (\#550).
+
 # 1.3.4
 
 * Add new commands `dataset status` and `dataset history` (\#544).
diff --git a/fractal/cmd/_aux_task_caching.py b/fractal/cmd/_aux_task_caching.py
index 8c433d1ae..38ef9c39d 100644
--- a/fractal/cmd/_aux_task_caching.py
+++ b/fractal/cmd/_aux_task_caching.py
@@ -3,6 +3,8 @@
 from typing import Any
 from typing import Optional
 
+import packaging.version
+
 from ..authclient import AuthClient
 from ..config import settings
 from ..response import check_response
@@ -10,6 +12,27 @@
 TASKS_CACHE_FILENAME = "tasks"
 
 
+def _loose_version_parse(v: str) -> packaging.version.Version:
+    """
+    Catch `InvalidVersion` error and return `Version("0")`.
+
+    This function is used in the comparison of different version strings. If a
+    version cannot be parsed correctly, then it should not be considered the
+    "latest"; we obtain this behavior by returning the "0" version when
+    version-string parsing fails.
+
+    Args:
+        v: Version string (e.g. `0.10.0a2`).
+
+    Returns:
+        A `Version` object, parsed with `packaging.version.parse`
+    """
+    try:
+        return packaging.version.parse(v)
+    except packaging.version.InvalidVersion:
+        return packaging.version.parse("0")
+
+
 class FractalCacheError(RuntimeError):
     """
     Custom error raised by functions of this module
@@ -153,7 +176,10 @@ def _search_in_task_list(
                     f"task list:\n{formatted_matching_task_list}"
                     "Please make your request more specific.\n"
                 )
-            max_version = max(_task["version"] for _task in matching_task_list)
+            available_versions = [
+                _task["version"] for _task in matching_task_list
+            ]
+            max_version = max(available_versions, key=_loose_version_parse)
             max_version_tasks = [
                 _task
                 for _task in matching_task_list
diff --git a/poetry.lock b/poetry.lock
index d60c9c697..d2f20d073 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2051,4 +2051,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "2c785939aa434059ebd63560ed0832355451beaa34324258fe29bcecd49f8ee0"
+content-hash = "afd5bd6d425275b9eb617189369ae2130444554a3d1e9df250e3244bb920c9c5"
diff --git a/pyproject.toml b/pyproject.toml
index f60b72ae0..e9ad1f5c8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,7 @@ httpx = "^0.23.0"
 PyJWT = "^2.4.0"
 pydantic = ">=1.10.8,<2"
 fastapi-users = "^10.4.1"
+packaging = "^23.1"
 
 [tool.poetry.group.dev.dependencies]
 devtools = "^0.9.0"


Test patch:
diff --git a/tests/test_unit_versions.py b/tests/test_unit_versions.py
new file mode 100644
index 000000000..9dc3eeecb
--- /dev/null
+++ b/tests/test_unit_versions.py
@@ -0,0 +1,67 @@
+import pytest
+from devtools import debug
+from packaging import version
+
+from fractal.cmd._aux_task_caching import _loose_version_parse
+
+
+# string, major, minor, micro, is_prerelease
+VERSIONS = [
+    ("0.10.0c0", 0, 10, 0, True),
+    ("0.10.0b4", 0, 10, 0, True),
+    ("0.10.0", 0, 10, 0, False),
+    ("0.10.0alpha3", 0, 10, 0, True),
+    ("0.10.0a2", 0, 10, 0, True),
+    ("1.0.0", 1, 0, 0, False),
+    ("0.10.0a0", 0, 10, 0, True),
+    ("1.0.0rc4.dev7", 1, 0, 0, True),
+    ("0.10.0beta5", 0, 10, 0, True),
+    ("0.10.0alpha0", 0, 10, 0, True),
+    ("3.2", 3, 2, 0, False),
+    ("2", 2, 0, 0, False),
+]
+
+SORTED_VERSIONS = [
+    "0.10.0a0",
+    "0.10.0alpha0",
+    "0.10.0a2",
+    "0.10.0alpha3",
+    "0.10.0b4",
+    "0.10.0beta5",
+    "0.10.0c0",
+    "0.10.0",
+    "1.0.0rc4.dev7",
+    "1.0.0",
+    "2",
+    "3.2",
+]
+
+
+def test_version_parsing():
+    for (v_string, major, minor, micro, is_prerelease) in VERSIONS:
+        v = version.parse(v_string)
+        debug(v_string, v.major, v.minor, v.micro, v.is_prerelease, v.pre)
+        if major is not None:
+            assert v.major == major
+        if minor is not None:
+            assert v.minor == minor
+        if micro is not None:
+            assert v.micro == micro
+        if is_prerelease is not None:
+            assert v.is_prerelease == is_prerelease
+
+    with pytest.raises(version.InvalidVersion):
+        version.parse("invalid")
+
+
+def test_version_sorting():
+    sorted_versions = sorted([v[0] for v in VERSIONS], key=version.parse)
+    debug(sorted_versions)
+    assert sorted_versions == SORTED_VERSIONS
+
+
+def test_max_with_loose_version_parse():
+    versions = ["invalid_1"] + [v[0] for v in VERSIONS] + ["invalid_2"]
+    sorted_versions = sorted(versions, key=_loose_version_parse)
+    debug(sorted_versions)
+    assert sorted_versions == ["invalid_1", "invalid_2"] + SORTED_VERSIONS