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

1 from __future__ import annotations

2

3 import ast

4 import json

5 import re

6 from pathlib import Path

7 from typing import Any

8

9 from . import scm

10

11

12 class ToolsError(Exception):

13 pass

14

15

16 class ValidationError(ToolsError):

17 pass

18

19

20 class InvalidVersionError(ToolsError):

21 pass

22

23

24 class MissingVariableError(ToolsError):

25 pass

26

27

28 class 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

60 def 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

74 def 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

90 def 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

94 def 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

151 def 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

198 def 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

226 def 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