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,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
|
+
]
|