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.
- framework_m_studio/__init__.py +16 -0
- framework_m_studio/app.py +283 -0
- framework_m_studio/cli.py +247 -0
- framework_m_studio/codegen/__init__.py +34 -0
- framework_m_studio/codegen/generator.py +291 -0
- framework_m_studio/codegen/parser.py +545 -0
- framework_m_studio/codegen/templates/doctype.py.jinja2 +69 -0
- framework_m_studio/codegen/templates/test_doctype.py.jinja2 +58 -0
- framework_m_studio/codegen/test_generator.py +368 -0
- framework_m_studio/codegen/transformer.py +406 -0
- framework_m_studio/discovery.py +193 -0
- framework_m_studio/docs_generator.py +318 -0
- framework_m_studio/git/__init__.py +1 -0
- framework_m_studio/git/adapter.py +309 -0
- framework_m_studio/git/github_provider.py +321 -0
- framework_m_studio/git/protocol.py +249 -0
- framework_m_studio/py.typed +0 -0
- framework_m_studio/routes.py +552 -0
- framework_m_studio/sdk_generator.py +239 -0
- framework_m_studio/workspace.py +295 -0
- framework_m_studio-0.2.2.dist-info/METADATA +65 -0
- framework_m_studio-0.2.2.dist-info/RECORD +24 -0
- framework_m_studio-0.2.2.dist-info/WHEEL +4 -0
- framework_m_studio-0.2.2.dist-info/entry_points.txt +4 -0
|
@@ -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 %}
|