-
Notifications
You must be signed in to change notification settings - Fork 351
Expand file tree
/
Copy pathdiff.py
More file actions
262 lines (238 loc) · 12.3 KB
/
Copy pathdiff.py
File metadata and controls
262 lines (238 loc) · 12.3 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
# -*- coding: utf-8 -*-
#
# WinPython diff.py script (streamlined, with historical and flexible modes)
# Copyright © 2013 Pierre Raybaut
# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/
# Licensed under the terms of the MIT License
import os
import re
import sys
import shutil
from pathlib import Path
try:
from packaging import version
except ModuleNotFoundError:
from pip._vendor.packaging import version
from . import utils
CHANGELOGS_DIR = Path(__file__).parent.parent / "changelogs"
class Package:
PATTERNS = [
r"\[([\w\-\:\/\.\_]+)\]\(([^)]+)\) \| ([^\|]*) \| ([^\|]*)", # SourceForge
r"\[([\w\-\:\/\.\_]+) ([^\]\ ]+)\] \| ([^\|]*) \| ([^\|]*)" # Google Code
]
def __init__(self, text=None):
self.name = self.url = self.version = self.description = None
if text:
self.from_text(text)
def from_text(self, text):
for pattern in self.PATTERNS:
match = re.match(pattern, text)
if match:
self.name, self.url, self.version, self.description = match.groups()
return
raise ValueError(f"Unrecognized package line format: {text}")
class PackageIndex:
HEADERS = {"tools": "### Tools", "python": "### Python packages", "wheelhouse": "### WheelHouse packages"}
BLANKS = ["Name | Version | Description", "-----|---------|------------", "", "<details>", "</details>"]
def __init__(self, content):
self.packages = {k: {} for k in self.HEADERS}
self._parse_index(content)
def _parse_index(self, text):
current = None
for line in text.splitlines():
if line in self.HEADERS.values():
current = [k for k, v in self.HEADERS.items() if v == line][0]
continue
if line.strip() in self.BLANKS:
continue
if current:
try:
pkg = Package(line)
self.packages[current][pkg.name] = pkg
except Exception:
continue
def compare_packages(old, new):
def normalize(d): return {k.replace("-", "_").lower(): v for k, v in d.items()}
old, new = normalize(old), normalize(new)
added = [new[k] for k in new if k not in old]
upgraded = [new[k] for k in new if k in old and new[k].version != old[k].version]
removed = [old[k] for k in old if k not in new]
output = ""
if added:
output += "\nNew packages:\n" + "".join(f" * {p.name} {p.version} ({p.description})\n" for p in added)
if upgraded:
output += "\nUpgraded packages:\n" + "".join(f" * {p.name} {old[p.name].version} → {p.version} ({p.description})\n" for p in upgraded if p.name in old)
if removed:
output += "\nRemoved packages:\n" + "".join(f" * {p.name} {p.version} ({p.description})\n" for p in removed)
return output or "\nNo differences found.\n"
def compare_markdown_sections(md1, md2, header1="python", header2="python", label1="Input1", label2="Input2"):
pkgs1 = PackageIndex(md1).packages
pkgs2 = PackageIndex(md2).packages
diff = compare_packages(pkgs1[header1], pkgs2[header2])
# If comparing the same section, use the historical header
if header1 == header2 and header1 in PackageIndex.HEADERS:
title = PackageIndex.HEADERS[header1]
else:
title = f"## {label1} [{header1}] vs {label2} [{header2}]"
return f"{title}\n\n{diff}"
def compare_markdown_section_pairs(md1, md2, header_pairs, label1="Input1", label2="Input2"):
pkgs1 = PackageIndex(md1).packages
pkgs2 = PackageIndex(md2).packages
text = f"# {label1} vs {label2} section-pairs comparison\n"
for h1, h2 in header_pairs:
diff = compare_packages(pkgs1[h1], pkgs2[h2])
if diff.strip() and diff != "No differences found.\n":
text += f"\n## {label1} [{h1}] vs {label2} [{h2}]\n\n{diff}\n"
return text
def index_title(file1, file2):
"""## Changes from <first index> to <second index>.
Comparing two files says nothing about which two, and the direction
matters: every upgrade line is written "old -> new", so a reader who has
the pair the wrong way round sees every upgrade as a downgrade. The version
mode gets this from the versions it was handed; the file mode has only the
names, so it uses them, without directory or extension.
"""
name = lambda f: Path(f).stem
return f"## Changes from {name(file1)} to {name(file2)}\n"
def compare_files(file1, file2, mode="full", header1=None, header2=None, header_pairs=None):
with open(file1, encoding=utils.guess_encoding(file1)[0]) as f1, \
open(file2, encoding=utils.guess_encoding(file2)[0]) as f2:
md1, md2 = f1.read(), f2.read()
if mode == "full":
result = index_title(file1, file2) + "\n"
for k in PackageIndex.HEADERS:
result += compare_markdown_sections(md1, md2, k, k, file1, file2) + "\n"
return result
elif mode == "section":
return (index_title(file1, file2) + "\n"
+ compare_markdown_sections(md1, md2, header1, header2, file1, file2))
elif mode == "pairs":
return compare_markdown_section_pairs(md1, md2, header_pairs, file1, file2)
else:
raise ValueError("Unknown mode.")
# --- ORIGINAL/HISTORICAL VERSION-TO-VERSION COMPARISON ---
def changelog_versions(searchdir, flavor="", architecture=64):
"""The changelogs on disk, as (parsed version, version string, file name).
The file name is the only source of the version, so the pattern has to
accept everything a WinPython version can be. It used to be digits and
dots, which silently dropped any release level: 3.14.7.1b1 is a perfectly
ordinary PEP 440 version -- it sorts after 3.14.7.0 and before 3.14.7.1 --
but no b1 changelog was ever visible to the comparison. Now the name is
handed to the version parser, which also drops the _History companions and
anything else in the directory that is not a changelog, since they do not
parse as versions.
"""
pattern = re.compile(rf"WinPython{re.escape(flavor)}-{architecture}bit-(.+)\.(?:txt|md)$")
found = []
for name in os.listdir(searchdir):
match = pattern.match(name)
if not match:
continue
try:
found.append((version.parse(match.group(1)), match.group(1), name))
except version.InvalidVersion:
continue
return found
def find_previous_version(target_version, searchdir=None, flavor="", architecture=64):
search_dir = Path(searchdir) if searchdir else CHANGELOGS_DIR
target = version.parse(target_version)
earlier = [
(parsed, raw) for parsed, raw, _ in changelog_versions(search_dir, flavor, architecture)
if parsed < target
]
return max(earlier, default=(None, target_version))[1]
def load_version_markdown(version, searchdir, flavor="", architecture=64):
filename = Path(searchdir) / f"WinPython{flavor}-{architecture}bit-{version}.md"
if not filename.exists():
raise FileNotFoundError(f"Changelog not found: {filename}")
with open(filename, "r", encoding=utils.guess_encoding(filename)[0]) as f:
return f.read()
def compare_package_indexes(version2, version1=None, searchdir=None, flavor="", flavor1=None, architecture=64):
searchdir = Path(searchdir) if searchdir else CHANGELOGS_DIR
version1 = version1 or find_previous_version(version2, searchdir, flavor, architecture)
flavor1 = flavor1 or flavor
md1 = load_version_markdown(version1, searchdir, flavor1, architecture)
md2 = load_version_markdown(version2, searchdir, flavor, architecture)
result = (
f"## History of changes for WinPython-{architecture}bit {version2 + flavor}\r\n\r\n"
f"The following changes were made to WinPython-{architecture}bit distribution since version {version1 + flavor1}.\n\n\n"
"\n"
)
for k in PackageIndex.HEADERS:
result += compare_markdown_sections(md1, md2, k, k, version1, version2) + "\n"
return result+ "\n\n* * *\n"
def copy_changelogs(target_version, searchdir, flavor="", architecture=64, basedir=None):
"""Copy all changelogs for a major.minor version into basedir.
Selected on the parsed version rather than a string prefix, so a release
level comes along and 3.1 cannot pick up 3.15.
"""
basever = version.parse(str(target_version)).release[:2]
dest = Path(basedir)
for parsed, _, fname in changelog_versions(searchdir, flavor, architecture):
if parsed.release[:2] == basever:
shutil.copyfile(Path(searchdir) / fname, dest / fname)
def write_changelog(version2, version1=None, searchdir=None, flavor="", architecture=64, basedir=None):
"""Write changelog between version1 and version2 of WinPython."""
searchdir = Path(searchdir) if searchdir else CHANGELOGS_DIR
if basedir:
copy_changelogs(version2, searchdir, flavor, architecture, basedir)
changelog = compare_package_indexes(version2, version1, searchdir, flavor, architecture=architecture)
output_file = searchdir / f"WinPython{flavor}-{architecture}bit-{version2}_History.md"
with open(output_file, "w", encoding="utf-8") as f:
f.write(changelog)
if basedir:
shutil.copyfile(output_file, Path(basedir) / output_file.name)
def print_usage():
# "python diff.py" cannot work -- the relative imports need the package --
# so every line says how it is really run
print("Usage:")
print(" wppm -diff file1.md file2.md (or: python -m wppm.diff file1.md file2.md)")
print(" - Compare all sections of two package indexes, in any order or flavor.")
print(" python -m wppm.diff file1.md file2.md --section header1 header2")
print(" - Compare section 'header1' of file1 with section 'header2' of file2.")
print(" python -m wppm.diff file1.md file2.md --pairs header1a header2a [header1b header2b ...]")
print(" - Compare pairs of sections. Example: --pairs python wheelhouse tools tools")
print(" python -m wppm.diff <version2> <version1> [searchdir] [flavor] [architecture]")
print(" - Compare WinPython package indexes by version, from a changelogs directory.")
print(" python -m wppm.diff --write-changelog <version2> <version1> [searchdir] [flavor] [architecture] [basedir]")
print(" - Write that comparison to a file (and optionally copy it to basedir).")
if __name__ == "__main__":
# every upgraded package prints an arrow, and a Windows console is cp1252,
# so the default encoding turned "compare two releases" into a traceback
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
args = sys.argv
if len(args) >= 3 and all(arg.lower().endswith('.md') for arg in args[1:3]):
file1, file2 = args[1], args[2]
if len(args) == 3:
print(compare_files(file1, file2))
elif args[3] == "--section" and len(args) >= 6:
h1, h2 = args[4], args[5]
print(compare_files(file1, file2, mode="section", header1=h1, header2=h2))
elif args[3] == "--pairs" and len(args) > 4 and len(args[4:]) % 2 == 0:
pairs = list(zip(args[4::2], args[5::2]))
print(compare_files(file1, file2, mode="pairs", header_pairs=pairs))
else:
print_usage()
elif len(args) >= 2 and args[1] == "--write-changelog":
# Usage: --write-changelog <version2> <version1> [searchdir] [flavor] [architecture] [basedir]
if len(args) < 4:
print_usage()
sys.exit(1)
version2 = args[2]
version1 = args[3]
searchdir = args[4] if len(args) > 4 else CHANGELOGS_DIR
flavor = args[5] if len(args) > 5 else ""
architecture = int(args[6]) if len(args) > 6 else 64
basedir = args[7] if len(args) > 7 else None
write_changelog(version2, version1, searchdir, flavor, architecture, basedir)
print(f"Changelog written for {version2} vs {version1}.")
elif len(args) >= 3:
version2 = args[1]
version1 = args[2] if len(args) > 2 and not args[2].endswith('.md') else None
searchdir = args[3] if len(args) > 3 else CHANGELOGS_DIR
flavor = args[4] if len(args) > 4 else ""
architecture = int(args[5]) if len(args) > 5 else 64
print(compare_package_indexes(version2, version1, searchdir, flavor, architecture=architecture))
else:
print_usage()