Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ max-branches = 18
addopts = "-r a --verbose"
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
pythonpath = ["."]
timeout = 1800

[tool.ty.environment]
Expand Down
123 changes: 110 additions & 13 deletions scripts/postprocess_generated_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ class alongside the canonical `ErrorType(StrEnum)`. This script removes the dupl
rewires references to use `ErrorType`.
- Missing @docs_group decorator: Adds `@docs_group('Models')` to all model classes for API
reference documentation grouping, along with the required import.
- Class sorting: Sorts class definitions alphabetically (with topological ordering to respect inheritance
dependencies), so that regeneration from a reordered OpenAPI spec produces minimal diffs.
"""

from __future__ import annotations

import heapq
import re
from collections import defaultdict
from pathlib import Path

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

# Map of camelCase discriminator values to their snake_case equivalents.
# Add new entries here as needed when the OpenAPI spec introduces new discriminators.
Expand Down Expand Up @@ -54,26 +59,118 @@ def deduplicate_error_type_enum(content: str) -> str:


def add_docs_group_decorators(content: str) -> str:
"""Add `@docs_group('Models')` decorator to all model classes and the required import."""
# Add the import after the existing imports.
content = re.sub(
r'(from pydantic import [^\n]+\n)',
r'\1\nfrom apify_client._docs import docs_group\n',
content,
)
# Add @docs_group('Models') before every class definition.
return re.sub(
r'\nclass ',
"\n@docs_group('Models')\nclass ",
content,
)
"""Add `@docs_group('Models')` decorator to all model classes and the required import.

This function is idempotent — it skips the import and decorators if they already exist.
"""
# Add the import after the existing imports (only if not already present).
if 'from apify_client._docs import docs_group' not in content:
content = re.sub(
r'(from pydantic import [^\n]+\n)',
r'\1\nfrom apify_client._docs import docs_group\n',
content,
)
# Add @docs_group('Models') before class definitions not already preceded by it.
lines = content.split('\n')
result: list[str] = []
for line in lines:
if line.startswith('class ') and (not result or result[-1] != DOCS_GROUP_DECORATOR):
result.append(DOCS_GROUP_DECORATOR)
result.append(line)
return '\n'.join(result)


def sort_classes(content: str) -> str:
"""Sort class definitions alphabetically while respecting inheritance order.

Uses topological sorting so that base classes always appear before their subclasses, with alphabetical ordering as
the tie-breaker. This makes the output deterministic regardless of the order in the OpenAPI spec, which keeps diffs
minimal across regenerations.

Only the class statement's base-class expression creates an ordering constraint — field type annotations are lazy
strings thanks to `from __future__ import annotations` and don't require forward declaration.
"""
lines = content.split('\n')

# Find where class blocks start (first @docs_group decorator).
header_end = 0
for i, line in enumerate(lines):
if line == DOCS_GROUP_DECORATOR:
header_end = i
break

# Strip trailing blank lines from the header; we re-add spacing later.
header_lines = lines[:header_end]
while header_lines and not header_lines[-1].strip():
header_lines.pop()
header = '\n'.join(header_lines)

# Split the remainder into class blocks.
# Each block starts with `@docs_group('Models')` on its own line.
rest = '\n'.join(lines[header_end:])
decorator_escaped = re.escape(DOCS_GROUP_DECORATOR)
raw_blocks = re.split(rf'(?=^{decorator_escaped}$)', rest, flags=re.MULTILINE)
blocks = [b.strip() for b in raw_blocks if b.strip()]

# Parse each block: extract class name and base-class dependencies.
class_blocks: dict[str, str] = {}
class_deps: dict[str, set[str]] = {}

for block in blocks:
match = re.search(r'^class\s+(\w+)\(([^)]+)\):', block, re.MULTILINE)
if not match:
continue
class_name = match.group(1)
base_expr = match.group(2)

# Collect all capitalized identifiers from the base-class expression.
referenced = set(re.findall(r'\b([A-Z]\w+)\b', base_expr))
class_blocks[class_name] = block
class_deps[class_name] = referenced

if len(class_blocks) != len(blocks):
# Some blocks didn't match the class regex — fall back to avoid data loss.
return content

all_names = set(class_blocks)

# Build the dependency graph (only in-file references matter).
in_degree: dict[str, int] = {}
reverse: dict[str, set[str]] = defaultdict(set)

for name, refs in class_deps.items():
local_deps = (refs & all_names) - {name}
in_degree[name] = len(local_deps)
for dep in local_deps:
reverse[dep].add(name)

# Kahn's algorithm with a min-heap for alphabetical tie-breaking.
heap = sorted(name for name, degree in in_degree.items() if degree == 0)
heapq.heapify(heap)

sorted_names: list[str] = []
while heap:
name = heapq.heappop(heap)
sorted_names.append(name)
for dependent in reverse[name]:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
heapq.heappush(heap, dependent)

if len(sorted_names) != len(class_blocks):
# Cycle detected — fall back to the original order to avoid data loss.
return content

sorted_blocks = [class_blocks[name] for name in sorted_names]
return header + '\n\n\n' + '\n\n\n'.join(sorted_blocks) + '\n'


def main() -> None:
content = MODELS_PATH.read_text()
fixed = fix_discriminators(content)
fixed = deduplicate_error_type_enum(fixed)
fixed = add_docs_group_decorators(fixed)
fixed = sort_classes(fixed)

if fixed != content:
MODELS_PATH.write_text(fixed)
Expand Down
Loading