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

1"""Module containing the parsing utilities for git providers.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from abc import ABC, abstractmethod 

7from typing import ClassVar, Match, Pattern 

8 

9 

10class RefRe: 

11 """An enum helper to store parts of regular expressions for references.""" 

12 

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]*)" 

22 

23 

24class Ref: 

25 """A class to represent a reference and its URL.""" 

26 

27 def __init__(self, ref: str, url: str) -> None: 

28 """Initialization method. 

29 

30 Arguments: 

31 ref: The reference text. 

32 url: The reference URL. 

33 """ 

34 self.ref: str = ref 

35 self.url: str = url 

36 

37 def __str__(self): 

38 return self.ref + ": " + self.url 

39 

40 

41class RefDef: 

42 """A class to store a reference regular expression and URL building string.""" 

43 

44 def __init__(self, regex: Pattern, url_string: str): 

45 """Initialization method. 

46 

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 

53 

54 

55class ProviderRefParser(ABC): 

56 """A base class for specific providers reference parsers.""" 

57 

58 url: str 

59 namespace: str 

60 project: str 

61 REF: ClassVar[dict[str, RefDef]] = {} 

62 

63 def __init__(self, namespace: str, project: str, url: str | None = None): 

64 """Initialization method. 

65 

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 

74 

75 def get_refs(self, ref_type: str, text: str) -> list[Ref]: 

76 """Find all references in the given text. 

77 

78 Arguments: 

79 ref_type: The reference type. 

80 text: The text in which to search references. 

81 

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 ] 

89 

90 def parse_refs(self, ref_type: str, text: str) -> list[Match]: 

91 """Parse references in the given text. 

92 

93 Arguments: 

94 ref_type: The reference type. 

95 text: The text to parse. 

96 

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)) 

104 

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. 

107 

108 Arguments: 

109 ref_type: The reference type. 

110 match_dict: The matched groups. 

111 

112 Returns: 

113 The built URL. 

114 """ 

115 return self.REF[ref_type].url_string.format(**match_dict) 

116 

117 @abstractmethod 

118 def get_tag_url(self, tag: str) -> str: 

119 """Get the URL for a git tag. 

120 

121 Arguments: 

122 tag: The git tag. 

123 

124 Returns: 

125 The tag URL. 

126 """ 

127 raise NotImplementedError 

128 

129 @abstractmethod 

130 def get_compare_url(self, base: str, target: str) -> str: 

131 """Get the URL for a tag comparison. 

132 

133 Arguments: 

134 base: The base tag. 

135 target: The target tag. 

136 

137 Returns: 

138 The comparison URL. 

139 """ 

140 raise NotImplementedError 

141 

142 

143class GitHub(ProviderRefParser): 

144 """A parser for the GitHub references.""" 

145 

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}" 

149 

150 commit_min_length = 8 

151 commit_max_length = 40 

152 

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 } 

183 

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) 

191 

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) 

194 

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}"}) 

197 

198 

199class GitLab(ProviderRefParser): 

200 """A parser for the GitLab references.""" 

201 

202 url: str = "https://gitlab.com" 

203 project_url: str = "{base_url}/{namespace}/{project}" 

204 tag_url: str = "{base_url}/{namespace}/{project}/tags/{ref}" 

205 

206 commit_min_length = 8 

207 commit_max_length = 40 

208 

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 } 

277 

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) 

287 

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) 

290 

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}"}) 

293 

294 

295class Bitbucket(ProviderRefParser): 

296 """A parser for the Bitbucket references.""" 

297 

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}" 

301 

302 commit_min_length = 8 

303 commit_max_length = 40 

304 

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 } 

342 

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) 

350 

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) 

353 

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}"})