Skip to content

Commit 37c8f27

Browse files
vdusekclaude
andcommitted
feat: sort generated model classes alphabetically with topological ordering
Add class sorting to postprocess_generated_models.py that orders class definitions alphabetically while respecting inheritance dependencies via topological sort. This makes regeneration from a reordered OpenAPI spec produce minimal diffs. Also makes add_docs_group_decorators idempotent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d52bc8d commit 37c8f27

2 files changed

Lines changed: 2559 additions & 2458 deletions

File tree

scripts/postprocess_generated_models.py

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ class alongside the canonical `ErrorType(StrEnum)`. This script removes the dupl
1010
rewires references to use `ErrorType`.
1111
- Missing @docs_group decorator: Adds `@docs_group('Models')` to all model classes for API
1212
reference documentation grouping, along with the required import.
13+
- Class sorting: Sorts class definitions alphabetically (with topological ordering to respect
14+
inheritance dependencies), so that regeneration from a reordered OpenAPI spec produces
15+
minimal diffs.
1316
"""
1417

1518
from __future__ import annotations
1619

20+
import heapq
1721
import re
22+
from collections import defaultdict
1823
from pathlib import Path
1924

2025
MODELS_PATH = Path(__file__).resolve().parent.parent / 'src' / 'apify_client' / '_models.py'
26+
DOCS_GROUP_DECORATOR = "@docs_group('Models')"
2127

2228
# Map of camelCase discriminator values to their snake_case equivalents.
2329
# Add new entries here as needed when the OpenAPI spec introduces new discriminators.
@@ -54,26 +60,121 @@ def deduplicate_error_type_enum(content: str) -> str:
5460

5561

5662
def add_docs_group_decorators(content: str) -> str:
57-
"""Add `@docs_group('Models')` decorator to all model classes and the required import."""
58-
# Add the import after the existing imports.
59-
content = re.sub(
60-
r'(from pydantic import [^\n]+\n)',
61-
r'\1\nfrom apify_client._docs import docs_group\n',
62-
content,
63-
)
64-
# Add @docs_group('Models') before every class definition.
65-
return re.sub(
66-
r'\nclass ',
67-
"\n@docs_group('Models')\nclass ",
68-
content,
69-
)
63+
"""Add `@docs_group('Models')` decorator to all model classes and the required import.
64+
65+
This function is idempotent — it skips the import and decorators if they
66+
already exist.
67+
"""
68+
# Add the import after the existing imports (only if not already present).
69+
if 'from apify_client._docs import docs_group' not in content:
70+
content = re.sub(
71+
r'(from pydantic import [^\n]+\n)',
72+
r'\1\nfrom apify_client._docs import docs_group\n',
73+
content,
74+
)
75+
# Add @docs_group('Models') before class definitions not already preceded by it.
76+
lines = content.split('\n')
77+
result: list[str] = []
78+
for line in lines:
79+
if line.startswith('class ') and (not result or result[-1] != DOCS_GROUP_DECORATOR):
80+
result.append(DOCS_GROUP_DECORATOR)
81+
result.append(line)
82+
return '\n'.join(result)
83+
84+
85+
def sort_classes(content: str) -> str:
86+
"""Sort class definitions alphabetically while respecting inheritance order.
87+
88+
Uses topological sorting so that base classes always appear before their
89+
subclasses, with alphabetical ordering as the tie-breaker. This makes the
90+
output deterministic regardless of the order in the OpenAPI spec, which
91+
keeps diffs minimal across regenerations.
92+
93+
Only the class statement's base-class expression creates an ordering
94+
constraint — field type annotations are lazy strings thanks to
95+
``from __future__ import annotations`` and don't require forward
96+
declaration.
97+
"""
98+
lines = content.split('\n')
99+
100+
# Find where class blocks start (first @docs_group decorator).
101+
header_end = 0
102+
for i, line in enumerate(lines):
103+
if line == DOCS_GROUP_DECORATOR:
104+
header_end = i
105+
break
106+
107+
# Strip trailing blank lines from the header; we re-add spacing later.
108+
header_lines = lines[:header_end]
109+
while header_lines and not header_lines[-1].strip():
110+
header_lines.pop()
111+
header = '\n'.join(header_lines)
112+
113+
# Split the remainder into class blocks.
114+
# Each block starts with ``@docs_group('Models')`` on its own line.
115+
rest = '\n'.join(lines[header_end:])
116+
decorator_escaped = re.escape(DOCS_GROUP_DECORATOR)
117+
raw_blocks = re.split(rf'(?=^{decorator_escaped}$)', rest, flags=re.MULTILINE)
118+
blocks = [b.strip() for b in raw_blocks if b.strip()]
119+
120+
# Parse each block: extract class name and base-class dependencies.
121+
class_blocks: dict[str, str] = {}
122+
class_deps: dict[str, set[str]] = {}
123+
124+
for block in blocks:
125+
match = re.search(r'^class\s+(\w+)\(([^)]+)\):', block, re.MULTILINE)
126+
if not match:
127+
continue
128+
class_name = match.group(1)
129+
base_expr = match.group(2)
130+
# Collect all capitalized identifiers from the base-class expression.
131+
referenced = set(re.findall(r'\b([A-Z]\w+)\b', base_expr))
132+
class_blocks[class_name] = block
133+
class_deps[class_name] = referenced
134+
135+
if len(class_blocks) != len(blocks):
136+
# Some blocks didn't match the class regex — fall back to avoid data loss.
137+
return content
138+
139+
all_names = set(class_blocks)
140+
141+
# Build the dependency graph (only in-file references matter).
142+
in_degree: dict[str, int] = {}
143+
reverse: dict[str, set[str]] = defaultdict(set)
144+
145+
for name, refs in class_deps.items():
146+
local_deps = (refs & all_names) - {name}
147+
in_degree[name] = len(local_deps)
148+
for dep in local_deps:
149+
reverse[dep].add(name)
150+
151+
# Kahn's algorithm with a min-heap for alphabetical tie-breaking.
152+
heap = sorted(name for name, degree in in_degree.items() if degree == 0)
153+
heapq.heapify(heap)
154+
155+
sorted_names: list[str] = []
156+
while heap:
157+
name = heapq.heappop(heap)
158+
sorted_names.append(name)
159+
for dependent in reverse[name]:
160+
in_degree[dependent] -= 1
161+
if in_degree[dependent] == 0:
162+
heapq.heappush(heap, dependent)
163+
164+
if len(sorted_names) != len(class_blocks):
165+
# Cycle detected — fall back to the original order to avoid data loss.
166+
return content
167+
168+
sorted_blocks = [class_blocks[name] for name in sorted_names]
169+
return header + '\n\n\n' + '\n\n\n'.join(sorted_blocks) + '\n'
70170

71171

72172
def main() -> None:
73173
content = MODELS_PATH.read_text()
74174
fixed = fix_discriminators(content)
75175
fixed = deduplicate_error_type_enum(fixed)
76176
fixed = add_docs_group_decorators(fixed)
177+
fixed = sort_classes(fixed)
77178

78179
if fixed != content:
79180
MODELS_PATH.write_text(fixed)

0 commit comments

Comments
 (0)