Skip to content

Commit 40faed9

Browse files
authored
fix(codegen): support datamodel-code-generator 0.71 (#127)
* fix(codegen): support datamodel-code-generator 0.64 * chore(deps-dev): require datamodel-code-generator 0.71.0 * refactor(schema): stop duplicating field descriptions * refactor(schema): generate field descriptions as docstrings * test(schema): verify generated descriptions are readable
1 parent 750c6ee commit 40faed9

6 files changed

Lines changed: 4242 additions & 5161 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Documentation = "https://agentclientprotocol.github.io/python-sdk/"
3232

3333
[dependency-groups]
3434
dev = [
35-
"datamodel-code-generator>=0.25",
35+
"datamodel-code-generator>=0.71.0",
3636
"pytest>=7.2.0",
3737
"pytest-asyncio>=0.21.0",
3838
"tox-uv>=1.11.3",

scripts/gen_schema.py

Lines changed: 115 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,6 @@
2020
VERSION_FILE = SCHEMA_DIR / "VERSION"
2121
SCHEMA_OUT = ROOT / "src" / "acp" / "schema.py"
2222

23-
# Pattern caches used when post-processing generated schema.
24-
FIELD_DECLARATION_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\s*:")
25-
DESCRIPTION_PATTERN = re.compile(
26-
r"description\s*=\s*(?P<prefix>[rRbBuU]*)?(?P<quote>'''|\"\"\"|'|\")(?P<value>.*?)(?P=quote)",
27-
re.DOTALL,
28-
)
29-
3023
STDIO_TYPE_LITERAL = 'Literal["2#-datamodel-code-generator-#-object-#-special-#"]'
3124
MODELS_TO_REMOVE = [
3225
"AgentClientProtocol",
@@ -137,6 +130,31 @@
137130
"ToolKind": ("read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"),
138131
}
139132

133+
# datamodel-code-generator 0.64 promotes referenced string enums to Enum classes.
134+
# Keep the existing Python API, where these schema types are plain strings; the
135+
# selected public fields below are narrowed back to the named Literal aliases.
136+
STRING_ENUM_TYPES = (
137+
*ENUM_LITERAL_MAP,
138+
"ElicitationSchemaType",
139+
"NesDiagnosticSeverity",
140+
"NesRejectReason",
141+
"NesTriggerKind",
142+
"PositionEncodingKind",
143+
"Role",
144+
"StringFormat",
145+
"TextDocumentSyncKind",
146+
)
147+
148+
# Preserve RootModel classes that existed in the generated public surface before
149+
# 0.64; other unreferenced RootModels are intermediates left after collapsing.
150+
PUBLIC_ROOT_MODELS = {
151+
"AgentResponse",
152+
"ClientResponse",
153+
"ElicitationContentValue",
154+
"ElicitationFormMode",
155+
"ElicitationUrlMode",
156+
}
157+
140158
FIELD_TYPE_OVERRIDES: tuple[tuple[str, str, str, bool], ...] = (
141159
("PermissionOption", "kind", "PermissionOptionKind", False),
142160
("PlanEntry", "priority", "PlanEntryPriority", False),
@@ -259,7 +277,16 @@ def generate_schema() -> None:
259277
"--collapse-root-models",
260278
"--output-model-type",
261279
"pydantic_v2.BaseModel",
280+
"--no-use-specialized-enum",
281+
"--no-use-standard-collections",
282+
"--no-use-union-operator",
283+
"--type-overrides",
284+
json.dumps(dict.fromkeys(STRING_ENUM_TYPES, "builtins.str")),
285+
"--formatters",
286+
"black",
287+
"isort",
262288
"--use-annotated",
289+
"--use-field-description",
263290
"--snake-case-field",
264291
]
265292

@@ -474,15 +501,18 @@ def postprocess_generated_schema(output_path: Path) -> list[str]:
474501
header_block = _build_header_block()
475502

476503
content = _strip_existing_header(raw_content)
504+
# Type overrides for builtins are rendered as imports in 0.64, but the
505+
# annotations should continue to use Python's builtin `str` directly.
506+
content = content.replace("from builtins import str\n", "")
477507
content = _remove_unused_models(content)
478508
content, leftover_classes = _rename_numbered_models(content)
479509

480510
processing_steps: tuple[_ProcessingStep, ...] = (
481511
_ProcessingStep("apply field overrides", _apply_field_overrides),
482512
_ProcessingStep("apply default overrides", _apply_default_overrides),
483513
_ProcessingStep("restore required nullable fields", _restore_required_nullable_fields),
484-
_ProcessingStep("attach description comments", _add_description_comments),
485514
_ProcessingStep("ensure custom BaseModel", _ensure_custom_base_model),
515+
_ProcessingStep("enable RootModel attribute docstrings", _enable_root_model_attribute_docstrings),
486516
_ProcessingStep("inject field validators", _inject_field_validators),
487517
_ProcessingStep("inject deserialize defaults", _inject_deserialize_defaults),
488518
_ProcessingStep("inject schema aliases", _inject_schema_aliases),
@@ -494,6 +524,7 @@ def postprocess_generated_schema(output_path: Path) -> list[str]:
494524
missing_targets = _find_missing_targets(content)
495525

496526
content = _inject_enum_aliases(content)
527+
content = _remove_unreferenced_root_models(content)
497528
final_content = header_block + content.rstrip() + "\n"
498529
if not final_content.endswith("\n"):
499530
final_content += "\n"
@@ -646,7 +677,7 @@ def _ensure_custom_base_model(content: str) -> str:
646677
lines[idx] = "from pydantic import " + ", ".join(new_imports)
647678
to_insert = textwrap.dedent("""\
648679
class BaseModel(_BaseModel):
649-
model_config = ConfigDict(populate_by_name=True)
680+
model_config = ConfigDict(populate_by_name=True, use_attribute_docstrings=True)
650681
651682
def __getattr__(self, item: str) -> Any:
652683
if item.lower() != item:
@@ -662,6 +693,24 @@ def __getattr__(self, item: str) -> Any:
662693
return "\n".join(lines) + "\n"
663694

664695

696+
def _enable_root_model_attribute_docstrings(content: str) -> str:
697+
lines = content.splitlines(keepends=True)
698+
tree = ast.parse(content)
699+
insertion_points = [
700+
node.body[0].lineno - 1
701+
for node in tree.body
702+
if isinstance(node, ast.ClassDef)
703+
and node.body
704+
and any(
705+
isinstance(base, ast.Subscript) and isinstance(base.value, ast.Name) and base.value.id == "RootModel"
706+
for base in node.bases
707+
)
708+
]
709+
for line_index in reversed(insertion_points):
710+
lines.insert(line_index, " model_config = ConfigDict(use_attribute_docstrings=True)\n\n")
711+
return "".join(lines)
712+
713+
665714
def _ensure_pydantic_import(content: str, name: str) -> str:
666715
"""Add *name* to the ``from pydantic import ...`` line if not already present."""
667716
lines = content.splitlines()
@@ -916,8 +965,14 @@ def _restore_required_nullable_fields(content: str, schema: dict[str, Any] | Non
916965
def restore_block(match: re.Match[str], _field_names: list[str] = field_names) -> str:
917966
header, block = match.group(1), match.group(2)
918967
for field_name in _field_names:
919-
field_pattern = re.compile(rf"(\n\s+{re.escape(field_name)}:\s+Annotated\[[\s\S]*?\n\s+\]\s*)=\s*None")
920-
block = field_pattern.sub(r"\1", block, count=1)
968+
field_patterns = (
969+
re.compile(rf"(\n\s+{re.escape(field_name)}:[^\n]*?)\s*=\s*None(?=\n)"),
970+
re.compile(rf"(\n\s+{re.escape(field_name)}:[^\n]*\[\s*\n[\s\S]*?\n\s+\]\s*)=\s*None"),
971+
)
972+
for field_pattern in field_patterns:
973+
block, count = field_pattern.subn(r"\1", block, count=1)
974+
if count:
975+
break
921976
return header + block
922977

923978
content = class_pattern.sub(restore_block, content, count=1)
@@ -926,18 +981,14 @@ def restore_block(match: re.Match[str], _field_names: list[str] = field_names) -
926981

927982
def _apply_field_overrides(content: str) -> str:
928983
for class_name, field_name, new_type, optional in FIELD_TYPE_OVERRIDES:
929-
if optional:
930-
pattern = re.compile(
931-
rf"(class {class_name}\(BaseModel\):.*?\n\s+{field_name}:\s+Annotated\[\s*)Optional\[str],",
932-
re.DOTALL,
933-
)
934-
content, count = pattern.subn(rf"\1Optional[{new_type}],", content)
935-
else:
936-
pattern = re.compile(
937-
rf"(class {class_name}\(BaseModel\):.*?\n\s+{field_name}:\s+Annotated\[\s*)str,",
938-
re.DOTALL,
939-
)
940-
content, count = pattern.subn(rf"\1{new_type},", content)
984+
old_type = "Optional[str]" if optional else "str"
985+
replacement_type = f"Optional[{new_type}]" if optional else new_type
986+
pattern = re.compile(
987+
rf"(class {re.escape(class_name)}\(BaseModel\):.*?\n\s+{re.escape(field_name)}:\s+"
988+
rf"(?:Annotated\[\s*)?){re.escape(old_type)}(?=\s*(?:,|=|\n))",
989+
re.DOTALL,
990+
)
991+
content, count = pattern.subn(rf"\g<1>{replacement_type}", content, count=1)
941992
if count == 0:
942993
print(
943994
f"Warning: failed to apply type override for {class_name}.{field_name} -> {new_type}",
@@ -963,7 +1014,8 @@ def replace_block(
9631014
field_patterns: tuple[tuple[re.Pattern[str], Callable[[re.Match[str]], str]], ...] = (
9641015
(
9651016
re.compile(
966-
rf"(\n\s+{_field_name}:.*?\]\s*=\s*)([\s\S]*?)(?=\n\s{{4}}[A-Za-z_]|$)",
1017+
rf"(\n\s+{_field_name}:.*?\]\s*=\s*)([\s\S]*?)"
1018+
rf"(?=\n\s{{4}}(?:[A-Za-z_][A-Za-z0-9_]*\s*:|[rRuUbBfF]*(?:'''|\"\"\"))|$)",
9671019
re.DOTALL,
9681020
),
9691021
lambda m, _rep=_replacement: m.group(1) + _rep,
@@ -995,77 +1047,6 @@ def replace_block(
9951047
return content
9961048

9971049

998-
def _add_description_comments(content: str) -> str:
999-
lines = content.splitlines()
1000-
new_lines: list[str] = []
1001-
index = 0
1002-
1003-
while index < len(lines):
1004-
line = lines[index]
1005-
stripped = line.lstrip()
1006-
indent = len(line) - len(stripped)
1007-
1008-
if indent == 4 and FIELD_DECLARATION_PATTERN.match(stripped or ""):
1009-
block_lines, next_index = _collect_field_block(lines, index, indent)
1010-
block_text = "\n".join(block_lines)
1011-
description = _extract_description(block_text)
1012-
1013-
if description:
1014-
indent_str = " " * indent
1015-
comment_lines = [
1016-
f"{indent_str}# {comment_line}" if comment_line else f"{indent_str}#"
1017-
for comment_line in description.splitlines()
1018-
]
1019-
if comment_lines:
1020-
new_lines.extend(comment_lines)
1021-
1022-
new_lines.extend(block_lines)
1023-
index = next_index
1024-
continue
1025-
1026-
new_lines.append(line)
1027-
index += 1
1028-
1029-
return "\n".join(new_lines)
1030-
1031-
1032-
def _collect_field_block(lines: list[str], start: int, indent: int) -> tuple[list[str], int]:
1033-
block: list[str] = []
1034-
index = start
1035-
1036-
while index < len(lines):
1037-
current_line = lines[index]
1038-
current_indent = len(current_line) - len(current_line.lstrip())
1039-
if index != start and current_line.strip() and current_indent <= indent:
1040-
break
1041-
1042-
block.append(current_line)
1043-
index += 1
1044-
1045-
return block, index
1046-
1047-
1048-
def _extract_description(block_text: str) -> str | None:
1049-
match = DESCRIPTION_PATTERN.search(block_text)
1050-
if not match:
1051-
return None
1052-
1053-
prefix = match.group("prefix") or ""
1054-
quote = match.group("quote")
1055-
value = match.group("value")
1056-
literal = f"{prefix}{quote}{value}{quote}"
1057-
1058-
# datamodel-code-generator emits standard string literals, but fall back to raw text on parse errors.
1059-
try:
1060-
parsed = ast.literal_eval(literal)
1061-
except (SyntaxError, ValueError):
1062-
return value.replace("\\n", "\n")
1063-
1064-
if isinstance(parsed, str):
1065-
return parsed
1066-
return str(parsed)
1067-
1068-
10691050
def _inject_enum_aliases(content: str) -> str:
10701051
enum_lines = [
10711052
f"{name} = Literal[{', '.join(repr(value) for value in values)}]" for name, values in ENUM_LITERAL_MAP.items()
@@ -1080,6 +1061,45 @@ def _inject_enum_aliases(content: str) -> str:
10801061
return content[:insertion_point] + block + content[insertion_point:]
10811062

10821063

1064+
def _remove_unreferenced_root_models(content: str) -> str:
1065+
tree = ast.parse(content)
1066+
root_models = {
1067+
node.name: node
1068+
for node in tree.body
1069+
if isinstance(node, ast.ClassDef)
1070+
and any(
1071+
isinstance(base, ast.Subscript) and isinstance(base.value, ast.Name) and base.value.id == "RootModel"
1072+
for base in node.bases
1073+
)
1074+
}
1075+
1076+
referenced_roots = set(PUBLIC_ROOT_MODELS)
1077+
root_dependencies: dict[str, set[str]] = {}
1078+
for statement in tree.body:
1079+
loaded_names = {
1080+
node.id
1081+
for node in ast.walk(statement)
1082+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in root_models
1083+
}
1084+
if isinstance(statement, ast.ClassDef) and statement.name in root_models:
1085+
root_dependencies[statement.name] = loaded_names
1086+
else:
1087+
referenced_roots.update(loaded_names)
1088+
1089+
pending = list(referenced_roots)
1090+
while pending:
1091+
root_name = pending.pop()
1092+
for dependency in root_dependencies.get(root_name, set()) - referenced_roots:
1093+
referenced_roots.add(dependency)
1094+
pending.append(dependency)
1095+
1096+
unused_models = [model for name, model in root_models.items() if name not in referenced_roots]
1097+
lines = content.splitlines(keepends=True)
1098+
for model in sorted(unused_models, key=lambda item: item.lineno, reverse=True):
1099+
del lines[model.lineno - 1 : model.end_lineno]
1100+
return re.sub(r"\n{4,}", "\n\n\n", "".join(lines))
1101+
1102+
10831103
def _remove_unused_models(content: str) -> str:
10841104
for model_name in MODELS_TO_REMOVE:
10851105
pattern = re.compile(

scripts/gen_signature.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ def _to_param_def(self, name: str, field: FieldInfo) -> tuple[ast.arg, ast.expr
127127
return arg, default
128128

129129
def _format_annotation(self, annotation: t.Any) -> ast.expr:
130-
if t.get_origin(annotation) is t.Literal and annotation in self._literals.values():
130+
origin = t.get_origin(annotation)
131+
if origin is t.Annotated:
132+
return self._format_annotation(t.get_args(annotation)[0])
133+
if origin is t.Literal and annotation in self._literals.values():
131134
name = next(name for name, value in self._literals.items() if value is annotation)
132135
self._add_schema_import(name)
133136
return ast.Name(id=name)
@@ -139,7 +142,6 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
139142
self._add_schema_import(annotation.__name__)
140143
return ast.Name(id=annotation.__name__)
141144
elif args := t.get_args(annotation):
142-
origin = t.get_origin(annotation)
143145
return ast.Subscript(
144146
value=self._format_annotation(origin),
145147
slice=ast.Tuple(elts=[self._format_annotation(arg) for arg in args], ctx=ast.Load())

0 commit comments

Comments
 (0)