Coverage for src/git_changelog/providers.py: 88.46%
104 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
1"""Module containing the parsing utilities for git providers."""
3from __future__ import annotations
5import re
6from abc import ABC, abstractmethod
7from typing import ClassVar, Match, Pattern
10class RefRe:
11 """An enum helper to store parts of regular expressions for references."""
13 BB = r"(?:^|[\s,])" # blank before
14 BA = r"(?:[\s,]|$)" # blank after
15 NP = r"(?:(?P<namespace>[-\w]+)/)?(?P<project>[-\w]+)" # namespace and project
16 ID = r"{symbol}(?P<ref>[1-9]\d*)"
17 ONE_WORD = r"{symbol}(?P<ref>\w*[-a-z_ ][-\w]*)"
18 MULTI_WORD = r'{symbol}(?P<ref>"\w[- \w]*")'
19 COMMIT = r"(?P<ref>[0-9a-f]{{{min},{max}}})"
20 COMMIT_RANGE = r"(?P<ref>[0-9a-f]{{{min},{max}}}\.\.\.[0-9a-f]{{{min},{max}}})"
21 MENTION = r"@(?P<ref>\w[-\w]*)"
24class Ref:
25 """A class to represent a reference and its URL."""
27 def __init__(self, ref: str, url: str) -> None:
28 """Initialization method.
30 Arguments:
31 ref: The reference text.
32 url: The reference URL.
33 """
34 self.ref: str = ref
35 self.url: str = url
37 def __str__(self):
38 return self.ref + ": " + self.url
41class RefDef:
42 """A class to store a reference regular expression and URL building string."""
44 def __init__(self, regex: Pattern, url_string: str):
45 """Initialization method.
47 Arguments:
48 regex: The regular expression to match the reference.
49 url_string: The URL string to format using matched groups.
50 """
51 self.regex = regex
52 self.url_string = url_string
55class ProviderRefParser(ABC):
56 """A base class for specific providers reference parsers."""
58 url: str
59 namespace: str
60 project: str
61 REF: ClassVar[dict[str, RefDef]] = {}
63 def __init__(self, namespace: str, project: str, url: str | None = None):
64 """Initialization method.
66 Arguments:
67 namespace: The Bitbucket namespace.
68 project: The Bitbucket project.
69 url: The Bitbucket URL.
70 """
71 self.namespace: str = namespace
72 self.project: str = project
73 self.url: str = url or self.url
75 def get_refs(self, ref_type: str, text: str) -> list[Ref]:
76 """Find all references in the given text.
78 Arguments:
79 ref_type: The reference type.
80 text: The text in which to search references.
82 Returns:
83 A list of references (instances of [Ref][git_changelog.providers.Ref]).
84 """
85 return [
86 Ref(ref=match.group().strip(), url=self.build_ref_url(ref_type, match.groupdict()))
87 for match in self.parse_refs(ref_type, text)
88 ]
90 def parse_refs(self, ref_type: str, text: str) -> list[Match]:
91 """Parse references in the given text.
93 Arguments:
94 ref_type: The reference type.
95 text: The text to parse.
97 Returns:
98 A list of regular expressions matches.
99 """
100 if ref_type not in self.REF: 100 ↛ 101line 100 didn't jump to line 101, because the condition on line 100 was never true
101 refs = [key for key in self.REF if key.startswith(ref_type)]
102 return [match for ref in refs for match in self.REF[ref].regex.finditer(text)]
103 return list(self.REF[ref_type].regex.finditer(text))
105 def build_ref_url(self, ref_type: str, match_dict: dict[str, str]) -> str:
106 """Build the URL for a reference type and a dictionary of matched groups.
108 Arguments:
109 ref_type: The reference type.
110 match_dict: The matched groups.
112 Returns:
113 The built URL.
114 """
115 return self.REF[ref_type].url_string.format(**match_dict)
117 @abstractmethod
118 def get_tag_url(self, tag: str) -> str:
119 """Get the URL for a git tag.
121 Arguments:
122 tag: The git tag.
124 Returns:
125 The tag URL.
126 """
127 raise NotImplementedError
129 @abstractmethod
130 def get_compare_url(self, base: str, target: str) -> str:
131 """Get the URL for a tag comparison.
133 Arguments:
134 base: The base tag.
135 target: The target tag.
137 Returns:
138 The comparison URL.
139 """
140 raise NotImplementedError
143class GitHub(ProviderRefParser):
144 """A parser for the GitHub references."""
146 url: str = "https://github.com"
147 project_url: str = "{base_url}/{namespace}/{project}"
148 tag_url: str = "{base_url}/{namespace}/{project}/releases/tag/{ref}"
150 commit_min_length = 8
151 commit_max_length = 40
153 REF: ClassVar[dict[str, RefDef]] = {
154 "issues": RefDef(
155 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol="#"), re.I),
156 url_string="{base_url}/{namespace}/{project}/issues/{ref}",
157 ),
158 "commits": RefDef(
159 regex=re.compile(
160 RefRe.BB
161 + r"(?:{np}@)?{commit}{ba}".format(
162 np=RefRe.NP,
163 commit=RefRe.COMMIT.format(min=commit_min_length, max=commit_max_length),
164 ba=RefRe.BA,
165 ),
166 re.I,
167 ),
168 url_string="{base_url}/{namespace}/{project}/commit/{ref}",
169 ),
170 "commits_ranges": RefDef(
171 regex=re.compile(
172 RefRe.BB
173 + r"(?:{np}@)?{commit_range}".format(
174 np=RefRe.NP,
175 commit_range=RefRe.COMMIT_RANGE.format(min=commit_min_length, max=commit_max_length),
176 ),
177 re.I,
178 ),
179 url_string="{base_url}/{namespace}/{project}/compare/{ref}",
180 ),
181 "mentions": RefDef(regex=re.compile(RefRe.BB + RefRe.MENTION, re.I), url_string="{base_url}/{ref}"),
182 }
184 def build_ref_url(self, ref_type: str, match_dict: dict[str, str]) -> str: # noqa: D102 (use parent docstring)
185 match_dict["base_url"] = self.url
186 if not match_dict.get("namespace"):
187 match_dict["namespace"] = self.namespace
188 if not match_dict.get("project"):
189 match_dict["project"] = self.project
190 return super().build_ref_url(ref_type, match_dict)
192 def get_tag_url(self, tag: str = "") -> str: # noqa: D102
193 return self.tag_url.format(base_url=self.url, namespace=self.namespace, project=self.project, ref=tag)
195 def get_compare_url(self, base: str, target: str) -> str: # noqa: D102 (use parent docstring)
196 return self.build_ref_url("commits_ranges", {"ref": f"{base}...{target}"})
199class GitLab(ProviderRefParser):
200 """A parser for the GitLab references."""
202 url: str = "https://gitlab.com"
203 project_url: str = "{base_url}/{namespace}/{project}"
204 tag_url: str = "{base_url}/{namespace}/{project}/tags/{ref}"
206 commit_min_length = 8
207 commit_max_length = 40
209 REF: ClassVar[dict[str, RefDef]] = {
210 "issues": RefDef(
211 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol="#"), re.I),
212 url_string="{base_url}/{namespace}/{project}/issues/{ref}",
213 ),
214 "merge_requests": RefDef(
215 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol=r"!"), re.I),
216 url_string="{base_url}/{namespace}/{project}/merge_requests/{ref}",
217 ),
218 "snippets": RefDef(
219 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol=r"\$"), re.I),
220 url_string="{base_url}/{namespace}/{project}/snippets/{ref}",
221 ),
222 "labels_ids": RefDef(
223 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol=r"~"), re.I),
224 url_string="{base_url}/{namespace}/{project}/issues?label_name[]={ref}", # no label_id param?
225 ),
226 "labels_one_word": RefDef(
227 regex=re.compile( # also matches label IDs
228 RefRe.BB + RefRe.NP + "?" + RefRe.ONE_WORD.format(symbol=r"~"),
229 re.I,
230 ),
231 url_string="{base_url}/{namespace}/{project}/issues?label_name[]={ref}",
232 ),
233 "labels_multi_word": RefDef(
234 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.MULTI_WORD.format(symbol=r"~"), re.I),
235 url_string="{base_url}/{namespace}/{project}/issues?label_name[]={ref}",
236 ),
237 "milestones_ids": RefDef(
238 regex=re.compile( # also matches milestones IDs
239 RefRe.BB + RefRe.NP + "?" + RefRe.ID.format(symbol=r"%"),
240 re.I,
241 ),
242 url_string="{base_url}/{namespace}/{project}/milestones/{ref}",
243 ),
244 "milestones_one_word": RefDef(
245 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.ONE_WORD.format(symbol=r"%"), re.I),
246 url_string="{base_url}/{namespace}/{project}/milestones", # cannot guess ID
247 ),
248 "milestones_multi_word": RefDef(
249 regex=re.compile(RefRe.BB + RefRe.NP + "?" + RefRe.MULTI_WORD.format(symbol=r"%"), re.I),
250 url_string="{base_url}/{namespace}/{project}/milestones", # cannot guess ID
251 ),
252 "commits": RefDef(
253 regex=re.compile(
254 RefRe.BB
255 + r"(?:{np}@)?{commit}{ba}".format(
256 np=RefRe.NP,
257 commit=RefRe.COMMIT.format(min=commit_min_length, max=commit_max_length),
258 ba=RefRe.BA,
259 ),
260 re.I,
261 ),
262 url_string="{base_url}/{namespace}/{project}/commit/{ref}",
263 ),
264 "commits_ranges": RefDef(
265 regex=re.compile(
266 RefRe.BB
267 + r"(?:{np}@)?{commit_range}".format(
268 np=RefRe.NP,
269 commit_range=RefRe.COMMIT_RANGE.format(min=commit_min_length, max=commit_max_length),
270 ),
271 re.I,
272 ),
273 url_string="{base_url}/{namespace}/{project}/compare/{ref}",
274 ),
275 "mentions": RefDef(regex=re.compile(RefRe.BB + RefRe.MENTION, re.I), url_string="{base_url}/{ref}"),
276 }
278 def build_ref_url(self, ref_type: str, match_dict: dict[str, str]) -> str: # noqa: D102 (use parent docstring)
279 match_dict["base_url"] = self.url
280 if not match_dict.get("namespace"):
281 match_dict["namespace"] = self.namespace
282 if not match_dict.get("project"):
283 match_dict["project"] = self.project
284 if ref_type.startswith("label"):
285 match_dict["ref"] = match_dict["ref"].replace('"', "").replace(" ", "+")
286 return super().build_ref_url(ref_type, match_dict)
288 def get_tag_url(self, tag: str = "") -> str: # noqa: D102
289 return self.tag_url.format(base_url=self.url, namespace=self.namespace, project=self.project, ref=tag)
291 def get_compare_url(self, base: str, target: str) -> str: # noqa: D102 (use parent docstring)
292 return self.build_ref_url("commits_ranges", {"ref": f"{base}...{target}"})
295class Bitbucket(ProviderRefParser):
296 """A parser for the Bitbucket references."""
298 url: str = "https://bitbucket.org"
299 project_url: str = "{base_url}/{namespace}/{project}"
300 tag_url: str = "{base_url}/{namespace}/{project}/commits/tag/{ref}"
302 commit_min_length = 8
303 commit_max_length = 40
305 REF: ClassVar[dict[str, RefDef]] = {
306 "issues": RefDef(
307 regex=re.compile(RefRe.BB + RefRe.NP + "?issue\\s*" + RefRe.ID.format(symbol="#"), re.I),
308 url_string="{base_url}/{namespace}/{project}/issues/{ref}",
309 ),
310 "merge_requests": RefDef(
311 regex=re.compile(RefRe.BB + RefRe.NP + "?pull request\\s*" + RefRe.ID.format(symbol=r"#"), re.I),
312 url_string="{base_url}/{namespace}/{project}/pull-request/{ref}",
313 ),
314 "commits": RefDef(
315 regex=re.compile(
316 RefRe.BB
317 + r"(?:{np}@)?{commit}{ba}".format(
318 np=RefRe.NP,
319 commit=RefRe.COMMIT.format(min=commit_min_length, max=commit_max_length),
320 ba=RefRe.BA,
321 ),
322 re.I,
323 ),
324 url_string="{base_url}/{namespace}/{project}/commits/{ref}",
325 ),
326 "commits_ranges": RefDef(
327 regex=re.compile(
328 RefRe.BB
329 + r"(?:{np}@)?{commit_range}".format(
330 np=RefRe.NP,
331 commit_range=RefRe.COMMIT_RANGE.format(min=commit_min_length, max=commit_max_length),
332 ),
333 re.I,
334 ),
335 url_string="{base_url}/{namespace}/{project}/branches/compare/{ref}#diff",
336 ),
337 "mentions": RefDef(
338 regex=re.compile(RefRe.BB + RefRe.MENTION, re.I),
339 url_string="{base_url}/{ref}",
340 ),
341 }
343 def build_ref_url(self, ref_type: str, match_dict: dict[str, str]) -> str: # noqa: D102 (use parent docstring)
344 match_dict["base_url"] = self.url
345 if not match_dict.get("namespace"): 345 ↛ 347line 345 didn't jump to line 347, because the condition on line 345 was never false
346 match_dict["namespace"] = self.namespace
347 if not match_dict.get("project"):
348 match_dict["project"] = self.project
349 return super().build_ref_url(ref_type, match_dict)
351 def get_tag_url(self, tag: str = "") -> str: # noqa: D102
352 return self.tag_url.format(base_url=self.url, namespace=self.namespace, project=self.project, ref=tag)
354 def get_compare_url(self, base: str, target: str) -> str: # noqa: D102 (use parent docstring)
355 return self.build_ref_url("commits_ranges", {"ref": f"{target}..{base}"})