@@ -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
1518from __future__ import annotations
1619
20+ import heapq
1721import re
22+ from collections import defaultdict
1823from pathlib import Path
1924
2025MODELS_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
5662def 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')\n class " ,
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
72172def 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