Coverage for src/hatch_ci/tools.py: 91%

156 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-01 19:54 +0000

1from __future__ import annotations 

2 

3import ast 

4import json 

5import re 

6from pathlib import Path 

7from typing import Any 

8 

9from . import scm 

10 

11 

12class ToolsError(Exception): 

13 pass 

14 

15 

16class ValidationError(ToolsError): 

17 pass 

18 

19 

20class InvalidVersionError(ToolsError): 

21 pass 

22 

23 

24class MissingVariableError(ToolsError): 

25 pass 

26 

27 

28class AbortExecutionError(Exception): 

29 @staticmethod 

30 def _strip(txt): 

31 txt = txt or "" 

32 txt = txt[1:] if txt.startswith("\n") else txt 

33 txt = indent(txt, pre="") 

34 return txt[:-1] if txt.endswith("\n") else txt 

35 

36 def __init__( 

37 self, message: str, explain: str | None = None, hint: str | None = None 

38 ): 

39 self.message = message.strip() 

40 self._explain = explain 

41 self._hint = hint 

42 

43 @property 

44 def explain(self): 

45 return self._strip(self._explain) 

46 

47 @property 

48 def hint(self): 

49 return self._strip(self._hint) 

50 

51 def __str__(self): 

52 result = [self.message] 

53 if self.explain: 

54 result.append(indent("\n" + self.explain, pre=" " * 2)[2:]) 

55 if self.hint: 

56 result.extend(["\nhint:", indent("\n" + self.hint, pre=" " * 2)[2:]]) 

57 return "".join(result) 

58 

59 

60def urmtree(path: Path): 

61 "universal (win|*nix) rmtree" 

62 from os import name 

63 from shutil import rmtree 

64 from stat import S_IWUSR 

65 

66 if name == "nt": 66 ↛ 67line 66 didn't jump to line 67, because the condition on line 66 was never true

67 for p in path.rglob("*"): 

68 p.chmod(S_IWUSR) 

69 rmtree(path, ignore_errors=True) 

70 if path.exists(): 70 ↛ 71line 70 didn't jump to line 71, because the condition on line 70 was never true

71 raise RuntimeError(f"cannot remove {path=}") 

72 

73 

74def indent(txt: str, pre: str = " " * 2) -> str: 

75 "simple text indentation" 

76 

77 from textwrap import dedent 

78 

79 txt = dedent(txt) 

80 if txt.endswith("\n"): 

81 last_eol = "\n" 

82 txt = txt[:-1] 

83 else: 

84 last_eol = "" 

85 

86 result = pre + txt.replace("\n", "\n" + pre) + last_eol 

87 return result if result.strip() else result.strip() 

88 

89 

90def list_of_paths(paths: str | Path | list[str | Path]) -> list[Path]: 

91 return [Path(s) for s in ([paths] if isinstance(paths, (str, Path)) else paths)] 

92 

93 

94def get_module_var( 

95 path: Path | str, var: str = "__version__", abort=True 

96) -> str | None: 

97 """extract from a python module in path the module level <var> variable 

98 

99 Args: 

100 path (str,Path): python module file to parse using ast (no code-execution) 

101 var (str): module level variable name to extract 

102 abort (bool): raise MissingVariable if var is not present 

103 

104 Returns: 

105 None or str: the variable value if found or None 

106 

107 Raises: 

108 MissingVariable: if the var is not found and abort is True 

109 

110 Notes: 

111 this uses ast to parse path, so it doesn't load the module 

112 """ 

113 

114 class V(ast.NodeVisitor): 

115 def __init__(self, keys): 

116 self.keys = keys 

117 self.result = {} 

118 

119 def visit_Module(self, node): # noqa: N802 

120 # we extract the module level variables 

121 for subnode in ast.iter_child_nodes(node): 

122 if not isinstance(subnode, ast.Assign): 122 ↛ 123line 122 didn't jump to line 123, because the condition on line 122 was never true

123 continue 

124 for target in subnode.targets: 

125 if target.id not in self.keys: 

126 continue 

127 if not isinstance(subnode.value, (ast.Num, ast.Str, ast.Constant)): 

128 raise ValidationError( 

129 f"cannot extract non Constant variable " 

130 f"{target.id} ({type(subnode.value)})" 

131 ) 

132 if isinstance(subnode.value, ast.Str): 

133 value = subnode.value.s 

134 elif isinstance(subnode.value, ast.Num): 134 ↛ 137line 134 didn't jump to line 137, because the condition on line 134 was never false

135 value = subnode.value.n 

136 else: 

137 value = subnode.value.value 

138 self.result[target.id] = value 

139 return self.generic_visit(node) 

140 

141 v = V({var}) 

142 path = Path(path) 

143 if path.exists(): 143 ↛ 146line 143 didn't jump to line 146, because the condition on line 143 was never false

144 tree = ast.parse(Path(path).read_text()) 

145 v.visit(tree) 

146 if var not in v.result and abort: 

147 raise MissingVariableError(f"cannot find {var} in {path}", path, var) 

148 return v.result.get(var, None) 

149 

150 

151def set_module_var( 

152 path: str | Path, var: str, value: Any, create: bool = True 

153) -> tuple[Any, str]: 

154 """replace var in path with value 

155 

156 Args: 

157 path (str,Path): python module file to parse 

158 var (str): module level variable name to extract 

159 value (None or Any): if not None replace var in initfile 

160 create (bool): create path if not present 

161 

162 Returns: 

163 (str, str) the (<previous-var-value|None>, <the new text>) 

164 """ 

165 # module level var 

166 expr = re.compile(f"^{var}\\s*=\\s*['\\\"](?P<value>[^\\\"']*)['\\\"]") 

167 fixed = None 

168 lines = [] 

169 

170 src = Path(path) 

171 if not src.exists() and create: 

172 src.parent.mkdir(parents=True, exist_ok=True) 

173 src.touch() 

174 

175 input_lines = src.read_text().split("\n") 

176 for line in reversed(input_lines): 

177 if fixed: 

178 lines.append(line) 

179 continue 

180 match = expr.search(line) 

181 if match: 

182 fixed = match.group("value") 

183 if value is not None: 183 ↛ 186line 183 didn't jump to line 186, because the condition on line 183 was never false

184 x, y = match.span(1) 

185 line = line[:x] + value + line[y:] 

186 lines.append(line) 

187 txt = "\n".join(reversed(lines)) 

188 if not fixed and create: 

189 if txt and txt[-1] != "\n": 

190 txt += "\n" 

191 txt += f'{var} = "{value}"' 

192 

193 with Path(path).open("w") as fp: 

194 fp.write(txt) 

195 return fixed, txt 

196 

197 

198def bump_version(version: str, mode: str) -> str: 

199 """given a version str will bump it according to mode 

200 

201 Arguments: 

202 version: text in the N.M.O form 

203 mode: major, minor or micro 

204 

205 Returns: 

206 increased text 

207 

208 >>> bump_version("1.0.3", "micro") 

209 "1.0.4" 

210 >>> bump_version("1.0.3", "minor") 

211 "1.1.0" 

212 """ 

213 newver = [int(n) for n in version.split(".")] 

214 if mode == "major": 

215 newver[-3] += 1 

216 newver[-2] = 0 

217 newver[-1] = 0 

218 elif mode == "minor": 

219 newver[-2] += 1 

220 newver[-1] = 0 

221 elif mode == "micro": 

222 newver[-1] += 1 

223 return ".".join(str(v) for v in newver) 

224 

225 

226def update_version( 

227 initfile: str | Path, github_dump: str | None = None, abort: bool = True 

228) -> str | None: 

229 """extracts version information from github_dump and updates initfile in-place 

230 

231 Args: 

232 initfile (str, Path): path to the __init__.py file with a __version__ variable 

233 github_dump (str): the os.getenv("GITHUB_DUMP") value 

234 

235 Returns: 

236 str: the new version for the package 

237 """ 

238 

239 path = Path(initfile) 

240 repo = scm.lookup(path) 

241 

242 if not (repo or github_dump): 242 ↛ 243line 242 didn't jump to line 243, because the condition on line 242 was never true

243 if abort: 

244 raise scm.InvalidGitRepoError(f"cannot find a valid git repo for {path}") 

245 return get_module_var(path, "__version__") 

246 

247 if not github_dump and repo: 

248 gdata = { 

249 "ref": repo.head.name, 

250 "sha": repo.head.target.hex[:7], 

251 "run_number": 0, 

252 } 

253 dirty = bool(repo.status()) 

254 else: 

255 gdata = json.loads(github_dump) if isinstance(github_dump, str) else github_dump 

256 dirty = False 

257 

258 version = current = get_module_var(path, "__version__") 

259 

260 expr = re.compile(r"/(?P<what>beta|release)/(?P<version>\d+([.]\d+)*)$") 

261 expr1 = re.compile(r"(?P<version>\d+([.]\d+)*)(?P<num>b\d+)?$") 

262 

263 if match := expr.search(gdata["ref"]): 

264 # setuptools double calls the update_version, 

265 # this fixes the issue 

266 match1 = expr1.search(current or "") 

267 if not match1: 267 ↛ 268line 267 didn't jump to line 268, because the condition on line 267 was never true

268 raise InvalidVersionError(f"cannot parse current version '{current}'") 

269 if match1.group("version") != match.group("version"): 

270 raise InvalidVersionError( 

271 f"building package for {current} from '{gdata['ref']}' " 

272 f"branch ({match.groupdict()} mismatch {match1.groupdict()})" 

273 ) 

274 if match.group("what") == "beta": 

275 version = f"{match1.group('version')}b{gdata['run_number']}" 

276 

277 short = gdata["sha"] + ("*" if dirty else "") 

278 

279 set_module_var(path, "__version__", version) 

280 set_module_var(path, "__hash__", short) 

281 return version