Skip to content
79 changes: 77 additions & 2 deletions scripts/postprocess_generated_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Post-process datamodel-codegen output to fix known issues and prune the TypedDict file.

Applied to both `_models.py` and `_typeddicts.py`:
- Reparent classes whose schema spells out the wire shape standalone instead of extending the base schema it
duplicates, so the generated class declares every field.

Applied to `_models.py`:
- Fix discriminator field names that use camelCase instead of snake_case (known issue with discriminators on schemas
referenced from array items).
Expand All @@ -12,6 +16,7 @@
- Add `@docs_group('Models')` to every model class (plus the required import).

Applied to `_typeddicts.py`:
- Drop the fields a reparented TypedDict inherits from its new base, which PEP 589 forbids it from redeclaring.
- Keep only the TypedDicts actually used as resource-client method inputs (plus their transitive dependencies).
The file is generated in full by datamodel-codegen; the trimming happens here.
- Rename every kept class to add a `Dict` suffix so it doesn't clash with the Pydantic model name
Expand Down Expand Up @@ -47,6 +52,14 @@
'pricingModel': 'pricing_model',
}

# Map of `{class name: base class it should inherit from}`, applied to both generated files. A schema that spells out
# only a few properties instead of extending the base schema carrying the rest generates a class whose `extra='allow'`
# absorbs the other fields - and `to_camel` doesn't alias extras, so sending it as a request body puts snake_case keys
# on the wire. Reparenting declares the full shape.
BASE_CLASS_FIXES: dict[str, str] = {
'RequestDraft': 'RequestBase',
}

# TypedDicts accepted as inputs by resource-client methods. These are the roots of the reachability
# walk over `_typeddicts.py`: anything not reachable from here (directly or transitively)
# is dropped so only the TypedDicts that are part of the public input surface — plus their nested
Expand Down Expand Up @@ -102,6 +115,65 @@ def _base_names(node: ast.ClassDef) -> set[str]:
return {b.id for b in node.bases if isinstance(b, ast.Name)}


def reparent_classes(content: str) -> str:
"""Replace the base class of every `BASE_CLASS_FIXES` entry with the mapped one.

Rewrites the whole base list, so re-running is a no-op. A mapped class absent from `content` is skipped, since a
schema doesn't always yield a class in both files. The base is not looked up: naming one the file doesn't define
yields source that fails to import, which beats silently skipping the fix.
"""
for name, base in BASE_CLASS_FIXES.items():
content = re.sub(
rf'^class {re.escape(name)}\([^)]*\):',
f'class {name}({base}):',
content,
flags=re.MULTILINE,
)
return content


def _annotated_field_names(node: ast.ClassDef) -> set[str]:
"""Return the names of every annotated field declared directly in `node`'s body."""
return {
stmt.target.id for stmt in node.body if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)
}


def drop_inherited_typeddict_fields(content: str) -> str:
"""Delete the fields a reparented TypedDict now inherits, along with their description docstrings.

PEP 589 forbids redeclaring a base's key, even to turn a `NotRequired` one into a required one. The keys stay
required at runtime, where the Pydantic model keeps its own redeclarations.
"""
tree = ast.parse(content)
classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)}
drop_line_indices: set[int] = set()

for name, base in BASE_CLASS_FIXES.items():
node, base_node = classes.get(name), classes.get(base)
if node is None or base_node is None:
continue
inherited = _annotated_field_names(base_node)
for index, stmt in enumerate(node.body):
if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
continue
if stmt.target.id not in inherited:
continue
end_line = stmt.end_lineno
following = node.body[index + 1] if index + 1 < len(node.body) else None
if following is not None and _is_string_expr(following):
end_line = following.end_lineno
assert end_line is not None # noqa: S101
drop_line_indices.update(range(stmt.lineno - 1, end_line))

if not drop_line_indices:
return content

lines = content.split('\n')
kept = [line for i, line in enumerate(lines) if i not in drop_line_indices]
return _collapse_blank_lines('\n'.join(kept))


def fix_discriminators(content: str) -> str:
"""Replace camelCase discriminator values with their snake_case equivalents."""
for camel, snake in DISCRIMINATOR_FIXES.items():
Expand Down Expand Up @@ -645,7 +717,8 @@ def postprocess_models(models_path: Path, literals_path: Path) -> list[Path]:
Returns the list of paths that were (re)written.
"""
original = models_path.read_text()
fixed = fix_discriminators(original)
fixed = reparent_classes(original)
fixed = fix_discriminators(fixed)
fixed = absolutize_doc_links(fixed)
fixed = convert_enums_to_literals(fixed)
fixed = add_docs_group_decorators(fixed, 'Models')
Expand All @@ -666,7 +739,9 @@ def postprocess_models(models_path: Path, literals_path: Path) -> list[Path]:
def postprocess_typeddicts(path: Path, alias_map: dict[str, dict[str, str]]) -> bool:
"""Apply `_typeddicts.py`-specific fixes. Returns True if the file changed."""
original = path.read_text()
pruned, kept = prune_typeddicts(original, RESOURCE_INPUT_TYPEDDICTS)
# Reparenting comes first so the new base counts as a dependency of the input surface and survives pruning.
reparented = drop_inherited_typeddict_fields(reparent_classes(original))
pruned, kept = prune_typeddicts(reparented, RESOURCE_INPUT_TYPEDDICTS)
renamed = rename_with_dict_suffix(pruned, kept)
flattened = flatten_empty_typeddicts(renamed)
camelized = add_camel_case_typeddicts(flattened, alias_map)
Expand Down
2 changes: 1 addition & 1 deletion src/apify_client/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2486,7 +2486,7 @@ class Request(RequestBase):


@docs_group('Models')
class RequestDraft(BaseModel):
class RequestDraft(RequestBase):
"""A request that failed to be processed during a request queue operation and can be retried."""

model_config = ConfigDict(
Expand Down
22 changes: 11 additions & 11 deletions src/apify_client/_resource_clients/request_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def _serialize_requests(
return [
json.dumps(
(request if isinstance(request, RequestDraft) else RequestDraft.model_validate(request)).model_dump(
by_alias=True, exclude_none=True
mode='json', by_alias=True, exclude_none=True, fallback=str
),
ensure_ascii=False,
allow_nan=False,
Expand Down Expand Up @@ -238,7 +238,7 @@ def add_request(
https://docs.apify.com/api/v2#/reference/request-queues/request-collection/add-request

Args:
request: The request to add to the queue.
request: The request to add to the queue. Must carry a `unique_key` and a `url`.
forefront: Whether to add the request to the head or the end of the queue.
timeout: Timeout for the API HTTP request.

Expand All @@ -253,7 +253,7 @@ def add_request(
response = self._http_client.call(
url=self._build_url('requests'),
method='POST',
json=request.model_dump(by_alias=True, exclude_none=True),
json=request.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str),
params=request_params,
timeout=timeout,
)
Expand Down Expand Up @@ -315,7 +315,7 @@ def update_request(
response = self._http_client.call(
url=self._build_url(f'requests/{request.id}'),
method='PUT',
json=request.model_dump(by_alias=True, exclude_none=True),
json=request.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str),
params=request_params,
timeout=timeout,
)
Expand Down Expand Up @@ -417,7 +417,7 @@ def batch_add_requests(
https://docs.apify.com/api/v2#/reference/request-queues/batch-request-operations/add-requests

Args:
requests: List of requests to be added to the queue.
requests: List of requests to be added to the queue. Each must carry a `unique_key` and a `url`.
forefront: Whether to add requests to the front of the queue.
max_parallel: Specifies the maximum number of parallel tasks for API calls. This is only applicable
to the async client. For the sync client, this value must be set to 1, as parallel execution
Expand Down Expand Up @@ -504,7 +504,7 @@ def batch_delete_requests(
else RequestDraftDelete.model_validate(
request,
)
).model_dump(by_alias=True, exclude_none=True)
).root.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str)
for request in requests
]

Expand Down Expand Up @@ -765,7 +765,7 @@ async def add_request(
https://docs.apify.com/api/v2#/reference/request-queues/request-collection/add-request

Args:
request: The request to add to the queue.
request: The request to add to the queue. Must carry a `unique_key` and a `url`.
forefront: Whether to add the request to the head or the end of the queue.
timeout: Timeout for the API HTTP request.

Expand All @@ -780,7 +780,7 @@ async def add_request(
response = await self._http_client.call(
url=self._build_url('requests'),
method='POST',
json=request.model_dump(by_alias=True, exclude_none=True),
json=request.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str),
params=request_params,
timeout=timeout,
)
Expand Down Expand Up @@ -840,7 +840,7 @@ async def update_request(
response = await self._http_client.call(
url=self._build_url(f'requests/{request.id}'),
method='PUT',
json=request.model_dump(by_alias=True, exclude_none=True),
json=request.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str),
params=request_params,
timeout=timeout,
)
Expand Down Expand Up @@ -990,7 +990,7 @@ async def batch_add_requests(
https://docs.apify.com/api/v2#/reference/request-queues/batch-request-operations/add-requests

Args:
requests: List of requests to be added to the queue.
requests: List of requests to be added to the queue. Each must carry a `unique_key` and a `url`.
forefront: Whether to add requests to the front of the queue.
max_parallel: Specifies the maximum number of parallel tasks for API calls. This is only applicable
to the async client. For the sync client, this value must be set to 1, as parallel execution
Expand Down Expand Up @@ -1082,7 +1082,7 @@ async def batch_delete_requests(
else RequestDraftDelete.model_validate(
request,
)
).model_dump(by_alias=True, exclude_none=True)
).root.model_dump(mode='json', by_alias=True, exclude_none=True, fallback=str)
for request in requests
]

Expand Down
22 changes: 2 additions & 20 deletions src/apify_client/_typeddicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,41 +112,23 @@ class RequestCamelDict(RequestBaseCamelDict):


@docs_group('Typed dicts')
class RequestDraftDict(TypedDict):
class RequestDraftDict(RequestBaseDict):
"""A request that failed to be processed during a request queue operation and can be retried."""

id: NotRequired[str]
"""
A unique identifier assigned to the request.
"""
unique_key: str
"""
A unique key used for request de-duplication. Requests with the same unique key are considered identical.
"""
url: str
"""
The URL of the request.
"""
method: NotRequired[Literal['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']]


@docs_group('Typed dicts')
class RequestDraftCamelDict(TypedDict):
class RequestDraftCamelDict(RequestBaseCamelDict):
"""A request that failed to be processed during a request queue operation and can be retried."""

id: NotRequired[str]
"""
A unique identifier assigned to the request.
"""
uniqueKey: str
"""
A unique key used for request de-duplication. Requests with the same unique key are considered identical.
"""
url: str
"""
The URL of the request.
"""
method: NotRequired[Literal['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']]


@docs_group('Typed dicts')
Expand Down
Loading
Loading