Skip to content

Commit 34ab418

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 #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 f2d9742 commit 34ab418

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
@@ -32,7 +32,7 @@
3232
from collections.abc import Iterator
3333

3434
from openqabot.types.pullrequest import PullRequest
35-
from openqabot.types.types import Repos
35+
from openqabot.types.types import VERSION_EXTRACT_REGEX, ProductVersion, Repos
3636

3737
ARCHS = {"x86_64", "aarch64", "ppc64le", "s390x"}
3838

@@ -54,7 +54,6 @@ class BuildResults:
5454

5555
PROJECT_PRODUCT_REGEX = re.compile(r".*:PullRequest:\d+:(.*)")
5656
SCMSYNC_REGEX = re.compile(r".*/products/(.*)#([\d\.]{2,6})$")
57-
VERSION_EXTRACT_REGEX = re.compile(r"[.\d]+")
5857
OBS_PROJECT_SHOW_REGEX = re.compile(r".*/project/show/([^/\s\?\#\)]+)")
5958
# Regex to find all HTTPS URLs, excluding common trailing punctuation like dots or parentheses
6059
# that are likely part of the surrounding text (e.g. at the end of a sentence or in Markdown).
@@ -155,10 +154,10 @@ def get_product_name(obs_project: str) -> str:
155154
return product_match.group(1) if product_match else ""
156155

157156

158-
def get_product_name_and_version_from_scmsync(scmsync_url: str) -> tuple[str, str]:
157+
def get_product_name_and_version_from_scmsync(scmsync_url: str) -> tuple[str, ProductVersion | None]:
159158
"""Extract product name and version from an scmsync URL."""
160159
m = SCMSYNC_REGEX.search(scmsync_url)
161-
return (m.group(1), m.group(2)) if m else ("", "")
160+
return (m.group(1), ProductVersion(m.group(2))) if m else ("", None)
162161

163162

164163
def compute_repo_url_for_job_setting(
@@ -320,14 +319,17 @@ def add_reviews(submission: dict[str, Any], reviews: list[Any]) -> int:
320319
return len(qam_states)
321320

322321

323-
def _extract_version(name: str, prefix: str) -> str:
322+
def _extract_version(name: str, prefix: str) -> ProductVersion | None:
324323
"""Extract version number from a package name string."""
325324
remainder = name.removeprefix(prefix)
326-
return next((part for part in remainder.split("-") if VERSION_EXTRACT_REGEX.search(part)), "")
325+
return next(
326+
(ProductVersion(part) for part in remainder.split("-") if VERSION_EXTRACT_REGEX.fullmatch(part)),
327+
None,
328+
)
327329

328330

329331
@lru_cache(maxsize=512)
330-
def get_product_version_from_repo_listing(project: str, product_name: str, repository: str) -> str:
332+
def get_product_version_from_repo_listing(project: str, product_name: str, repository: str) -> ProductVersion | None:
331333
"""Determine the product version by inspecting an OBS repository listing."""
332334
project_path = project.replace(":", ":/")
333335
url = f"{config.settings.obs_download_url}/{project_path}/{repository}/repo?jsontable"
@@ -338,34 +340,34 @@ def get_product_version_from_repo_listing(project: str, product_name: str, repos
338340
data = r.json()["data"]
339341
except requests.exceptions.HTTPError as e:
340342
log.warning("Repo ignored: Could not query repository '%s' (%s->%s): %s", repository, product_name, project, e)
341-
return ""
343+
return None
342344
# Catching both because requests' JSONDecodeError might not inherit from json's
343345
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
344346
log.info("Invalid JSON document at '%s', ignoring: %s", url, e)
345-
return ""
347+
return None
346348
except requests.exceptions.RequestException as e:
347349
log.warning("Product version unresolved: Could not read from '%s': %s", url, e)
348-
return ""
350+
return None
349351
versions = (_extract_version(entry["name"], start) for entry in data if entry["name"].startswith(start))
350-
return next((v for v in versions if len(v) > 0), "")
352+
return next((v for v in versions if v is not None), None)
351353

352354

353-
def _get_product_version(res: etree._Element, project: str, product_name: str) -> str:
355+
def _get_product_version(res: etree._Element, project: str, product_name: str) -> ProductVersion | None:
354356
"""Extract product version from scmsync element or repository listing."""
355357
# read product version from scmsync element if possible, e.g. 15.99
356358
product_version = next(
357359
(
358360
pv
359-
for _, pv in (get_product_name_and_version_from_scmsync(e.text) for e in res.findall("scmsync"))
360-
if len(pv) > 0
361+
for _, pv in (get_product_name_and_version_from_scmsync(e.text) for e in res.findall("scmsync") if e.text)
362+
if pv is not None
361363
),
362-
"",
364+
None,
363365
)
364366

365367
# read product version from directory listing if the project is for a concrete product
366368
if (
367-
len(product_name) != 0
368-
and len(product_version) == 0
369+
product_name
370+
and product_version is None
369371
and ("all" in config.settings.obs_products_set or product_name in config.settings.obs_products_set)
370372
):
371373
product_version = get_product_version_from_repo_listing(project, product_name, res.get("repository"))
@@ -388,9 +390,9 @@ def add_channel_for_build_result(
388390
product_version = _get_product_version(res, project, product_name)
389391

390392
# append product version to channel if known; otherwise skip channel if this is for a concrete product
391-
if len(product_version) > 0:
393+
if product_version is not None:
392394
channel = f"{channel}#{product_version}"
393-
elif len(product_name) > 0:
395+
elif product_name:
394396
log.debug("Channel skipped: Product version for build result %s:%s could not be determined", project, arch)
395397
return channel
396398

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
@@ -74,7 +74,7 @@ def test_get_product_version_from_repo_listing_json_error(mocker: MockerFixture)
7474
mock_response.json.side_effect = json.JSONDecodeError("msg", "doc", 0)
7575
mocker.patch.object(gitea.retried_requests, "get", return_value=mock_response)
7676
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
77-
assert not res
77+
assert res is None
7878
assert mock_log.info.called
7979

8080

@@ -85,7 +85,7 @@ def test_get_product_version_from_repo_listing_http_error(mocker: MockerFixture)
8585
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("error")
8686
mocker.patch.object(gitea.retried_requests, "get", return_value=mock_response)
8787
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
88-
assert not res
88+
assert res is None
8989
assert mock_log.warning.called
9090

9191

@@ -96,7 +96,7 @@ def test_get_product_version_from_repo_listing_request_exception(
9696
caplog.set_level(logging.WARNING, logger="bot.loader.gitea")
9797
mocker.patch("openqabot.loader.gitea.retried_requests.get", side_effect=requests.RequestException("error"))
9898
res = gitea.get_product_version_from_repo_listing("project", "product", "repo")
99-
assert not res
99+
assert res is None
100100
assert "Product version unresolved" in caplog.text
101101

102102

@@ -146,7 +146,7 @@ def test_get_product_version_from_repo_listing_requests_json_error(
146146
mock_response.json.side_effect = requests.exceptions.JSONDecodeError("msg", "doc", 0)
147147
mocker.patch("openqabot.loader.gitea.retried_requests.get", return_value=mock_response)
148148
res = gitea.get_product_version_from_repo_listing("project_json", "product_json", "repo_json")
149-
assert not res
149+
assert res is None
150150
assert "Invalid JSON document" in caplog.text
151151

152152

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)