Skip to content

Commit 6c4b6b0

Browse files
committed
refactor: move static methods to free functions
Motivation: Adhere to project guidelines to prioritize functional style over Object-Oriented and address reviewer feedback. Design Choices: - Extracted logic from @staticmethod methods into module-level functions. - Kept original method names as minimal wrappers where necessary for backward compatibility and test stability. - Updated tests to use module-level patching instead of patch.object where appropriate. User Benefits: - Improved code maintainability through explicit data flow. - Enhanced testability by allowing logic to be verified in isolation. - Cleaner class definitions focused on state management.
1 parent 5fb9462 commit 6c4b6b0

File tree

9 files changed

+315
-304
lines changed

9 files changed

+315
-304
lines changed

openqabot/commenter.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from .loader.qem import get_aggregate_results, get_submission_results, get_submissions
1818
from .openqa import OpenQAInterface
19-
from .osclib.comments import CommentAPI
19+
from .osclib.comments import CommentAPI, add_marker, truncate
2020

2121
if TYPE_CHECKING:
2222
from argparse import Namespace
@@ -26,6 +26,27 @@
2626
log = getLogger("bot.commenter")
2727

2828

29+
def _format_group_message(group_data: dict[str, Any]) -> str:
30+
"""Format a single group summary into a markdown string."""
31+
msg = "\n\n" + group_data["title"]
32+
infos = []
33+
if group_data["passed"]:
34+
infos.append(f"{group_data['passed']:d} tests passed")
35+
if group_data["failed"]:
36+
infos.append(f"{len(group_data['failed']):d} tests failed")
37+
if group_data["unfinished"]:
38+
infos.append(f"{group_data['unfinished']:d} unfinished tests")
39+
msg += "(" + ", ".join(infos) + ")\n"
40+
for fail in group_data["failed"]:
41+
msg += fail
42+
return msg
43+
44+
45+
def _escape_for_markdown(string: str) -> str:
46+
"""Escape underscores for markdown."""
47+
return string.replace("_", r"\_")
48+
49+
2950
class Commenter:
3051
"""Logic for commenting on submissions in OBS."""
3152

@@ -87,8 +108,8 @@ def osc_comment(self, sub: Submission, msg: str, state: str) -> None:
87108
for key in sub.revisions:
88109
info[f"revision_{key.version}_{key.arch}"] = sub.revisions[key]
89110

90-
msg = self.commentapi.add_marker(msg, bot_name, info)
91-
msg = self.commentapi.truncate(msg.strip())
111+
msg = add_marker(msg, bot_name, info)
112+
msg = truncate(msg.strip())
92113

93114
kw = {"request_id": str(sub.rr)}
94115
comments = self.commentapi.get_comments(**kw)
@@ -125,7 +146,7 @@ def summarize_message(self, jobs: list[dict[str, Any]]) -> str:
125146

126147
msg = ""
127148
for group in sorted(groups.keys()):
128-
msg += self._format_group_message(groups[group])
149+
msg += _format_group_message(groups[group])
129150
return msg.rstrip("\n")
130151

131152
def _process_job(self, groups: dict[str, dict[str, Any]], job: dict[str, Any]) -> None:
@@ -135,7 +156,7 @@ def _process_job(self, groups: dict[str, dict[str, Any]], job: dict[str, Any]) -
135156
log.warning("Job %s skipped: Missing 'job_group'", job["job_id"])
136157
return
137158

138-
gl = f"{Commenter.escape_for_markdown(job['job_group'])}@{Commenter.escape_for_markdown(job['flavor'])}"
159+
gl = f"{_escape_for_markdown(job['job_group'])}@{_escape_for_markdown(job['flavor'])}"
139160
self._create_group_if_missing(groups, job, gl)
140161

141162
job_summary = self.__summarize_one_openqa_job(job)
@@ -172,27 +193,6 @@ def _create_group_if_missing(self, groups: dict[str, dict[str, Any]], job: dict[
172193
"failed": [],
173194
}
174195

175-
@staticmethod
176-
def _format_group_message(group_data: dict[str, Any]) -> str:
177-
"""Format a single group summary into a markdown string."""
178-
msg = "\n\n" + group_data["title"]
179-
infos = []
180-
if group_data["passed"]:
181-
infos.append(f"{group_data['passed']:d} tests passed")
182-
if group_data["failed"]:
183-
infos.append(f"{len(group_data['failed']):d} tests failed")
184-
if group_data["unfinished"]:
185-
infos.append(f"{group_data['unfinished']:d} unfinished tests")
186-
msg += "(" + ", ".join(infos) + ")\n"
187-
for fail in group_data["failed"]:
188-
msg += fail
189-
return msg
190-
191-
@staticmethod
192-
def escape_for_markdown(string: str) -> str:
193-
"""Escape underscores for markdown."""
194-
return string.replace("_", r"\_")
195-
196196
def __summarize_one_openqa_job(self, job: dict[str, Any]) -> str | None:
197197
testurl = osc.core.makeurl(self.client.openqa.baseurl, ["tests", str(job["job_id"])])
198198
name = job["name"]

openqabot/osclib/comments.py

Lines changed: 62 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,65 @@ def comment_as_dict(comment_element: etree.Element) -> dict[str, Any]:
3030
}
3131

3232

33+
def _parse_info(info_str: str) -> dict[str, str]:
34+
"""Parse the info string from a comment marker."""
35+
if not (stripped := info_str.strip()):
36+
return {}
37+
return dict(pair.split("=") for pair in stripped.split())
38+
39+
40+
def _info_matches(info: dict[str, str], info_match: dict[str, Any] | None) -> bool:
41+
"""Check if parsed info matches the criteria."""
42+
if not info_match:
43+
return True
44+
return all(value is None or info.get(key) == value for key, value in info_match.items())
45+
46+
47+
def add_marker(comment: str, bot: str, info: dict[str, Any] | None = None) -> str:
48+
"""Add bot marker to comment that can be used to find comment."""
49+
info_str = ""
50+
if info:
51+
infos = ["=".join((str(key), str(value))) for key, value in info.items()]
52+
info_str = " " + " ".join(infos)
53+
54+
marker = f"<!-- {bot}{info_str} -->"
55+
return marker + "\n\n" + comment
56+
57+
58+
def truncate(comment: str, suffix: str = "...", length: int = 65535) -> str:
59+
"""Truncate a comment to a specific length, preserving markdown pre tags."""
60+
# Handle very short length by dropping suffix and just chopping comment.
61+
if length <= len(suffix) + len("\n</pre>"):
62+
return comment[:length]
63+
if len(comment) <= length:
64+
return comment
65+
66+
# Determine the point at which to end by leaving room for suffix.
67+
end = length - len(suffix)
68+
if comment.find("<pre>", 0, end) != -1:
69+
# For the sake of simplicity leave space for closing pre tag even if
70+
# after truncation it may no longer be necessary. Otherwise, it
71+
# requires recursion with some fun edge cases.
72+
end -= len("\n</pre>")
73+
74+
# Check for the end location landing inside a pre tag and correct by
75+
# moving in front of the tag. Landing on the ends is a noop.
76+
pre_index = max(
77+
comment.rfind("<pre>", end - 4, end + 4),
78+
comment.rfind("</pre>", end - 5, end + 5),
79+
)
80+
if pre_index != -1:
81+
end = pre_index
82+
83+
comment = comment[:end]
84+
85+
# Check for unbalanced pre tag and add a closing tag.
86+
if comment.count("<pre>") > comment.count("</pre>"):
87+
suffix += "\n</pre>"
88+
89+
return comment + suffix
90+
91+
3392
class OscCommentsValueError(ValueError):
3493
"""Raised when an invalid value is provided to OscComments."""
3594

@@ -98,20 +157,6 @@ def get_comments(
98157
comments[c["id"]] = c
99158
return comments
100159

101-
@staticmethod
102-
def _parse_info(info_str: str) -> dict[str, str]:
103-
"""Parse the info string from a comment marker."""
104-
if not (stripped := info_str.strip()):
105-
return {}
106-
return dict(pair.split("=") for pair in stripped.split())
107-
108-
@staticmethod
109-
def _info_matches(info: dict[str, str], info_match: dict[str, Any] | None) -> bool:
110-
"""Check if parsed info matches the criteria."""
111-
if not info_match:
112-
return True
113-
return all(value is None or info.get(key) == value for key, value in info_match.items())
114-
115160
def comment_find(
116161
self,
117162
comments: dict[str, Any],
@@ -127,23 +172,12 @@ def comment_find(
127172
if bot != m.group("bot").lower():
128173
continue
129174

130-
info = self._parse_info(m.group("info"))
131-
if self._info_matches(info, info_match):
175+
info = _parse_info(m.group("info"))
176+
if _info_matches(info, info_match):
132177
return c, info
133178

134179
return None, None
135180

136-
@staticmethod
137-
def add_marker(comment: str, bot: str, info: dict[str, Any] | None = None) -> str:
138-
"""Add bot marker to comment that can be used to find comment."""
139-
info_str = ""
140-
if info:
141-
infos = ["=".join((str(key), str(value))) for key, value in info.items()]
142-
info_str = " " + " ".join(infos)
143-
144-
marker = f"<!-- {bot}{info_str} -->"
145-
return marker + "\n\n" + comment
146-
147181
def add_comment(
148182
self,
149183
request_id: str | int | None = None,
@@ -163,46 +197,12 @@ def add_comment(
163197
if not comment:
164198
raise OscCommentsEmptyError
165199

166-
comment = self.truncate(comment.strip())
200+
comment = truncate(comment.strip())
167201

168202
query = {"parent_id": parent_id} if parent_id else {}
169203
url = self.prepare_url(request_id, project_name, package_name, query)
170204
return http_POST(url, data=comment)
171205

172-
@staticmethod
173-
def truncate(comment: str, suffix: str = "...", length: int = 65535) -> str:
174-
"""Truncate a comment to a specific length, preserving markdown pre tags."""
175-
# Handle very short length by dropping suffix and just chopping comment.
176-
if length <= len(suffix) + len("\n</pre>"):
177-
return comment[:length]
178-
if len(comment) <= length:
179-
return comment
180-
181-
# Determine the point at which to end by leaving room for suffix.
182-
end = length - len(suffix)
183-
if comment.find("<pre>", 0, end) != -1:
184-
# For the sake of simplicity leave space for closing pre tag even if
185-
# after truncation it may no longer be necessary. Otherwise, it
186-
# requires recursion with some fun edge cases.
187-
end -= len("\n</pre>")
188-
189-
# Check for the end location landing inside a pre tag and correct by
190-
# moving in front of the tag. Landing on the ends is a noop.
191-
pre_index = max(
192-
comment.rfind("<pre>", end - 4, end + 4),
193-
comment.rfind("</pre>", end - 5, end + 5),
194-
)
195-
if pre_index != -1:
196-
end = pre_index
197-
198-
comment = comment[:end]
199-
200-
# Check for unbalanced pre tag and add a closing tag.
201-
if comment.count("<pre>") > comment.count("</pre>"):
202-
suffix += "\n</pre>"
203-
204-
return comment + suffix
205-
206206
def delete(self, comment_id: str | int) -> None:
207207
"""Remove a comment object.
208208

openqabot/types/aggregate.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@
3030
ALL_ISSUES_KEY = "TEST_ISSUES[]"
3131

3232

33+
def _normalize_repos(config: dict[str, Any]) -> dict[str, ProdVer]:
34+
"""Normalize repository configuration from config.settings."""
35+
try:
36+
return {key: ProdVer(*value.split(":")) for key, value in config["test_issues"].items()}
37+
except KeyError as e:
38+
raise NoTestIssuesError from e
39+
40+
41+
def get_buildnr(repohash: str, old_repohash: str, build: str) -> str:
42+
"""Determine the next build number based on current date and repohash."""
43+
today = datetime.datetime.now(tz=UTC).date().strftime("%Y%m%d")
44+
45+
if build.startswith(today) and repohash == old_repohash:
46+
raise SameBuildExistsError
47+
48+
counter = int(build.rsplit("-", maxsplit=1)[-1]) + 1 if build.startswith(today) else 1
49+
return f"{today}-{counter}"
50+
51+
3352
class PostData(NamedTuple):
3453
"""Data to be posted to dashboard."""
3554

@@ -53,26 +72,12 @@ def __init__(self, config: JobConfig) -> None:
5372
@staticmethod
5473
def normalize_repos(config: dict[str, Any]) -> dict[str, ProdVer]:
5574
"""Normalize repository configuration from config.settings."""
56-
try:
57-
return {key: ProdVer(*value.split(":")) for key, value in config["test_issues"].items()}
58-
except KeyError as e:
59-
raise NoTestIssuesError from e
75+
return _normalize_repos(config)
6076

6177
def __repr__(self) -> str:
6278
"""Return a string representation of the Aggregate."""
6379
return f"<Aggregate product: {self.product}>"
6480

65-
@staticmethod
66-
def get_buildnr(repohash: str, old_repohash: str, build: str) -> str:
67-
"""Determine the next build number based on current date and repohash."""
68-
today = datetime.datetime.now(tz=UTC).date().strftime("%Y%m%d")
69-
70-
if build.startswith(today) and repohash == old_repohash:
71-
raise SameBuildExistsError
72-
73-
counter = int(build.rsplit("-", maxsplit=1)[-1]) + 1 if build.startswith(today) else 1
74-
return f"{today}-{counter}"
75-
7681
def filter_submissions(self, submissions: list[Submission]) -> list[Submission]:
7782
"""Filter out submissions that are not suitable for aggregate tests."""
7883

@@ -230,7 +235,7 @@ def process_arch(
230235
old_build = old_jobs[0].get("build", "") if old_jobs else ""
231236

232237
try:
233-
build = self.get_buildnr(
238+
build = get_buildnr(
234239
repohash,
235240
old_repohash,
236241
old_build,

0 commit comments

Comments
 (0)