Coverage for src/hatch_ci/tools.py: 91%
156 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-01 21:34 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-01 21:34 +0000
1from __future__ import annotations
3import ast
4import json
5import re
6from pathlib import Path
7from typing import Any
9from . import scm
12class ToolsError(Exception):
13 pass
16class ValidationError(ToolsError):
17 pass
20class InvalidVersionError(ToolsError):
21 pass
24class MissingVariableError(ToolsError):
25 pass
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
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
43 @property
44 def explain(self):
45 return self._strip(self._explain)
47 @property
48 def hint(self):
49 return self._strip(self._hint)
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)
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
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=}")
74def indent(txt: str, pre: str = " " * 2) -> str:
75 "simple text indentation"
77 from textwrap import dedent
79 txt = dedent(txt)
80 if txt.endswith("\n"):
81 last_eol = "\n"
82 txt = txt[:-1]
83 else:
84 last_eol = ""
86 result = pre + txt.replace("\n", "\n" + pre) + last_eol
87 return result if result.strip() else result.strip()
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)]
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
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
104 Returns:
105 None or str: the variable value if found or None
107 Raises:
108 MissingVariable: if the var is not found and abort is True
110 Notes:
111 this uses ast to parse path, so it doesn't load the module
112 """
114 class V(ast.NodeVisitor):
115 def __init__(self, keys):
116 self.keys = keys
117 self.result = {}
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)
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)
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
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
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 = []
170 src = Path(path)
171 if not src.exists() and create:
172 src.parent.mkdir(parents=True, exist_ok=True)
173 src.touch()
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}"'
193 with Path(path).open("w") as fp:
194 fp.write(txt)
195 return fixed, txt
198def bump_version(version: str, mode: str) -> str:
199 """given a version str will bump it according to mode
201 Arguments:
202 version: text in the N.M.O form
203 mode: major, minor or micro
205 Returns:
206 increased text
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)
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
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
235 Returns:
236 str: the new version for the package
237 """
239 path = Path(initfile)
240 repo = scm.lookup(path)
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__")
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
258 version = current = get_module_var(path, "__version__")
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+)?$")
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']}"
277 short = gdata["sha"] + ("*" if dirty else "")
279 set_module_var(path, "__version__", version)
280 set_module_var(path, "__hash__", short)
281 return version