Skip to content

Commit 7c4f0fe

Browse files
committed
feat(types): harden product version with validated ProductVersion type
Motivation: Product version extraction used plain strings and empty strings for missing values, causing ambiguity and potential errors. PR openSUSE#429 feedback requested stricter definitions and better type safety. Design Choices: - Introduced ProductVersion string subclass in types.py with regex-based validation in __new__. - Refactored gitea.py to return ProductVersion | None instead of "". - Moved VERSION_EXTRACT_REGEX to types.py to centralize logic. - Updated extraction logic to use fullmatch for stricter validation. - Updated dependent code to use explicit is not None checks. User Benefits: - Improved type safety and runtime validation of product versions. - Clearer API contracts with Optional types. - Centralized version parsing logic improves maintainability.
1 parent 3125fad commit 7c4f0fe

File tree

5 files changed

+75
-25
lines changed

5 files changed

+75
-25
lines changed

openqabot/loader/gitea.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from collections.abc import Iterator
3535

3636
from openqabot.types.pullrequest import PullRequest
37-
from openqabot.types.types import Repos
37+
from openqabot.types.types import VERSION_EXTRACT_REGEX, ProductVersion, Repos
3838

3939
ARCHS = {"x86_64", "aarch64", "ppc64le", "s390x"}
4040

@@ -58,7 +58,6 @@ class BuildResults:
5858

5959
PROJECT_PRODUCT_REGEX = re.compile(r".*:PullRequest:\d+:(.*)")
6060
SCMSYNC_REGEX = re.compile(r".*/products/(.*)#([\d\.]{2,6})$")
61-
VERSION_EXTRACT_REGEX = re.compile(r"[.\d]+")
6261
OBS_PROJECT_SHOW_REGEX = re.compile(r".*/project/show/([^/\s\?\#\)]+)")
6362
# Regex to find all HTTPS URLs, excluding common trailing punctuation like dots or parentheses
6463
# that are likely part of the surrounding text (e.g. at the end of a sentence or in Markdown).
@@ -162,10 +161,10 @@ def get_product_name(obs_project: str) -> str:
162161
return product_match.group(1) if product_match else ""
163162

164163

165-
def get_product_name_and_version_from_scmsync(scmsync_url: str) -> tuple[str, str]:
164+
def get_product_name_and_version_from_scmsync(scmsync_url: str) -> tuple[str, ProductVersion | None]:
166165
"""Extract product name and version from an scmsync URL."""
167166
m = SCMSYNC_REGEX.search(scmsync_url)
168-
return (m.group(1), m.group(2)) if m else ("", "")
167+
return (m.group(1), ProductVersion(m.group(2))) if m else ("", None)
169168

170169

171170
def compute_repo_url_for_job_setting(
@@ -291,14 +290,17 @@ def add_reviews(submission: dict[str, Any], reviews: list[Any]) -> int:
291290
return len(qam_states)
292291

293292

294-
def _extract_version(name: str, prefix: str) -> str:
293+
def _extract_version(name: str, prefix: str) -> ProductVersion | None:
295294
"""Extract version number from a package name string."""
296295
remainder = name.removeprefix(prefix)
297-
return next((part for part in remainder.split("-") if VERSION_EXTRACT_REGEX.search(part)), "")
296+
return next(
297+
(ProductVersion(part) for part in remainder.split("-") if VERSION_EXTRACT_REGEX.fullmatch(part)),
298+
None,
299+
)
298300

299301

300302
@lru_cache(maxsize=512)
301-
def get_product_version_from_repo_listing(project: str, product_name: str, repository: str) -> str:
303+
def get_product_version_from_repo_listing(project: str, product_name: str, repository: str) -> ProductVersion | None:
302304
"""Determine the product version by inspecting an OBS repository listing."""
303305
project_path = project.replace(":", ":/")
304306
url = f"{config.settings.obs_download_url}/{project_path}/{repository}/repo?jsontable"
@@ -309,34 +311,34 @@ def get_product_version_from_repo_listing(project: str, product_name: str, repos
309311
data = r.json()["data"]
310312
except requests.exceptions.HTTPError as e:
311313
log.warning("Repo ignored: Could not query repository '%s' (%s->%s): %s", repository, product_name, project, e)
312-
return ""
314+
return None
313315
# Catching both because requests' JSONDecodeError might not inherit from json's
314316
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
315317
log.info("Invalid JSON document at '%s', ignoring: %s", url, e)
316-
return ""
318+
return None
317319
except requests.exceptions.RequestException as e:
318320
log.warning("Product version unresolved: Could not read from '%s': %s", url, e)
319-
return ""
321+
return None
320322
versions = (_extract_version(entry["name"], start) for entry in data if entry["name"].startswith(start))
321-
return next((v for v in versions if len(v) > 0), "")
323+
return next((v for v in versions if v is not None), None)
322324

323325

324-
def _get_product_version(res: etree._Element, project: str, product_name: str) -> str:
326+
def _get_product_version(res: etree._Element, project: str, product_name: str) -> ProductVersion | None:
325327
"""Extract product version from scmsync element or repository listing."""
326328
# read product version from scmsync element if possible, e.g. 15.99
327329
product_version = next(
328330
(
329331
pv
330-
for _, pv in (get_product_name_and_version_from_scmsync(e.text) for e in res.findall("scmsync"))
331-
if len(pv) > 0
332+
for _, pv in (get_product_name_and_version_from_scmsync(e.text) for e in res.findall("scmsync") if e.text)
333+
if pv is not None
332334
),
333-
"",
335+
None,
334336
)
335337

336338
# read product version from directory listing if the project is for a concrete product
337339
if (
338-
len(product_name) != 0
339-
and len(product_version) == 0
340+
product_name
341+
and product_version is None
340342
and ("all" in config.settings.obs_products_set or product_name in config.settings.obs_products_set)
341343
):
342344
product_version = get_product_version_from_repo_listing(project, product_name, res.get("repository"))
@@ -359,9 +361,9 @@ def add_channel_for_build_result(
359361
product_version = _get_product_version(res, project, product_name)
360362

361363
# append product version to channel if known; otherwise skip channel if this is for a concrete product
362-
if len(product_version) > 0:
364+
if product_version is not None:
363365
channel = f"{channel}#{product_version}"
364-
elif len(product_name) > 0:
366+
elif product_name:
365367
log.debug("Channel skipped: Product version for build result %s:%s could not be determined", project, arch)
366368
return channel
367369

openqabot/types/types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,27 @@
44

55
from __future__ import annotations
66

7+
import re
78
from enum import Enum, auto
89
from typing import NamedTuple
910

1011
from openqabot.config import OBS_REPO_TYPE
1112

13+
VERSION_EXTRACT_REGEX = re.compile(r"[.\d]+")
14+
15+
16+
class ProductVersion(str): # noqa: FURB189
17+
"""A validated product version string."""
18+
19+
__slots__ = ()
20+
21+
def __new__(cls, value: str) -> ProductVersion: # noqa: PYI034
22+
"""Create a new ProductVersion instance and validate its format."""
23+
if not VERSION_EXTRACT_REGEX.fullmatch(value):
24+
msg = f"Invalid product version format: '{value}'"
25+
raise ValueError(msg)
26+
return super().__new__(cls, value)
27+
1228

1329
class ChannelType(Enum):
1430
"""Enumeration of channel types."""

tests/test_giteasync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def test_extracting_product_name_and_version() -> None:
247247

248248
slfo_url = "https://src.suse.de/user1/SLFO.git?onlybuild=tree#f229f"
249249
prod_ver = get_product_name_and_version_from_scmsync(slfo_url)
250-
assert prod_ver == ("", "")
250+
assert prod_ver == ("", None)
251251
prod_url = "https://src.suse.de/products/SLES#15.99"
252252
prod_ver = get_product_name_and_version_from_scmsync(prod_url)
253253
assert prod_ver == ("SLES", "15.99")

tests/test_loader_gitea_helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def test_get_product_version_from_repo_listing_json_error(mocker: MockerFixture)
6969
mock_response.json.side_effect = json.JSONDecodeError("msg", "doc", 0)
7070
mocker.patch.object(gitea.retried_requests, "get", return_value=mock_response)
7171
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
72-
assert not res
72+
assert res is None
7373
assert mock_log.info.called
7474

7575

@@ -80,7 +80,7 @@ def test_get_product_version_from_repo_listing_http_error(mocker: MockerFixture)
8080
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("error")
8181
mocker.patch.object(gitea.retried_requests, "get", return_value=mock_response)
8282
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
83-
assert not res
83+
assert res is None
8484
assert mock_log.warning.called
8585

8686

@@ -91,7 +91,7 @@ def test_get_product_version_from_repo_listing_request_exception(
9191
caplog.set_level(logging.WARNING, logger="bot.loader.gitea")
9292
mocker.patch("openqabot.loader.gitea.retried_requests.get", side_effect=requests.RequestException("error"))
9393
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
94-
assert not res
94+
assert res is None
9595
assert "Product version unresolved" in caplog.text
9696

9797

@@ -141,7 +141,7 @@ def test_get_product_version_from_repo_listing_requests_json_error(
141141
mock_response.json.side_effect = requests.exceptions.JSONDecodeError("msg", "doc", 0)
142142
mocker.patch("openqabot.loader.gitea.retried_requests.get", return_value=mock_response)
143143
res = gitea.get_product_version_from_repo_listing("project_json", "product_json", "repo_json")
144-
assert not res
144+
assert res is None
145145
assert "Invalid JSON document" in caplog.text
146146

147147

tests/test_types.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,39 @@
44

55
import pytest
66

7-
from openqabot.types.types import ChannelType, ProdVer, Repos, get_channel_type
7+
from openqabot.types.types import ChannelType, ProductVersion, ProdVer, Repos, get_channel_type
8+
9+
10+
@pytest.mark.parametrize(
11+
"value",
12+
[
13+
"15.4",
14+
"15",
15+
"0.1.2",
16+
"1234.5678",
17+
],
18+
)
19+
def test_product_version_valid(value: str) -> None:
20+
"""Test valid product version strings."""
21+
pv = ProductVersion(value)
22+
assert pv == value
23+
assert isinstance(pv, ProductVersion)
24+
assert isinstance(pv, str)
25+
26+
27+
@pytest.mark.parametrize(
28+
"value",
29+
[
30+
"",
31+
"a.b",
32+
"15.4-SP1",
33+
"15_4",
34+
],
35+
)
36+
def test_product_version_invalid(value: str) -> None:
37+
"""Test invalid product version strings."""
38+
with pytest.raises(ValueError, match="Invalid product version format:"):
39+
ProductVersion(value)
840

941

1042
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)