# -*- 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 from 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", "-----|---------|------------", "", "
", "
"] 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 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 = "" for k in PackageIndex.HEADERS: result += compare_markdown_sections(md1, md2, k, k, file1, file2) + "\n" return result elif mode == "section": return 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 find_previous_version(target_version, searchdir=None, flavor="", architecture=64): search_dir = Path(searchdir) if searchdir else CHANGELOGS_DIR pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-([0-9\.]+)\.(txt|md)") versions = [pattern.match(f).group(1) for f in os.listdir(search_dir) if pattern.match(f)] versions = [v for v in versions if version.parse(v) < version.parse(target_version)] return max(versions, key=version.parse, default=target_version) 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"# WinPython {architecture}bit {version2}{flavor} vs {version1}{flavor1}\n" 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(version, searchdir, flavor="", architecture=64, basedir=None): """Copy all changelogs for a major.minor version into basedir.""" basever = ".".join(str(version).split(".")[:2]) pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-{basever}[0-9\.]*\.(txt|md)") dest = Path(basedir) for fname in os.listdir(searchdir): if pattern.match(fname): 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(): print("Usage:") print(" python diff.py file1.md file2.md") print(" - Compare all sections of two markdown files.") print(" python diff.py file1.md file2.md --section header1 header2") print(" - Compare section 'header1' of file1 with section 'header2' of file2.") print(" python diff.py file1.md file2.md --pairs header1a header2a [header1b header2b ...]") print(" - Compare pairs of sections. Example: python diff.py f1.md f2.md --pairs python wheelhouse tools tools") print(" python diff.py [searchdir] [flavor] [architecture]") print(" - Compare WinPython markdown changelogs by version (historical mode).") print(" python diff.py --write-changelog [searchdir] [flavor] [architecture] [basedir]") print(" - Write changelog between version1 and version2 to file (and optionally copy to basedir).") if __name__ == "__main__": 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 [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()