framework-m-studio 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ """DocType Code Generator.
2
+
3
+ Strategy: "Jinja for Creation, LibCST for Mutation"
4
+
5
+ - **Creation (Scaffolding)**: Use Jinja2 templates to generate clean Python code from scratch.
6
+ Shared with CLI `m new:doctype`.
7
+
8
+ - **Mutation (Transformer)**: Use LibCST to parse and modify existing files, preserving
9
+ comments and custom methods.
10
+
11
+ Usage:
12
+ from framework_m_studio.codegen.generator import generate_doctype_source
13
+
14
+ code = generate_doctype_source({
15
+ "name": "Todo",
16
+ "fields": [
17
+ {"name": "title", "type": "str", "required": True},
18
+ {"name": "completed", "type": "bool", "default": "False"},
19
+ ],
20
+ })
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
30
+
31
+ # Template directory
32
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
33
+
34
+
35
+ def _get_jinja_env() -> Environment:
36
+ """Get configured Jinja2 environment."""
37
+ return Environment(
38
+ loader=FileSystemLoader(str(TEMPLATE_DIR)),
39
+ autoescape=select_autoescape(enabled_extensions=()),
40
+ trim_blocks=True,
41
+ lstrip_blocks=True,
42
+ keep_trailing_newline=True,
43
+ )
44
+
45
+
46
+ def _to_snake_case(name: str) -> str:
47
+ """Convert PascalCase to snake_case."""
48
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
49
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
50
+
51
+
52
+ def _get_test_value(field_type: str) -> str:
53
+ """Get a test value for a field type."""
54
+ type_values = {
55
+ "str": '"test"',
56
+ "int": "1",
57
+ "float": "1.0",
58
+ "bool": "True",
59
+ "date": "date.today()",
60
+ "datetime": "datetime.now()",
61
+ "UUID": "uuid4()",
62
+ "uuid": "uuid4()",
63
+ }
64
+
65
+ # Check for base type (handle Optional, Union, etc.)
66
+ base_type = field_type.split("[")[0].split("|")[0].strip()
67
+ return type_values.get(base_type, '"test"')
68
+
69
+
70
+ def generate_doctype_source(schema: dict[str, Any]) -> str:
71
+ """Generate Python source code for a DocType.
72
+
73
+ Args:
74
+ schema: Dictionary with DocType schema:
75
+ - name: str - Class name (PascalCase)
76
+ - fields: list[dict] - Field definitions
77
+ - docstring: str | None - Class docstring
78
+ - config: dict | None - Config class metadata
79
+ - imports: list[str] | None - Additional imports
80
+
81
+ Returns:
82
+ Generated Python source code as string.
83
+
84
+ Example:
85
+ >>> code = generate_doctype_source({
86
+ ... "name": "Todo",
87
+ ... "fields": [{"name": "title", "type": "str", "required": True}],
88
+ ... })
89
+ >>> print(code)
90
+ """
91
+ env = _get_jinja_env()
92
+ template = env.get_template("doctype.py.jinja2")
93
+
94
+ # Prepare context
95
+ context = {
96
+ "name": schema["name"],
97
+ "docstring": schema.get("docstring"),
98
+ "fields": schema.get("fields", []),
99
+ "config": schema.get("config"),
100
+ "imports": schema.get("imports", []),
101
+ }
102
+
103
+ return template.render(**context)
104
+
105
+
106
+ def generate_test_source(schema: dict[str, Any]) -> str:
107
+ """Generate test file source code for a DocType.
108
+
109
+ Args:
110
+ schema: Dictionary with DocType schema (same as generate_doctype_source)
111
+ Plus:
112
+ - module: str - Python module path for imports
113
+
114
+ Returns:
115
+ Generated test file source code.
116
+ """
117
+ env = _get_jinja_env()
118
+ template = env.get_template("test_doctype.py.jinja2")
119
+
120
+ # Prepare fields with test values
121
+ fields = schema.get("fields", [])
122
+ for field in fields:
123
+ if "test_value" not in field:
124
+ field["test_value"] = _get_test_value(field.get("type", "str"))
125
+
126
+ # Check if has required fields
127
+ has_required = any(f.get("required", False) for f in fields)
128
+
129
+ context = {
130
+ "name": schema["name"],
131
+ "snake_name": _to_snake_case(schema["name"]),
132
+ "module": schema.get("module", "myapp.doctypes"),
133
+ "fields": fields,
134
+ "has_required_fields": has_required,
135
+ }
136
+
137
+ return template.render(**context)
138
+
139
+
140
+ def update_doctype_source(
141
+ source: str,
142
+ schema: dict[str, Any],
143
+ ) -> str:
144
+ """Update existing DocType source by adding/modifying fields.
145
+
146
+ Uses LibCST to preserve comments and custom methods.
147
+
148
+ Args:
149
+ source: Existing Python source code
150
+ schema: Updated DocType schema with fields to add/modify
151
+
152
+ Returns:
153
+ Updated Python source code.
154
+
155
+ Note:
156
+ This function preserves:
157
+ - Comments and docstrings
158
+ - Custom methods
159
+ - Import statements
160
+ - Existing fields not in schema (unless explicitly removed)
161
+ """
162
+ import libcst as cst
163
+
164
+ class DocTypeUpdater(cst.CSTTransformer):
165
+ """LibCST transformer to update DocType fields."""
166
+
167
+ def __init__(self, schema: dict[str, Any]) -> None:
168
+ self.schema = schema
169
+ self.target_class = schema["name"]
170
+ self.fields_to_add = {f["name"]: f for f in schema.get("fields", [])}
171
+ self.in_target_class = False
172
+ self.existing_fields: set[str] = set()
173
+
174
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
175
+ """Track when we're inside target class."""
176
+ if node.name.value == self.target_class:
177
+ self.in_target_class = True
178
+ return True
179
+
180
+ def leave_ClassDef(
181
+ self,
182
+ original_node: cst.ClassDef,
183
+ updated_node: cst.ClassDef,
184
+ ) -> cst.ClassDef:
185
+ """Add new fields when leaving target class."""
186
+ if original_node.name.value != self.target_class:
187
+ return updated_node
188
+
189
+ self.in_target_class = False
190
+
191
+ # Find fields that need to be added
192
+ fields_to_add = [
193
+ f
194
+ for name, f in self.fields_to_add.items()
195
+ if name not in self.existing_fields
196
+ ]
197
+
198
+ if not fields_to_add:
199
+ return updated_node
200
+
201
+ # Generate new field statements
202
+ new_statements = []
203
+ for field in fields_to_add:
204
+ stmt = self._create_field_statement(field)
205
+ new_statements.append(stmt)
206
+
207
+ # Insert new statements at the end of class body
208
+ new_body = list(updated_node.body.body) + new_statements
209
+
210
+ return updated_node.with_changes(
211
+ body=updated_node.body.with_changes(body=new_body)
212
+ )
213
+
214
+ def leave_AnnAssign(
215
+ self,
216
+ original_node: cst.AnnAssign,
217
+ updated_node: cst.AnnAssign,
218
+ ) -> cst.AnnAssign:
219
+ """Update existing field definitions."""
220
+ if not self.in_target_class:
221
+ return updated_node
222
+
223
+ if not isinstance(original_node.target, cst.Name):
224
+ return updated_node
225
+
226
+ field_name = original_node.target.value
227
+ self.existing_fields.add(field_name)
228
+
229
+ # Check if field needs update
230
+ if field_name not in self.fields_to_add:
231
+ return updated_node
232
+
233
+ new_field = self.fields_to_add[field_name]
234
+
235
+ # Update type annotation
236
+ new_annotation = cst.Annotation(
237
+ annotation=cst.parse_expression(new_field["type"])
238
+ )
239
+
240
+ # Update default value
241
+ new_value = None
242
+ if new_field.get("default"):
243
+ new_value = cst.parse_expression(new_field["default"])
244
+ elif not new_field.get("required", True):
245
+ new_value = cst.Name("None")
246
+
247
+ return updated_node.with_changes(
248
+ annotation=new_annotation,
249
+ value=new_value,
250
+ )
251
+
252
+ def _create_field_statement(
253
+ self,
254
+ field: dict[str, Any],
255
+ ) -> cst.SimpleStatementLine:
256
+ """Create a new field statement."""
257
+ type_str = field["type"]
258
+ if not field.get("required", True) and "None" not in type_str:
259
+ type_str = f"{type_str} | None"
260
+
261
+ annotation = cst.Annotation(annotation=cst.parse_expression(type_str))
262
+
263
+ value = None
264
+ if field.get("default"):
265
+ value = cst.parse_expression(field["default"])
266
+ elif not field.get("required", True):
267
+ value = cst.Name("None")
268
+
269
+ return cst.SimpleStatementLine(
270
+ body=[
271
+ cst.AnnAssign(
272
+ target=cst.Name(field["name"]),
273
+ annotation=annotation,
274
+ value=value,
275
+ )
276
+ ]
277
+ )
278
+
279
+ # Parse and transform
280
+ tree = cst.parse_module(source)
281
+ transformer = DocTypeUpdater(schema)
282
+ updated_tree = tree.visit(transformer)
283
+
284
+ return updated_tree.code
285
+
286
+
287
+ __all__ = [
288
+ "generate_doctype_source",
289
+ "generate_test_source",
290
+ "update_doctype_source",
291
+ ]