-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathversion_tool.py
More file actions
401 lines (335 loc) · 13.4 KB
/
version_tool.py
File metadata and controls
401 lines (335 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
#!/usr/bin/env python
"""AST-based version tool for PyBuilder build.py files.
Reads and modifies the `version` variable in a PyBuilder build.py using AST
analysis to ensure correctness. No external dependencies (stdlib only).
Compatible with Python 3.9 - 3.15+.
"""
import ast
import json
import re
import sys
# PEP 440 version regex, derived from the spec.
# Accepts bare `.dev` (no trailing number) as a PyBuilder convention.
_PEP440_RE = re.compile(
r"^(?:(?P<epoch>[0-9]+)!)?" # epoch
r"(?P<release>[0-9]+(?:\.[0-9]+)*)" # release segment
r"(?P<pre>" # pre-release
r"[-_.]?"
r"(?P<pre_type>a|alpha|b|beta|c|rc|preview)"
r"[-_.]?"
r"(?P<pre_num>[0-9]+)?"
r")?"
r"(?P<post>" # post-release
r"(?:-(?P<post_num1>[0-9]+))"
r"|"
r"(?:[-_.]?(?:post|rev|r)[-_.]?(?P<post_num2>[0-9]+)?)"
r")?"
r"(?P<dev>" # dev release
r"[-_.]?dev[-_.]?(?P<dev_num>[0-9]+)?"
r")?"
r"$",
re.IGNORECASE,
)
# Canonical pre-release type mapping
_PRE_TYPE_MAP = {
"a": "a", "alpha": "a",
"b": "b", "beta": "b",
"c": "rc", "rc": "rc", "preview": "rc",
}
def _parse_pep440(version_str):
"""Parse a PEP 440 version string into components.
Returns a dict with keys: epoch, release (list of ints), pre_type, pre_num,
post_num, dev_num, is_dev, raw.
Raises ValueError if the version doesn't match PEP 440.
"""
m = _PEP440_RE.match(version_str)
if not m:
raise ValueError("Not a valid PEP 440 version: %r" % version_str)
epoch = int(m.group("epoch")) if m.group("epoch") is not None else None
release = [int(x) for x in m.group("release").split(".")]
pre_type = None
pre_num = None
if m.group("pre") is not None:
pre_type = _PRE_TYPE_MAP[m.group("pre_type").lower()]
pre_num = int(m.group("pre_num")) if m.group("pre_num") is not None else 0
post_num = None
if m.group("post") is not None:
raw_post = m.group("post_num1")
if raw_post is None:
raw_post = m.group("post_num2")
post_num = int(raw_post) if raw_post is not None else 0
dev_num = None
is_dev = m.group("dev") is not None
if is_dev:
dev_num = int(m.group("dev_num")) if m.group("dev_num") is not None else None
return {
"epoch": epoch,
"release": release,
"pre_type": pre_type,
"pre_num": pre_num,
"post_num": post_num,
"dev_num": dev_num,
"is_dev": is_dev,
"raw": version_str,
}
def _format_version(parts):
"""Format parsed version parts back into a canonical PEP 440 string.
Uses bare `.dev` (no number) for the dev segment, matching PyBuilder convention.
"""
result = ""
if parts["epoch"] is not None:
result += "%d!" % parts["epoch"]
result += ".".join(str(x) for x in parts["release"])
if parts["pre_type"] is not None:
result += "%s%d" % (parts["pre_type"], parts["pre_num"])
if parts["post_num"] is not None:
result += ".post%d" % parts["post_num"]
if parts["is_dev"]:
result += ".dev"
return result
def _find_version_assignment(source):
"""Find the last top-level `version = "..."` assignment in source.
Returns (node, value_str) or raises an error.
Implements the full safety analysis described in the plan.
"""
try:
tree = ast.parse(source)
except SyntaxError as e:
raise ValueError("Failed to parse build.py: %s" % e)
# Collect top-level assignments to `version`
assignments = [] # list of (index, node)
for i, node in enumerate(tree.body):
if isinstance(node, ast.AugAssign):
if isinstance(node.target, ast.Name) and node.target.id == "version":
raise ValueError(
"Augmented assignment to `version` (line %d) is not supported"
% node.lineno
)
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "version":
assignments.append((i, node))
break
elif isinstance(node, ast.AnnAssign):
if isinstance(node.target, ast.Name) and node.target.id == "version":
if node.value is not None:
assignments.append((i, node))
if not assignments:
raise ValueError(
"No top-level `version = \"...\"` assignment found in build.py"
)
last_idx, last_node = assignments[-1]
# Check that the value is a string literal
if isinstance(last_node, ast.Assign):
value_node = last_node.value
else:
value_node = last_node.value
if not isinstance(value_node, ast.Constant) or not isinstance(value_node.value, str):
raise ValueError(
"version assignment on line %d is not a string literal"
% last_node.lineno
)
# Post-assignment safety scan: check all nodes after the last assignment
for j in range(last_idx + 1, len(tree.body)):
_safety_scan(tree.body[j])
return last_node, value_node
def _safety_scan(node):
"""Recursively scan an AST node for constructs that could modify `version`."""
for child in ast.walk(node):
# Assignment to `version` inside control flow
if isinstance(child, ast.Assign):
for target in child.targets:
if isinstance(target, ast.Name) and target.id == "version":
raise ValueError(
"Conditional/nested assignment to `version` on line %d "
"makes the version unprovable" % child.lineno
)
if isinstance(child, ast.AugAssign):
if isinstance(child.target, ast.Name) and child.target.id == "version":
raise ValueError(
"Augmented assignment to `version` on line %d "
"makes the version unprovable" % child.lineno
)
if isinstance(child, ast.AnnAssign):
if (isinstance(child.target, ast.Name) and child.target.id == "version"
and child.value is not None):
raise ValueError(
"Annotated assignment to `version` on line %d "
"makes the version unprovable" % child.lineno
)
# del version
if isinstance(child, ast.Delete):
for target in child.targets:
if isinstance(target, ast.Name) and target.id == "version":
raise ValueError(
"`del version` on line %d makes the version unprovable"
% child.lineno
)
# exec() / eval() calls
if isinstance(child, ast.Call):
func = child.func
if isinstance(func, ast.Name) and func.id in ("exec", "eval"):
raise ValueError(
"`%s()` call on line %d makes the version unprovable"
% (func.id, child.lineno)
)
# globals(), vars(), locals()
if isinstance(func, ast.Name) and func.id in ("globals", "vars", "locals"):
raise ValueError(
"`%s()` call on line %d makes the version unprovable"
% (func.id, child.lineno)
)
# from X import *
if isinstance(child, ast.ImportFrom):
for alias in child.names:
if alias.name == "*":
raise ValueError(
"Star import on line %d makes the version unprovable"
% child.lineno
)
def _replace_version_in_source(source, value_node, new_version_str):
"""Replace the version string literal in source using AST positions.
Returns the modified source text.
"""
lines = source.splitlines(True)
# AST positions are 1-based for lines, 0-based for columns
start_line = value_node.lineno - 1
start_col = value_node.col_offset
end_line = value_node.end_lineno - 1
end_col = value_node.end_col_offset
# Extract the original string literal to determine quote style
if start_line == end_line:
original_literal = lines[start_line][start_col:end_col]
else:
# Multi-line string literal (unusual for version but handle it)
parts = [lines[start_line][start_col:]]
for ln in range(start_line + 1, end_line):
parts.append(lines[ln])
parts.append(lines[end_line][:end_col])
original_literal = "".join(parts)
# Determine quote style from the original literal
if original_literal.startswith('"""') or original_literal.startswith("'''"):
quote = original_literal[:3]
elif original_literal.startswith('"'):
quote = '"'
elif original_literal.startswith("'"):
quote = "'"
else:
quote = '"'
new_literal = quote + new_version_str + quote
# Reconstruct the source with the replacement
if start_line == end_line:
line = lines[start_line]
lines[start_line] = line[:start_col] + new_literal + line[end_col:]
else:
# Replace multi-line span
lines[start_line] = lines[start_line][:start_col] + new_literal + lines[end_line][end_col:]
del lines[start_line + 1:end_line + 1]
return "".join(lines)
def _validate_replacement(new_source, expected_version):
"""Re-parse the modified source and confirm the version matches."""
_, value_node = _find_version_assignment(new_source)
actual = value_node.value
if actual != expected_version:
raise ValueError(
"Verification failed: expected version %r but got %r after replacement"
% (expected_version, actual)
)
def cmd_read(build_py_path):
"""Read and print the version from build.py."""
with open(build_py_path, "r") as f:
source = f.read()
_, value_node = _find_version_assignment(source)
version_str = value_node.value
parsed = _parse_pep440(version_str)
result = {
"version": version_str,
"is_dev": parsed["is_dev"],
"line": value_node.lineno,
}
print(json.dumps(result))
def cmd_set_release(build_py_path, explicit_version=None):
"""Set the release version by stripping .dev or setting an explicit version."""
with open(build_py_path, "r") as f:
source = f.read()
_, value_node = _find_version_assignment(source)
old_version = value_node.value
old_parsed = _parse_pep440(old_version)
if explicit_version is not None:
# Validate the explicit version
explicit_parsed = _parse_pep440(explicit_version)
if explicit_parsed["is_dev"]:
raise ValueError(
"Explicit release version %r has a dev segment; "
"release versions must not be dev versions" % explicit_version
)
new_version = explicit_version
else:
# Strip .dev suffix
if not old_parsed["is_dev"]:
raise ValueError(
"Current version %r does not have a .dev segment; "
"cannot strip it" % old_version
)
old_parsed["is_dev"] = False
old_parsed["dev_num"] = None
new_version = _format_version(old_parsed)
new_source = _replace_version_in_source(source, value_node, new_version)
_validate_replacement(new_source, new_version)
with open(build_py_path, "w") as f:
f.write(new_source)
result = {"old_version": old_version, "new_version": new_version}
print(json.dumps(result))
def cmd_bump_dev(build_py_path):
"""Bump the rightmost numeric segment and append .dev."""
with open(build_py_path, "r") as f:
source = f.read()
_, value_node = _find_version_assignment(source)
old_version = value_node.value
parsed = _parse_pep440(old_version)
if parsed["is_dev"]:
raise ValueError(
"Current version %r already has a .dev segment; "
"bump-dev expects a release version" % old_version
)
# Bump the rightmost numeric segment
if parsed["post_num"] is not None:
parsed["post_num"] += 1
elif parsed["pre_type"] is not None:
parsed["pre_num"] += 1
else:
# Bump the last release segment
parsed["release"][-1] += 1
parsed["is_dev"] = True
new_version = _format_version(parsed)
new_source = _replace_version_in_source(source, value_node, new_version)
_validate_replacement(new_source, new_version)
with open(build_py_path, "w") as f:
f.write(new_source)
result = {"old_version": old_version, "new_version": new_version}
print(json.dumps(result))
def main():
if len(sys.argv) < 3:
print(
"Usage: %s {read|set-release|bump-dev} BUILD_PY [VERSION]" % sys.argv[0],
file=sys.stderr,
)
sys.exit(2)
command = sys.argv[1]
build_py_path = sys.argv[2]
try:
if command == "read":
cmd_read(build_py_path)
elif command == "set-release":
explicit_version = sys.argv[3] if len(sys.argv) > 3 else None
cmd_set_release(build_py_path, explicit_version)
elif command == "bump-dev":
cmd_bump_dev(build_py_path)
else:
print("Unknown command: %s" % command, file=sys.stderr)
sys.exit(2)
except (ValueError, OSError) as e:
print("ERROR: %s" % e, file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()