2020VERSION_FILE = SCHEMA_DIR / "VERSION"
2121SCHEMA_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-
3023STDIO_TYPE_LITERAL = 'Literal["2#-datamodel-code-generator-#-object-#-special-#"]'
3124MODELS_TO_REMOVE = [
3225 "AgentClientProtocol" ,
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+
140158FIELD_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+
665714def _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
927982def _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-
10691050def _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+
10831103def _remove_unused_models (content : str ) -> str :
10841104 for model_name in MODELS_TO_REMOVE :
10851105 pattern = re .compile (
0 commit comments