Skip to content

Commit 8ff8ec7

Browse files
authored
chore: use alias_generator on generated models instead of per-field aliases (#858)
The codegen postprocess now adds `alias_generator=to_camel` to every model's `ConfigDict` and emits an explicit `Field(alias=...)` only where the camelCase conversion is irregular (all-caps usage keys, `gitHubGistUrl`, `schema`, ...). This drops the explicit per-field aliases in `_models.py` from 623 to 45 and shrinks the file from 3852 to 3077 lines. The wire format is unchanged, guarded by tests: Pydantic lets an explicit alias override the generator, and `populate_by_name=True` keeps snake_case input working. The before/after runtime alias contract is byte-identical across all 212 models / 1060 fields, and `_typeddicts.py` / `_literals.py` regenerate unchanged (the camel `*Dict` synthesis now derives names via `to_camel` plus the irregular-alias overrides). datamodel-codegen has no native option for this (its `--no-alias` would drop the irregular aliases too), so the work lives in `scripts/postprocess_generated_models.py`. Closes #852
1 parent 8dffe34 commit 8ff8ec7

5 files changed

Lines changed: 886 additions & 751 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ dev = [
4646
# See https://github.com/apify/apify-client-python/pull/582/ for more details.
4747
# We explicitly constrain black>=24.3.0 to override the transitive dependency.
4848
"black>=24.3.0",
49-
"datamodel-code-generator[http,ruff]>=0.57.0,<1.0.0",
49+
"datamodel-code-generator[http,ruff]>=0.64.1,<1.0.0",
5050
"dycw-pytest-only<3.0.0",
5151
"griffe<3.0.0",
5252
"poethepoet<1.0.0",
@@ -295,9 +295,12 @@ cwd = "website"
295295
shell = "./build_api_reference.sh && pnpm install && uv run pnpm start"
296296
cwd = "website"
297297

298+
# The `--alias-generator to_camel` flag lives on the `_models.py` command (not in `[tool.datamodel-codegen]`)
299+
# because datamodel-codegen only allows it for `pydantic_v2.BaseModel` output and would reject the TypedDict run.
298300
[tool.poe.tasks.generate-models]
299301
shell = """
300302
uv run datamodel-codegen --url https://docs.apify.com/api/openapi.json \
303+
--alias-generator to_camel \
301304
&& uv run datamodel-codegen --url https://docs.apify.com/api/openapi.json \
302305
--output src/apify_client/_typeddicts.py \
303306
--output-model-type typing.TypedDict \
@@ -308,6 +311,7 @@ uv run datamodel-codegen --url https://docs.apify.com/api/openapi.json \
308311
[tool.poe.tasks.generate-models-from-file]
309312
shell = """
310313
uv run datamodel-codegen --input $input_file \
314+
--alias-generator to_camel \
311315
&& uv run datamodel-codegen --input $input_file \
312316
--output src/apify_client/_typeddicts.py \
313317
--output-model-type typing.TypedDict \

scripts/postprocess_generated_models.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from pathlib import Path
2929
from typing import TYPE_CHECKING
3030

31+
from pydantic.alias_generators import to_camel
32+
3133
if TYPE_CHECKING:
3234
from apify_client._docs import GroupName
3335

@@ -412,28 +414,58 @@ def _extract_alias_from_field_call(field_call: ast.Call) -> str | None:
412414
return None
413415

414416

417+
def _class_uses_camel_generator(class_node: ast.ClassDef) -> bool:
418+
"""Return True if `class_node` declares `model_config = ConfigDict(..., alias_generator=to_camel)`.
419+
420+
datamodel-codegen emits the generator (via `--alias-generator to_camel`) on every model, so unaliased fields
421+
derive their API spelling through `to_camel` rather than mapping to themselves.
422+
"""
423+
for stmt in class_node.body:
424+
if isinstance(stmt, ast.Assign):
425+
targets = stmt.targets
426+
elif isinstance(stmt, ast.AnnAssign):
427+
targets = [stmt.target]
428+
else:
429+
continue
430+
if not any(isinstance(t, ast.Name) and t.id == 'model_config' for t in targets):
431+
continue
432+
value = stmt.value
433+
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == 'ConfigDict':
434+
return any(
435+
kw.arg == 'alias_generator' and isinstance(kw.value, ast.Name) and kw.value.id == 'to_camel'
436+
for kw in value.keywords
437+
)
438+
return False
439+
440+
415441
def _extract_class_field_aliases(class_node: ast.ClassDef) -> dict[str, str]:
416442
"""Return `{snake_field: api_field}` for every annotated field declared on `class_node`.
417443
418-
Fields without a `Field(alias=...)` map to themselves (their declared Python name matches the API name — typical
419-
for single-word fields like `url`, `id`).
444+
The API spelling is resolved in priority order: an explicit `Field(alias=...)` wins; otherwise, on a model that
445+
carries `alias_generator=to_camel`, the name is run through `to_camel` (matching Pydantic at runtime); otherwise
446+
the field maps to itself (single-word fields like `url`, `id`, or models without the generator).
420447
"""
448+
uses_camel = _class_uses_camel_generator(class_node)
421449
aliases: dict[str, str] = {}
422450
for stmt in class_node.body:
423451
if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
424452
continue
425453
field_name = stmt.target.id
426454
if field_name == 'model_config':
427455
continue
428-
# Default: no alias means snake name == API name.
429-
api_name = field_name
430456
# Walk the annotation to find a nested `Field(alias='...')` call inside `Annotated[...]`.
457+
explicit_alias: str | None = None
431458
for sub in ast.walk(stmt.annotation):
432459
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name) and sub.func.id == 'Field':
433-
found = _extract_alias_from_field_call(sub)
434-
if found is not None:
435-
api_name = found
460+
explicit_alias = _extract_alias_from_field_call(sub)
461+
if explicit_alias is not None:
436462
break
463+
if explicit_alias is not None:
464+
api_name = explicit_alias
465+
elif uses_camel:
466+
api_name = to_camel(field_name)
467+
else:
468+
api_name = field_name
437469
aliases[field_name] = api_name
438470
return aliases
439471

0 commit comments

Comments
 (0)