|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Sort the PublicApiAnalyzers tracking files, and optionally promote unshipped API to shipped. |
| 3 | +
|
| 4 | +Two jobs, deliberately separate: |
| 5 | +
|
| 6 | +*Sorting* (always). The analyzer does not care about order and its code fix appends, so these files |
| 7 | +accrete in whatever order entries were added, and drift out of any order they are put into. Nothing |
| 8 | +keeps them sorted between runs - this is what does, run at a moment somebody is already editing them. |
| 9 | +
|
| 10 | +*Promotion* (``--promote`` only). Moves every entry from ``PublicAPI.Unshipped.txt`` into its sibling |
| 11 | +``PublicAPI.Shipped.txt``. "Shipped" means released and frozen, so doing this mid-cycle would freeze |
| 12 | +API that has not shipped and cannot then be changed without it looking like a break. It is therefore |
| 13 | +opt in, and belongs to the release: ship the package, then promote. |
| 14 | +
|
| 15 | + eng/public-api.py # sort in place, promote nothing |
| 16 | + eng/public-api.py --promote # the release step: fold unshipped into shipped, and sort |
| 17 | + eng/public-api.py --check # CI: write nothing, exit 1 if anything is unsorted |
| 18 | +""" |
| 19 | + |
| 20 | +import argparse |
| 21 | +import os |
| 22 | +import sys |
| 23 | + |
| 24 | +# Modifiers the analyzer writes ahead of a signature. Stripped for sorting so a type's surface stays in |
| 25 | +# one block: sorting the raw line groups by modifier instead, splitting every type across an |
| 26 | +# abstract/override/static/virtual block and a bare one. |
| 27 | +MODIFIERS = ("abstract ", "override ", "static ", "virtual ", "readonly ", "const ", "sealed ") |
| 28 | + |
| 29 | + |
| 30 | +def sort_key(line): |
| 31 | + s = line |
| 32 | + # an experimental annotation - [SER009]Foo.Bar - sorts with its symbol, not as punctuation |
| 33 | + if s.startswith("["): |
| 34 | + close = s.find("]") |
| 35 | + if close > 0: |
| 36 | + s = s[close + 1:] |
| 37 | + changed = True |
| 38 | + while changed: |
| 39 | + changed = False |
| 40 | + for m in MODIFIERS: |
| 41 | + if s.startswith(m): |
| 42 | + s = s[len(m):] |
| 43 | + changed = True |
| 44 | + # tie-break on the whole line so the order is total and stable |
| 45 | + return (s, line) |
| 46 | + |
| 47 | + |
| 48 | +class ApiFile: |
| 49 | + def __init__(self, path): |
| 50 | + self.path = path |
| 51 | + raw = open(path, "rb").read() |
| 52 | + self.bom = raw.startswith(b"\xef\xbb\xbf") |
| 53 | + text = raw.decode("utf-8-sig") |
| 54 | + self.newline = "\r\n" if "\r\n" in text else "\n" |
| 55 | + lines = text.replace("\r\n", "\n").split("\n") |
| 56 | + # header directives (#nullable enable) stay at the top; blank lines are not content, and go |
| 57 | + self.header = [l for l in lines if l.startswith("#")] |
| 58 | + self.entries = [l for l in lines if l and not l.startswith("#")] |
| 59 | + |
| 60 | + def render(self, entries): |
| 61 | + text = self.newline.join(self.header + entries) + self.newline |
| 62 | + return (b"\xef\xbb\xbf" if self.bom else b"") + text.encode("utf-8") |
| 63 | + |
| 64 | + def write(self, entries): |
| 65 | + open(self.path, "wb").write(self.render(entries)) |
| 66 | + |
| 67 | + |
| 68 | +def find_shipped(root): |
| 69 | + out = [] |
| 70 | + for dirpath, dirnames, filenames in os.walk(os.path.join(root, "src")): |
| 71 | + dirnames[:] = [d for d in dirnames if d not in ("obj", "bin")] |
| 72 | + if "PublicAPI.Shipped.txt" in filenames: |
| 73 | + out.append(os.path.join(dirpath, "PublicAPI.Shipped.txt")) |
| 74 | + return sorted(out) |
| 75 | + |
| 76 | + |
| 77 | +def main(): |
| 78 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 79 | + ap.add_argument("--promote", action="store_true", |
| 80 | + help="move unshipped entries into shipped; do this only just after a release") |
| 81 | + ap.add_argument("--check", action="store_true", |
| 82 | + help="write nothing; exit 1 if any file would change") |
| 83 | + args = ap.parse_args() |
| 84 | + |
| 85 | + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 86 | + shipped_paths = find_shipped(root) |
| 87 | + if not shipped_paths: |
| 88 | + print(f"no PublicAPI.Shipped.txt found under {root}/src", file=sys.stderr) |
| 89 | + return 2 |
| 90 | + |
| 91 | + changed, problems = [], [] |
| 92 | + |
| 93 | + for shipped_path in shipped_paths: |
| 94 | + shipped = ApiFile(shipped_path) |
| 95 | + unshipped_path = os.path.join(os.path.dirname(shipped_path), "PublicAPI.Unshipped.txt") |
| 96 | + unshipped = ApiFile(unshipped_path) if os.path.exists(unshipped_path) else None |
| 97 | + |
| 98 | + rel = os.path.relpath(shipped_path, root) |
| 99 | + entries = list(shipped.entries) |
| 100 | + promoted = [] |
| 101 | + |
| 102 | + if args.promote and unshipped and unshipped.entries: |
| 103 | + promoted = list(unshipped.entries) |
| 104 | + # an entry in both files is not something to silently dedupe: the analyzer rejects |
| 105 | + # duplicates (RS0025), and it means one of the two was hand-edited |
| 106 | + clash = [e for e in promoted if e in set(entries)] |
| 107 | + if clash: |
| 108 | + problems.append(f"{rel}: {len(clash)} entr(y/ies) already shipped, e.g. {clash[0]}") |
| 109 | + continue |
| 110 | + entries += promoted |
| 111 | + |
| 112 | + new_shipped = sorted(entries, key=sort_key) |
| 113 | + if shipped.render(new_shipped) != shipped.render(shipped.entries) or promoted: |
| 114 | + note = f" (+{len(promoted)} promoted)" if promoted else "" |
| 115 | + changed.append(rel + note) |
| 116 | + if not args.check: |
| 117 | + shipped.write(new_shipped) |
| 118 | + |
| 119 | + if unshipped: |
| 120 | + rel_un = os.path.relpath(unshipped_path, root) |
| 121 | + # promoted entries leave; whatever remains is sorted too, so that appending to it from two |
| 122 | + # branches collides less often |
| 123 | + new_unshipped = [] if promoted else sorted(unshipped.entries, key=sort_key) |
| 124 | + if unshipped.render(new_unshipped) != unshipped.render(unshipped.entries): |
| 125 | + if not promoted: |
| 126 | + changed.append(rel_un) |
| 127 | + if not args.check: |
| 128 | + unshipped.write(new_unshipped) |
| 129 | + |
| 130 | + for p in problems: |
| 131 | + print(f"ERROR: {p}", file=sys.stderr) |
| 132 | + if problems: |
| 133 | + return 2 |
| 134 | + |
| 135 | + if not changed: |
| 136 | + print("public API files are sorted; nothing to do.") |
| 137 | + return 0 |
| 138 | + |
| 139 | + for c in changed: |
| 140 | + print(("would change: " if args.check else "updated: ") + c) |
| 141 | + |
| 142 | + if args.check: |
| 143 | + print("run 'eng/public-api.py' to fix.", file=sys.stderr) |
| 144 | + return 1 |
| 145 | + return 0 |
| 146 | + |
| 147 | + |
| 148 | +if __name__ == "__main__": |
| 149 | + sys.exit(main()) |
0 commit comments