Skip to content

Commit c73cc81

Browse files
committed
Add eng/public-api.py and promote unshipped public API
Ported from marc's StackExchange.Redis PR #3245: sorts the PublicAPI tracking files and, with --promote, folds Unshipped into Shipped as a release step. Running --promote here also fixes the actual release blocker (CmsInformation.CellSize was left unshipped) and, as a one-off, fully re-sorts the previously unsorted Shipped.txt.
1 parent 96b68d5 commit c73cc81

3 files changed

Lines changed: 666 additions & 517 deletions

File tree

‎eng/public-api.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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

Comments
 (0)