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,545 @@
1
+ """DocType Parser - LibCST-based Python file parser.
2
+
3
+ This module provides the `parse_doctype` function that extracts structured
4
+ information from DocType Python files, including:
5
+ - Class definition and bases
6
+ - Field definitions with types and defaults
7
+ - Config class metadata (tablename, verbose_name, etc.)
8
+ - Docstrings and comments
9
+
10
+ Strategy: "LibCST for parsing, Jinja for generation"
11
+ - LibCST preserves comments and formatting when modifying existing files
12
+ - Jinja templates provide clean scaffolding for new files
13
+
14
+ Usage:
15
+ from framework_m_studio.codegen.parser import parse_doctype
16
+
17
+ schema = parse_doctype("/path/to/todo.py")
18
+ # Returns structured dict with class info, fields, config
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import libcst as cst
28
+
29
+ # =============================================================================
30
+ # Data Models
31
+ # =============================================================================
32
+
33
+
34
+ @dataclass
35
+ class FieldSchema:
36
+ """Parsed field information from a DocType class."""
37
+
38
+ name: str
39
+ type: str
40
+ default: str | None = None
41
+ required: bool = True
42
+ description: str | None = None
43
+ label: str | None = None
44
+ validators: dict[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @dataclass
48
+ class ConfigSchema:
49
+ """Parsed Config class metadata."""
50
+
51
+ tablename: str | None = None
52
+ verbose_name: str | None = None
53
+ verbose_name_plural: str | None = None
54
+ is_submittable: bool = False
55
+ is_tree: bool = False
56
+ track_changes: bool = True
57
+ extra: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass
61
+ class DocTypeSchema:
62
+ """Complete parsed DocType schema."""
63
+
64
+ name: str
65
+ module: str
66
+ file_path: str
67
+ bases: list[str] = field(default_factory=list)
68
+ fields: list[FieldSchema] = field(default_factory=list)
69
+ config: ConfigSchema = field(default_factory=ConfigSchema)
70
+ docstring: str | None = None
71
+ imports: list[str] = field(default_factory=list)
72
+ custom_methods: list[str] = field(default_factory=list)
73
+
74
+
75
+ # =============================================================================
76
+ # LibCST Visitor for Complete DocType Parsing
77
+ # =============================================================================
78
+
79
+
80
+ class DocTypeParserVisitor(cst.CSTVisitor):
81
+ """LibCST visitor to extract complete DocType information."""
82
+
83
+ def __init__(self) -> None:
84
+ self.doctypes: list[DocTypeSchema] = []
85
+ self.imports: list[str] = []
86
+ self._current_class: str | None = None
87
+ self._current_bases: list[str] = []
88
+ self._current_fields: list[FieldSchema] = []
89
+ self._current_docstring: str | None = None
90
+ self._current_config: ConfigSchema = ConfigSchema()
91
+ self._current_methods: list[str] = []
92
+ self._in_config_class: bool = False
93
+
94
+ def visit_Import(self, node: cst.Import) -> bool:
95
+ """Collect import statements."""
96
+ self.imports.append(_node_to_source(node))
97
+ return False
98
+
99
+ def visit_ImportFrom(self, node: cst.ImportFrom) -> bool:
100
+ """Collect from imports."""
101
+ self.imports.append(_node_to_source(node))
102
+ return False
103
+
104
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
105
+ """Visit class definition."""
106
+ class_name = node.name.value
107
+
108
+ # Check if this is the Config or Meta inner class
109
+ if self._current_class and class_name in ("Config", "Meta"):
110
+ self._in_config_class = True
111
+ return True
112
+
113
+ # Get base classes
114
+ bases: list[str] = []
115
+ for arg in node.bases:
116
+ if isinstance(arg.value, cst.Name):
117
+ bases.append(arg.value.value)
118
+ elif isinstance(arg.value, cst.Attribute):
119
+ bases.append(_get_attribute_name(arg.value))
120
+
121
+ # Check if this is a DocType
122
+ is_doctype = any(
123
+ base in ("BaseDocType", "DocType") or "DocType" in base for base in bases
124
+ )
125
+
126
+ if is_doctype:
127
+ self._current_class = class_name
128
+ self._current_bases = bases
129
+ self._current_fields = []
130
+ self._current_methods = []
131
+ self._current_config = ConfigSchema()
132
+
133
+ # Extract docstring
134
+ self._current_docstring = _extract_docstring(node)
135
+
136
+ return True
137
+
138
+ def leave_ClassDef(self, node: cst.ClassDef) -> None:
139
+ """Leave class definition."""
140
+ class_name = node.name.value
141
+
142
+ if class_name in ("Config", "Meta"):
143
+ self._in_config_class = False
144
+ return
145
+
146
+ if self._current_class == class_name:
147
+ doctype = DocTypeSchema(
148
+ name=self._current_class,
149
+ module="",
150
+ file_path="",
151
+ bases=self._current_bases,
152
+ fields=self._current_fields,
153
+ config=self._current_config,
154
+ docstring=self._current_docstring,
155
+ imports=self.imports.copy(),
156
+ custom_methods=self._current_methods,
157
+ )
158
+ self.doctypes.append(doctype)
159
+
160
+ self._current_class = None
161
+ self._current_bases = []
162
+ self._current_fields = []
163
+ self._current_docstring = None
164
+ self._current_config = ConfigSchema()
165
+ self._current_methods = []
166
+
167
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
168
+ """Visit function/method definition."""
169
+ if self._current_class and not self._in_config_class:
170
+ method_name = node.name.value
171
+ if not method_name.startswith("_"):
172
+ self._current_methods.append(method_name)
173
+ return False
174
+
175
+ def visit_AnnAssign(self, node: cst.AnnAssign) -> bool:
176
+ """Visit annotated assignment (field definition)."""
177
+ if self._current_class is None:
178
+ return False
179
+
180
+ if not isinstance(node.target, cst.Name):
181
+ return False
182
+
183
+ field_name = node.target.value
184
+
185
+ # Config class attributes
186
+ if self._in_config_class:
187
+ self._parse_config_attribute(field_name, node)
188
+ return False
189
+
190
+ # Skip private/dunder fields
191
+ if field_name.startswith("_"):
192
+ return False
193
+
194
+ # Parse field
195
+ field_type_raw = node.annotation.annotation
196
+ field_type = _annotation_to_string(field_type_raw)
197
+ default_value: str | None = None
198
+ required = node.value is None
199
+ validators: dict[str, Any] = {}
200
+ description: str | None = None
201
+ label: str | None = None
202
+
203
+ # Check for Annotated[type, Field(...)] pattern
204
+ if isinstance(field_type_raw, cst.Subscript):
205
+ subscript_value = _node_to_source(field_type_raw.value)
206
+ if subscript_value == "Annotated":
207
+ # libcst slice is a tuple of SubscriptElement objects
208
+ slice_elements = field_type_raw.slice
209
+ if isinstance(slice_elements, tuple) and len(slice_elements) >= 1:
210
+ # First element is the actual type (wrapped in Index)
211
+ first_elem = slice_elements[0]
212
+ if isinstance(first_elem.slice, cst.Index):
213
+ field_type = _node_to_source(first_elem.slice.value)
214
+ else:
215
+ field_type = _node_to_source(first_elem.slice)
216
+
217
+ # Look for Field() in remaining elements
218
+ for elem in slice_elements[1:]:
219
+ if isinstance(elem.slice, cst.Index):
220
+ value = elem.slice.value
221
+ else:
222
+ value = elem.slice
223
+
224
+ if isinstance(value, cst.Call):
225
+ func_name = _node_to_source(value.func)
226
+ if func_name == "Field":
227
+ default_value, validators, description, label = (
228
+ _parse_field_call(value)
229
+ )
230
+ if default_value == "...":
231
+ required = True
232
+ default_value = None
233
+ elif default_value is not None:
234
+ required = False
235
+ break
236
+
237
+ # Check for Optional type
238
+ if "Optional" in field_type or "None" in field_type:
239
+ required = False
240
+
241
+ # Handle Field() call in value (old style: field: type = Field(...))
242
+ if node.value and isinstance(node.value, cst.Call):
243
+ func_name = _node_to_source(node.value.func)
244
+ if func_name == "Field":
245
+ # Extract default from first positional argument
246
+ default_value, validators, description, label = _parse_field_call(
247
+ node.value
248
+ )
249
+ # If default is "..." it means required with no default
250
+ if default_value == "...":
251
+ required = True
252
+ default_value = None
253
+ elif default_value is not None:
254
+ required = False
255
+ else:
256
+ # Some other call, keep as is
257
+ default_value = _node_to_source(node.value)
258
+ elif node.value:
259
+ default_value = _node_to_source(node.value)
260
+
261
+ field_schema = FieldSchema(
262
+ name=field_name,
263
+ type=field_type,
264
+ default=default_value,
265
+ required=required,
266
+ description=description,
267
+ label=label,
268
+ validators=validators,
269
+ )
270
+ self._current_fields.append(field_schema)
271
+
272
+ return False
273
+
274
+ def visit_Assign(self, node: cst.Assign) -> bool:
275
+ """Visit simple assignment (for Config class)."""
276
+ if not self._in_config_class:
277
+ return False
278
+
279
+ for target in node.targets:
280
+ if isinstance(target.target, cst.Name):
281
+ field_name = target.target.value
282
+ value = _node_to_source(node.value)
283
+ self._set_config_value(field_name, value)
284
+
285
+ return False
286
+
287
+ def _parse_config_attribute(self, name: str, node: cst.AnnAssign) -> None:
288
+ """Parse Config class attribute."""
289
+ value = _node_to_source(node.value) if node.value else None
290
+ self._set_config_value(name, value)
291
+
292
+ def _set_config_value(self, name: str, value: str | None) -> None:
293
+ """Set a config value."""
294
+ if value is None:
295
+ return
296
+
297
+ # Remove quotes from strings
298
+ clean_value = value.strip("\"'")
299
+
300
+ if name == "tablename":
301
+ self._current_config.tablename = clean_value
302
+ elif name == "verbose_name":
303
+ self._current_config.verbose_name = clean_value
304
+ elif name == "verbose_name_plural":
305
+ self._current_config.verbose_name_plural = clean_value
306
+ elif name == "is_submittable":
307
+ self._current_config.is_submittable = value.lower() == "true"
308
+ elif name == "is_tree":
309
+ self._current_config.is_tree = value.lower() == "true"
310
+ elif name == "track_changes":
311
+ self._current_config.track_changes = value.lower() != "false"
312
+ else:
313
+ self._current_config.extra[name] = value
314
+
315
+
316
+ # =============================================================================
317
+ # Helper Functions
318
+ # =============================================================================
319
+
320
+
321
+ def _get_attribute_name(node: cst.Attribute) -> str:
322
+ """Get full attribute name from Attribute node."""
323
+ parts: list[str] = []
324
+ current: cst.BaseExpression = node
325
+
326
+ while isinstance(current, cst.Attribute):
327
+ parts.append(current.attr.value)
328
+ current = current.value
329
+
330
+ if isinstance(current, cst.Name):
331
+ parts.append(current.value)
332
+
333
+ return ".".join(reversed(parts))
334
+
335
+
336
+ def _annotation_to_string(node: cst.BaseExpression) -> str:
337
+ """Convert annotation node to string."""
338
+ return _node_to_source(node)
339
+
340
+
341
+ def _node_to_source(node: cst.CSTNode) -> str:
342
+ """Convert CST node to source code string."""
343
+ return cst.Module(body=[]).code_for_node(node)
344
+
345
+
346
+ def _extract_docstring(node: cst.ClassDef) -> str | None:
347
+ """Extract docstring from class definition."""
348
+ if not node.body or not node.body.body:
349
+ return None
350
+
351
+ first_stmt = node.body.body[0]
352
+ if not isinstance(first_stmt, cst.SimpleStatementLine):
353
+ return None
354
+
355
+ if not first_stmt.body or not isinstance(first_stmt.body[0], cst.Expr):
356
+ return None
357
+
358
+ expr = first_stmt.body[0].value
359
+ if isinstance(expr, cst.SimpleString):
360
+ value = expr.value
361
+ if value.startswith('"""') or value.startswith("'''"):
362
+ return value[3:-3].strip()
363
+ elif value.startswith('"') or value.startswith("'"):
364
+ return value[1:-1].strip()
365
+ elif isinstance(expr, cst.ConcatenatedString):
366
+ # Handle multi-line docstrings
367
+ parts = []
368
+ for part in (expr.left, expr.right):
369
+ if isinstance(part, cst.SimpleString):
370
+ parts.append(part.value.strip("\"'"))
371
+ return "".join(parts)
372
+
373
+ return None
374
+
375
+
376
+ def _parse_field_call(
377
+ node: cst.Call,
378
+ ) -> tuple[str | None, dict[str, Any], str | None, str | None]:
379
+ """Parse Field(...) call and extract default, validators, description, and label.
380
+
381
+ Returns:
382
+ Tuple of (default_value, validators_dict, description, label)
383
+ """
384
+ default_value: str | None = None
385
+ validators: dict[str, Any] = {}
386
+ description: str | None = None
387
+ label: str | None = None
388
+
389
+ for i, arg in enumerate(node.args):
390
+ # First positional argument is the default value
391
+ if arg.keyword is None and i == 0:
392
+ default_value = _node_to_source(arg.value)
393
+ continue
394
+
395
+ if isinstance(arg.keyword, cst.Name):
396
+ key = arg.keyword.value
397
+ value_str = _node_to_source(arg.value)
398
+
399
+ # Map Pydantic Field kwargs to our validator schema
400
+ if key == "ge":
401
+ # Remove trailing decimal if present (100000.0 -> 100000)
402
+ val = float(value_str)
403
+ validators["min_value"] = int(val) if val == int(val) else val
404
+ elif key == "le":
405
+ val = float(value_str)
406
+ validators["max_value"] = int(val) if val == int(val) else val
407
+ elif key == "gt":
408
+ val = float(value_str)
409
+ validators["min_value"] = int(val) + 1 if val == int(val) else val
410
+ elif key == "lt":
411
+ val = float(value_str)
412
+ validators["max_value"] = int(val) - 1 if val == int(val) else val
413
+ elif key == "min_length":
414
+ validators["min_length"] = int(value_str)
415
+ elif key == "max_length":
416
+ validators["max_length"] = int(value_str)
417
+ elif key in ("pattern", "regex"):
418
+ # Remove r prefix and quotes
419
+ pattern = value_str
420
+ if pattern.startswith("r"):
421
+ pattern = pattern[1:]
422
+ pattern = pattern.strip("\"'")
423
+ validators["pattern"] = pattern
424
+ elif key == "description":
425
+ description = value_str.strip("\"'")
426
+ elif key == "label":
427
+ label = value_str.strip("\"'")
428
+ elif key == "default":
429
+ default_value = value_str
430
+
431
+ return default_value, validators, description, label
432
+
433
+
434
+ def _path_to_module(file_path: Path) -> str:
435
+ """Convert file path to Python module path."""
436
+ parts = file_path.parts
437
+ try:
438
+ src_idx = parts.index("src")
439
+ module_parts = parts[src_idx + 1 : -1]
440
+ module_name = file_path.stem
441
+ return ".".join([*module_parts, module_name])
442
+ except ValueError:
443
+ return file_path.stem
444
+
445
+
446
+ # =============================================================================
447
+ # Public API
448
+ # =============================================================================
449
+
450
+
451
+ def parse_doctype(file_path: str | Path) -> dict[str, Any]:
452
+ """Parse a DocType Python file and return structured schema.
453
+
454
+ Args:
455
+ file_path: Path to the Python file containing DocType
456
+
457
+ Returns:
458
+ Dictionary with keys:
459
+ - name: DocType class name
460
+ - module: Python module path
461
+ - file_path: Absolute file path
462
+ - bases: List of base class names
463
+ - fields: List of field definitions
464
+ - config: Config class metadata
465
+ - docstring: Class docstring
466
+ - imports: List of import statements
467
+ - custom_methods: List of custom method names
468
+
469
+ Raises:
470
+ FileNotFoundError: If file doesn't exist
471
+ ValueError: If no DocType found in file
472
+
473
+ Example:
474
+ >>> schema = parse_doctype("src/myapp/doctypes/todo.py")
475
+ >>> schema["name"]
476
+ 'Todo'
477
+ >>> schema["fields"][0]["name"]
478
+ 'title'
479
+ """
480
+ path = Path(file_path)
481
+ if not path.exists():
482
+ raise FileNotFoundError(f"File not found: {file_path}")
483
+
484
+ source = path.read_text(encoding="utf-8")
485
+
486
+ try:
487
+ tree = cst.parse_module(source)
488
+ except cst.ParserSyntaxError as e:
489
+ raise ValueError(f"Failed to parse Python file: {e}") from e
490
+
491
+ visitor = DocTypeParserVisitor()
492
+ tree.visit(visitor)
493
+
494
+ if not visitor.doctypes:
495
+ raise ValueError(f"No DocType class found in {file_path}")
496
+
497
+ # Return the first DocType found
498
+ doctype = visitor.doctypes[0]
499
+ doctype.file_path = str(path.absolute())
500
+ doctype.module = _path_to_module(path)
501
+
502
+ return doctype_to_dict(doctype)
503
+
504
+
505
+ def doctype_to_dict(doctype: DocTypeSchema) -> dict[str, Any]:
506
+ """Convert DocTypeSchema to dictionary."""
507
+ return {
508
+ "name": doctype.name,
509
+ "module": doctype.module,
510
+ "file_path": doctype.file_path,
511
+ "bases": doctype.bases,
512
+ "fields": [
513
+ {
514
+ "name": f.name,
515
+ "type": f.type,
516
+ "default": f.default,
517
+ "required": f.required,
518
+ "label": f.label,
519
+ "description": f.description,
520
+ "validators": f.validators,
521
+ }
522
+ for f in doctype.fields
523
+ ],
524
+ "config": {
525
+ "tablename": doctype.config.tablename,
526
+ "verbose_name": doctype.config.verbose_name,
527
+ "verbose_name_plural": doctype.config.verbose_name_plural,
528
+ "is_submittable": doctype.config.is_submittable,
529
+ "is_tree": doctype.config.is_tree,
530
+ "track_changes": doctype.config.track_changes,
531
+ **doctype.config.extra,
532
+ },
533
+ "docstring": doctype.docstring,
534
+ "imports": doctype.imports,
535
+ "custom_methods": doctype.custom_methods,
536
+ }
537
+
538
+
539
+ __all__ = [
540
+ "ConfigSchema",
541
+ "DocTypeSchema",
542
+ "FieldSchema",
543
+ "doctype_to_dict",
544
+ "parse_doctype",
545
+ ]
@@ -0,0 +1,69 @@
1
+ """Auto-generated DocType: {{ name }}.
2
+
3
+ Generated by Framework M Studio.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import ClassVar
9
+
10
+ {% if imports %}
11
+ {% for imp in imports %}
12
+ {{ imp }}
13
+ {% endfor %}
14
+ {% endif %}
15
+ from pydantic import Field
16
+ from framework_m.core.base import BaseDocType
17
+
18
+
19
+ class {{ name }}(BaseDocType):
20
+ {% if docstring %}
21
+ """{{ docstring }}"""
22
+
23
+ {% endif %}
24
+ {% if fields %}
25
+ {% for field in fields %}
26
+ {%- set has_field_args = field.description or field.label or field.validators %}
27
+ {% if has_field_args %}
28
+ {{ field.name }}: {{ field.type }}{{ ' | None' if not field.required else '' }} = Field(
29
+ {%- if field.required and (not field.default or field.default == 'None') %}...{% elif field.default and field.default != 'None' %}{{ field.default }}{% else %}None{% endif %}
30
+ {%- if field.description %}, description="{{ field.description }}"{% endif %}
31
+ {%- if field.label %}, label="{{ field.label }}"{% endif %}
32
+ {%- if field.validators %}
33
+ {%- if field.validators.min_length %}, min_length={{ field.validators.min_length }}{% endif %}
34
+ {%- if field.validators.max_length %}, max_length={{ field.validators.max_length }}{% endif %}
35
+ {%- if field.validators.pattern %}, pattern=r"{{ field.validators.pattern }}"{% endif %}
36
+ {%- if field.validators.min_value is defined %}, ge={{ field.validators.min_value }}{% endif %}
37
+ {%- if field.validators.max_value is defined %}, le={{ field.validators.max_value }}{% endif %}
38
+ {%- endif %}
39
+ )
40
+ {% elif field.required and not field.default %}
41
+ {{ field.name }}: {{ field.type }}
42
+ {% elif field.default %}
43
+ {{ field.name }}: {{ field.type }} = {{ field.default }}
44
+ {% else %}
45
+ {{ field.name }}: {{ field.type }} | None = None
46
+ {% endif %}
47
+ {% endfor %}
48
+ {% else %}
49
+ pass
50
+ {% endif %}
51
+
52
+ class Meta:
53
+ """DocType metadata."""
54
+
55
+ api_resource: ClassVar[bool] = True # Expose via REST API
56
+ show_in_desk: ClassVar[bool] = True # Show in Desk UI
57
+
58
+ {% if config %}
59
+ class Config:
60
+ {% if config.tablename %}
61
+ tablename = "{{ config.tablename }}"
62
+ {% endif %}
63
+ {% if config.verbose_name %}
64
+ verbose_name = "{{ config.verbose_name }}"
65
+ {% endif %}
66
+ {% if config.is_submittable %}
67
+ is_submittable = True
68
+ {% endif %}
69
+ {% endif %}
@@ -0,0 +1,58 @@
1
+ """Test module for {{ name }}.
2
+
3
+ Auto-generated by Framework M Studio.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+
11
+ class Test{{ name }}:
12
+ """Tests for {{ name }} DocType."""
13
+
14
+ {% if fields %}
15
+ def test_create_{{ snake_name }}(self) -> None:
16
+ """Test creating a {{ name }} instance."""
17
+ from {{ module }} import {{ name }}
18
+
19
+ doc = {{ name }}(
20
+ {% for field in fields if field.required %}
21
+ {{ field.name }}={{ field.test_value }},
22
+ {% endfor %}
23
+ )
24
+ assert doc is not None
25
+ {% for field in fields if field.required %}
26
+ assert doc.{{ field.name }} == {{ field.test_value }}
27
+ {% endfor %}
28
+
29
+ def test_{{ snake_name }}_defaults(self) -> None:
30
+ """Test default values for {{ name }}."""
31
+ from {{ module }} import {{ name }}
32
+
33
+ doc = {{ name }}(
34
+ {% for field in fields if field.required %}
35
+ {{ field.name }}={{ field.test_value }},
36
+ {% endfor %}
37
+ )
38
+ {% for field in fields if field.default %}
39
+ assert doc.{{ field.name }} == {{ field.default }}
40
+ {% endfor %}
41
+
42
+ {% if has_required_fields %}
43
+ def test_{{ snake_name }}_validation(self) -> None:
44
+ """Test validation for required fields."""
45
+ from {{ module }} import {{ name }}
46
+ import pydantic
47
+
48
+ with pytest.raises(pydantic.ValidationError):
49
+ {{ name }}() # Missing required fields
50
+ {% endif %}
51
+ {% else %}
52
+ def test_create_{{ snake_name }}(self) -> None:
53
+ """Test creating a {{ name }} instance."""
54
+ from {{ module }} import {{ name }}
55
+
56
+ doc = {{ name }}()
57
+ assert doc is not None
58
+ {% endif %}