Skip to content

Commit 6c2e939

Browse files
committed
refactor: extract _normalise_version and _directory_label from export_updateSoftware
Extract the version validation/normalisation logic and the filesystem directory-label derivation into two module-level helper functions (_normalise_version, _directory_label) so they can be unit-tested directly without standing up a full DIRAC service. Update the unit tests to import and exercise the real production functions instead of maintaining local mirror implementations.
1 parent 6a7640e commit 6c2e939

2 files changed

Lines changed: 140 additions & 122 deletions

File tree

src/DIRAC/FrameworkSystem/Service/SystemAdministratorHandler.py

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,92 @@ def loadDIRACCFG():
4747
return S_OK((cfgPath, diracCFG))
4848

4949

50+
def _normalise_version(version):
51+
"""Validate and normalise a raw version string supplied by the operator.
52+
53+
Parameters
54+
----------
55+
version:
56+
Raw string as received from the client (may contain surrounding
57+
whitespace or use the spaced ``pkg @ url`` pip syntax).
58+
59+
Returns
60+
-------
61+
tuple(str, str | None, bool, bool)
62+
``(version, primaryExtension, released_version, isPrerelease)``
63+
64+
- *version* – normalised version string ready to be passed to pip.
65+
- *primaryExtension* – package name when the caller used
66+
``extension==version`` syntax; ``None`` otherwise.
67+
- *released_version* – ``True`` when installing a PEP 440 release,
68+
``False`` when installing from a VCS URL.
69+
- *isPrerelease* – ``True`` when the PEP 440 version is a pre-release.
70+
71+
Raises
72+
------
73+
ValueError
74+
When the version string is empty or not a valid PEP 440 version and
75+
does not contain a recognised VCS URL.
76+
"""
77+
version = version.strip()
78+
if not version:
79+
raise ValueError("No version specified")
80+
81+
primaryExtension = None
82+
if "==" in version:
83+
primaryExtension, version = version.split("==", 1)
84+
85+
released_version = True
86+
isPrerelease = False
87+
88+
# Special aliases: install DIRAC from the integration branch
89+
if version.lower() in ("integration", "devel", "master", "main"):
90+
released_version = False
91+
version = "DIRAC[server] @ git+https://github.com/DIRACGrid/DIRAC.git@integration"
92+
return version, primaryExtension, released_version, isPrerelease
93+
94+
# Try to parse as a PEP 440 version number
95+
try:
96+
parsed = Version(version)
97+
isPrerelease = parsed.is_prerelease
98+
version = f"v{parsed}"
99+
except InvalidVersion:
100+
if "https://" in version:
101+
# Treat as a VCS URL (e.g. "DIRAC[server] @ git+https://...")
102+
released_version = False
103+
else:
104+
raise ValueError(f"Invalid version passed {version!r}")
105+
106+
return version, primaryExtension, released_version, isPrerelease
107+
108+
109+
def _directory_label(version, released_version):
110+
"""Derive the filesystem directory label for a given version.
111+
112+
For released versions this is the version string itself. For VCS URLs
113+
(pip ``pkg @ url`` syntax) it is the URL part, stripped of any
114+
``#egg=...`` fragment and surrounding whitespace.
115+
116+
Parameters
117+
----------
118+
version:
119+
Normalised version string as returned by :func:`_normalise_version`.
120+
released_version:
121+
``True`` when *version* is a PEP 440 release string.
122+
123+
Returns
124+
-------
125+
str
126+
A filesystem-safe label derived from *version*.
127+
"""
128+
if released_version:
129+
return version
130+
# version is "pkg @ git+https://host/repo.git@branch"
131+
# Split on the *first* "@" (the pip separator) only, then strip spaces
132+
# and drop any "#egg=..." fragment so the branch name is preserved.
133+
return version.split("@", 1)[1].strip().split("#")[0]
134+
135+
50136
class SystemAdministratorHandler(RequestHandler):
51137
@classmethod
52138
def initializeHandler(cls, serviceInfo):
@@ -262,35 +348,11 @@ def export_updateSoftware(self, version):
262348
- a git tag/branch like "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
263349
"""
264350
# Validate and normalise the requested version
265-
# Strip surrounding whitespace and collapse any internal spaces around "@"
266-
# so that both "DIRAC[server] @ git+https://..." and
267-
# "DIRAC[server]@git+https://..." are accepted.
268-
version = version.strip()
269-
if not version:
270-
return S_ERROR("No version specified")
271-
primaryExtension = None
272-
if "==" in version:
273-
primaryExtension, version = version.split("==")
274-
275-
released_version = True
276-
isPrerelease = False
277-
278-
# Special cases (e.g. installing the integration/main branch)
279-
if version.lower() in ["integration", "devel", "master", "main"]:
280-
released_version = False
281-
version = "DIRAC[server] @ git+https://github.com/DIRACGrid/DIRAC.git@integration"
282-
283-
if released_version:
284-
try:
285-
version = Version(version)
286-
isPrerelease = version.is_prerelease
287-
version = f"v{version}"
288-
except InvalidVersion:
289-
if "https://" in version:
290-
released_version = False
291-
else:
292-
self.log.exception("Invalid version passed", version)
293-
return S_ERROR(f"Invalid version passed {version!r}")
351+
try:
352+
version, primaryExtension, released_version, isPrerelease = _normalise_version(version)
353+
except ValueError as e:
354+
self.log.exception("Invalid version passed", version)
355+
return S_ERROR(str(e))
294356

295357
# Find what to install
296358
otherExtensions = []
@@ -316,7 +378,7 @@ def export_updateSoftware(self, version):
316378
installer.flush()
317379
self.log.info("Downloaded DIRACOS installer to", installer.name)
318380

319-
directory = version if released_version else version.split("@", 1)[1].strip().split("#")[0]
381+
directory = _directory_label(version, released_version)
320382
newProPrefix = os.path.join(
321383
rootPath,
322384
"versions",

src/DIRAC/FrameworkSystem/Service/test/test_SystemAdministratorHandler.py

Lines changed: 48 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,18 @@
1-
"""Unit tests for SystemAdministratorHandler version normalisation logic
2-
and the SystemAdministratorClientCLI do_update input validation.
1+
"""Unit tests for SystemAdministratorHandler helper functions and
2+
the SystemAdministratorClientCLI do_update input validation.
33
"""
44

55
import pytest
66
from unittest.mock import MagicMock, patch
77

8-
9-
# ---------------------------------------------------------------------------
10-
# Helpers – replicate the version-normalisation logic from
11-
# SystemAdministratorHandler.export_updateSoftware so we can test it
12-
# without standing up a full DIRAC service.
13-
# ---------------------------------------------------------------------------
14-
15-
16-
def _normalise_version(version):
17-
"""Mirror the normalisation applied at the top of export_updateSoftware.
18-
19-
Returns the normalised version string, or raises ValueError if invalid.
20-
Mirrors the side-effects relevant to the directory-name derivation and
21-
the pip command construction.
22-
"""
23-
from packaging.version import Version, InvalidVersion
24-
25-
version = version.strip()
26-
if not version:
27-
raise ValueError("No version specified")
28-
29-
released_version = True
30-
isPrerelease = False
31-
32-
if version.lower() in ["integration", "devel", "master", "main"]:
33-
released_version = False
34-
version = "DIRAC[server] @ git+https://github.com/DIRACGrid/DIRAC.git@integration"
35-
36-
if released_version:
37-
try:
38-
parsed = Version(version)
39-
isPrerelease = parsed.is_prerelease
40-
version = f"v{parsed}"
41-
except InvalidVersion:
42-
if "https://" in version:
43-
released_version = False
44-
else:
45-
raise ValueError(f"Invalid version passed {version!r}")
46-
47-
return version, released_version, isPrerelease
48-
49-
50-
def _directory_from_version(version, released_version):
51-
"""Mirror the directory-name derivation in export_updateSoftware.
52-
53-
Split on the *first* "@" only (the pip package @ URL separator), strip
54-
whitespace, then drop any "#egg=..." fragment.
55-
"""
56-
if released_version:
57-
return version
58-
return version.split("@", 1)[1].strip().split("#")[0]
8+
from DIRAC.FrameworkSystem.Service.SystemAdministratorHandler import (
9+
_directory_label,
10+
_normalise_version,
11+
)
5912

6013

6114
# ---------------------------------------------------------------------------
62-
# Tests: version normalisation
15+
# Tests: _normalise_version
6316
# ---------------------------------------------------------------------------
6417

6518

@@ -73,72 +26,80 @@ def test_whitespace_only_raises(self):
7326
_normalise_version(" ")
7427

7528
def test_released_version(self):
76-
version, released, pre = _normalise_version("9.0.18")
29+
version, primary, released, pre = _normalise_version("9.0.18")
7730
assert released is True
7831
assert pre is False
7932
assert version == "v9.0.18"
33+
assert primary is None
8034

8135
def test_released_prerelease_version(self):
82-
version, released, pre = _normalise_version("9.0.18a1")
36+
version, primary, released, pre = _normalise_version("9.0.18a1")
8337
assert released is True
8438
assert pre is True
8539
assert version == "v9.0.18a1"
8640

41+
def test_extension_syntax_splits_primary(self):
42+
version, primary, released, pre = _normalise_version("MyExtension==9.0.18")
43+
assert primary == "MyExtension"
44+
assert version == "v9.0.18"
45+
assert released is True
46+
8747
@pytest.mark.parametrize("keyword", ["integration", "devel", "master", "main"])
8848
def test_special_keywords(self, keyword):
89-
version, released, pre = _normalise_version(keyword)
49+
version, primary, released, pre = _normalise_version(keyword)
9050
assert released is False
51+
assert pre is False
9152
assert "DIRACGrid/DIRAC" in version
9253
assert "@integration" in version
9354

9455
def test_git_url_without_spaces(self):
9556
raw = "DIRAC[server]@git+https://github.com/fstagni/DIRAC.git@test_branch"
96-
version, released, pre = _normalise_version(raw)
57+
version, primary, released, pre = _normalise_version(raw)
9758
assert released is False
9859
assert version == raw
9960

10061
def test_git_url_with_spaces_around_at(self):
101-
"""The CLI now sends the raw user input; leading/trailing spaces must be stripped."""
102-
raw = " DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch "
103-
version, released, pre = _normalise_version(raw)
62+
"""Leading/trailing whitespace is stripped; internal pip spaces are kept."""
63+
raw = "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
64+
version, primary, released, pre = _normalise_version(f" {raw} ")
10465
assert released is False
105-
# Internal spaces around "@" are preserved (pip accepts them)
106-
assert version.strip() == raw.strip()
66+
assert version == raw
10767

10868
def test_invalid_version_no_url_raises(self):
10969
with pytest.raises(ValueError, match="Invalid version passed"):
11070
_normalise_version("not-a-valid-version")
11171

11272

11373
# ---------------------------------------------------------------------------
114-
# Tests: directory derivation from version string
74+
# Tests: _directory_label
11575
# ---------------------------------------------------------------------------
11676

11777

118-
class TestDirectoryFromVersion:
78+
class TestDirectoryLabel:
11979
def test_released_version_uses_version_directly(self):
120-
d = _directory_from_version("v9.0.18", released_version=True)
121-
assert d == "v9.0.18"
80+
assert _directory_label("v9.0.18", released_version=True) == "v9.0.18"
12281

12382
def test_git_url_without_spaces(self):
124-
"""No spaces around '@' separator — branch name must be included."""
83+
"""Branch name after the second '@' must be preserved."""
12584
version = "DIRAC[server]@git+https://github.com/fstagni/DIRAC.git@test_branch"
126-
d = _directory_from_version(version, released_version=False)
127-
# Split on first "@" → "git+https://github.com/fstagni/DIRAC.git@test_branch"
128-
assert d == "git+https://github.com/fstagni/DIRAC.git@test_branch"
85+
assert (
86+
_directory_label(version, released_version=False) == "git+https://github.com/fstagni/DIRAC.git@test_branch"
87+
)
12988

130-
def test_git_url_with_spaces_around_at_separator(self):
131-
"""Spaces around the pip '@' separator must be stripped; branch part kept."""
132-
# Simulate what the handler receives after version.strip()
89+
def test_git_url_with_spaces_around_pip_separator(self):
90+
"""Spaces around the pip '@' separator are stripped; branch part kept."""
13391
version = "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
134-
d = _directory_from_version(version, released_version=False)
135-
assert d == "git+https://github.com/fstagni/DIRAC.git@test_branch"
92+
assert (
93+
_directory_label(version, released_version=False) == "git+https://github.com/fstagni/DIRAC.git@test_branch"
94+
)
13695

13796
def test_git_url_with_hash_fragment(self):
138-
"""#egg= fragment must be stripped from directory name."""
97+
"""#egg= fragment must be stripped from the directory label."""
13998
version = "DIRAC[server]@git+https://github.com/DIRACGrid/DIRAC.git@integration#egg=DIRAC"
140-
d = _directory_from_version(version, released_version=False)
141-
assert d == "git+https://github.com/DIRACGrid/DIRAC.git@integration"
99+
assert (
100+
_directory_label(version, released_version=False)
101+
== "git+https://github.com/DIRACGrid/DIRAC.git@integration"
102+
)
142103

143104

144105
# ---------------------------------------------------------------------------
@@ -150,15 +111,12 @@ class TestDoUpdate:
150111
"""Test SystemAdministratorClientCLI.do_update input validation."""
151112

152113
def _make_cli(self):
153-
from DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI import (
154-
SystemAdministratorClientCLI,
155-
)
114+
from DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI import SystemAdministratorClientCLI
156115

157-
with patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.SystemAdministratorClient"):
158-
cli = SystemAdministratorClientCLI.__new__(SystemAdministratorClientCLI)
159-
cli.host = "localhost"
160-
cli.port = 9162
161-
return cli
116+
cli = SystemAdministratorClientCLI.__new__(SystemAdministratorClientCLI)
117+
cli.host = "localhost"
118+
cli.port = 9162
119+
return cli
162120

163121
def test_empty_args_prints_usage_and_returns(self):
164122
cli = self._make_cli()
@@ -169,12 +127,10 @@ def test_empty_args_prints_usage_and_returns(self):
169127
patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.gLogger") as mock_logger,
170128
):
171129
cli.do_update("")
172-
# Client must NOT be contacted
173130
mock_client_cls.assert_not_called()
174-
# Usage should be printed
175131
assert mock_logger.notice.called
176132

177-
def test_whitespace_only_args_prints_usage_and_returns(self):
133+
def test_whitespace_only_args_does_not_contact_server(self):
178134
cli = self._make_cli()
179135
with (
180136
patch(
@@ -202,8 +158,8 @@ def test_valid_version_calls_client(self):
202158
mock_client_cls.assert_called_once_with(cli.host, cli.port)
203159
mock_instance.updateSoftware.assert_called_once_with("9.0.18", timeout=600)
204160

205-
def test_git_url_with_spaces_passes_stripped_version(self):
206-
"""Spaces are stripped from the outer edges but the version body is preserved."""
161+
def test_git_url_with_spaces_passes_full_version_to_server(self):
162+
"""The version body (including internal spaces) is forwarded as-is."""
207163
cli = self._make_cli()
208164
with (
209165
patch(

0 commit comments

Comments
 (0)