framework-m-studio 0.2.3__py3-none-any.whl → 0.3.0__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 +6 -1
- framework_m_studio/app.py +56 -11
- framework_m_studio/checklist_parser.py +421 -0
- framework_m_studio/cli/__init__.py +752 -0
- framework_m_studio/cli/build.py +421 -0
- framework_m_studio/cli/dev.py +214 -0
- framework_m_studio/cli/new.py +754 -0
- framework_m_studio/cli/quality.py +157 -0
- framework_m_studio/cli/studio.py +159 -0
- framework_m_studio/cli/utility.py +50 -0
- framework_m_studio/codegen/generator.py +6 -2
- framework_m_studio/codegen/parser.py +101 -4
- framework_m_studio/codegen/templates/doctype.py.jinja2 +19 -10
- framework_m_studio/codegen/test_generator.py +6 -2
- framework_m_studio/discovery.py +15 -5
- framework_m_studio/docs_generator.py +298 -2
- framework_m_studio/protocol_scanner.py +435 -0
- framework_m_studio/routes.py +39 -11
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/METADATA +7 -2
- framework_m_studio-0.3.0.dist-info/RECORD +32 -0
- framework_m_studio-0.3.0.dist-info/entry_points.txt +18 -0
- framework_m_studio/cli.py +0 -247
- framework_m_studio/static/assets/index-BJ5Noua8.js +0 -171
- framework_m_studio/static/assets/index-CnPUX2YK.css +0 -1
- framework_m_studio/static/favicon.ico +0 -0
- framework_m_studio/static/index.html +0 -40
- framework_m_studio-0.2.3.dist-info/RECORD +0 -28
- framework_m_studio-0.2.3.dist-info/entry_points.txt +0 -4
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Scaffolding CLI Commands - Create new apps and doctypes.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands for scaffolding new Framework M
|
|
4
|
+
components using templates.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
m new:doctype Invoice # Create new doctype
|
|
8
|
+
m new:doctype Invoice --app myapp # Explicit app target
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import cyclopts
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Template Content (Embedded for simplicity)
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
DOCTYPE_TEMPLATE = '''\
|
|
25
|
+
"""{{ class_name }} DocType.
|
|
26
|
+
|
|
27
|
+
Auto-generated by `m new:doctype {{ name }}`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import ClassVar
|
|
33
|
+
|
|
34
|
+
from framework_m import DocType, Field
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class {{ class_name }}(DocType):
|
|
38
|
+
"""{{ class_name }} DocType definition.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
name: Unique identifier for this {{ name }}.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__doctype_name__: ClassVar[str] = "{{ class_name }}"
|
|
45
|
+
|
|
46
|
+
name: str = Field(description="Unique identifier")
|
|
47
|
+
|
|
48
|
+
class Meta:
|
|
49
|
+
"""DocType metadata."""
|
|
50
|
+
|
|
51
|
+
naming_rule: ClassVar[str] = "autoincrement"
|
|
52
|
+
is_submittable: ClassVar[bool] = False
|
|
53
|
+
api_resource: ClassVar[bool] = True # Expose via REST API
|
|
54
|
+
show_in_desk: ClassVar[bool] = True # Show in Desk UI
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
CONTROLLER_TEMPLATE = '''\
|
|
58
|
+
"""{{ class_name }} Controller.
|
|
59
|
+
|
|
60
|
+
Auto-generated by `m new:doctype {{ name }}`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
from framework_m import Controller
|
|
66
|
+
from .doctype import {{ class_name }}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class {{ class_name }}Controller(Controller[{{ class_name }}]):
|
|
70
|
+
"""Controller for {{ class_name }} DocType.
|
|
71
|
+
|
|
72
|
+
Implement custom business logic here.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
doctype = {{ class_name }}
|
|
76
|
+
|
|
77
|
+
async def before_save(self, doc: {{ class_name }}) -> None:
|
|
78
|
+
"""Called before saving a document."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
async def after_save(self, doc: {{ class_name }}) -> None:
|
|
82
|
+
"""Called after saving a document."""
|
|
83
|
+
pass
|
|
84
|
+
'''
|
|
85
|
+
|
|
86
|
+
TEST_TEMPLATE = '''\
|
|
87
|
+
"""Tests for {{ class_name }} DocType.
|
|
88
|
+
|
|
89
|
+
Auto-generated by `m new:doctype {{ name }}`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from __future__ import annotations
|
|
93
|
+
|
|
94
|
+
import pytest
|
|
95
|
+
|
|
96
|
+
from .doctype import {{ class_name }}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Test{{ class_name }}:
|
|
100
|
+
"""Tests for {{ class_name }}."""
|
|
101
|
+
|
|
102
|
+
def test_create_{{ snake_name }}(self) -> None:
|
|
103
|
+
"""{{ class_name }} should be creatable."""
|
|
104
|
+
doc = {{ class_name }}(name="test-001")
|
|
105
|
+
assert doc.name == "test-001"
|
|
106
|
+
|
|
107
|
+
def test_{{ snake_name }}_doctype_name(self) -> None:
|
|
108
|
+
"""{{ class_name }} should have correct doctype name."""
|
|
109
|
+
assert {{ class_name }}.__doctype_name__ == "{{ class_name }}"
|
|
110
|
+
'''
|
|
111
|
+
|
|
112
|
+
INIT_TEMPLATE = '''\
|
|
113
|
+
"""{{ class_name }} DocType package.
|
|
114
|
+
|
|
115
|
+
Auto-generated by `m new:doctype {{ name }}`.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
from .controller import {{ class_name }}Controller
|
|
119
|
+
from .doctype import {{ class_name }}
|
|
120
|
+
|
|
121
|
+
__all__ = ["{{ class_name }}", "{{ class_name }}Controller"]
|
|
122
|
+
'''
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Name Utilities
|
|
127
|
+
# =============================================================================
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def to_pascal_case(name: str) -> str:
|
|
131
|
+
"""Convert name to PascalCase.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
>>> to_pascal_case("sales_order")
|
|
135
|
+
'SalesOrder'
|
|
136
|
+
>>> to_pascal_case("user")
|
|
137
|
+
'User'
|
|
138
|
+
>>> to_pascal_case("ItemSupplier")
|
|
139
|
+
'ItemSupplier'
|
|
140
|
+
>>> to_pascal_case("Sales Order")
|
|
141
|
+
'SalesOrder'
|
|
142
|
+
>>> to_pascal_case("my-app")
|
|
143
|
+
'MyApp'
|
|
144
|
+
"""
|
|
145
|
+
# Normalize separators: replace spaces and hyphens with underscores
|
|
146
|
+
normalized = name.replace(" ", "_").replace("-", "_")
|
|
147
|
+
|
|
148
|
+
# Handle snake_case - capitalize first letter of each part, preserve rest
|
|
149
|
+
if "_" in normalized:
|
|
150
|
+
return "".join(
|
|
151
|
+
(word[0].upper() + word[1:]) if word else ""
|
|
152
|
+
for word in normalized.split("_")
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Already PascalCase or single word - just ensure first letter is uppercase
|
|
156
|
+
return name[0].upper() + name[1:] if name else name
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def to_snake_case(name: str) -> str:
|
|
160
|
+
"""Convert name to snake_case.
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> to_snake_case("SalesOrder")
|
|
164
|
+
'sales_order'
|
|
165
|
+
>>> to_snake_case("user")
|
|
166
|
+
'user'
|
|
167
|
+
>>> to_snake_case("my-app")
|
|
168
|
+
'my_app'
|
|
169
|
+
>>> to_snake_case("Sales Order")
|
|
170
|
+
'sales_order'
|
|
171
|
+
"""
|
|
172
|
+
# First, replace spaces and hyphens with underscores
|
|
173
|
+
name = name.replace(" ", "_").replace("-", "_")
|
|
174
|
+
# Insert underscore before uppercase letters
|
|
175
|
+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
176
|
+
result = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
177
|
+
# Collapse multiple underscores
|
|
178
|
+
return re.sub("_+", "_", result)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalize_app_name(name: str) -> str:
|
|
182
|
+
"""Normalize app name to Python package format.
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
>>> normalize_app_name("my-app")
|
|
186
|
+
'my_app'
|
|
187
|
+
"""
|
|
188
|
+
return name.replace("-", "_")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# =============================================================================
|
|
192
|
+
# App Detection Strategy
|
|
193
|
+
# =============================================================================
|
|
194
|
+
|
|
195
|
+
# Entry point group for discovering installed apps
|
|
196
|
+
APPS_ENTRY_POINT_GROUP = "framework_m.apps"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def detect_app_from_cwd() -> str | None:
|
|
200
|
+
"""Detect app name from pyproject.toml in cwd or parents.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
App name if found, None otherwise.
|
|
204
|
+
"""
|
|
205
|
+
result = find_app_root()
|
|
206
|
+
return result[0] if result else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def find_app_root() -> tuple[str, Path] | None:
|
|
210
|
+
"""Find app name and root directory from pyproject.toml.
|
|
211
|
+
|
|
212
|
+
Searches current directory and parents for pyproject.toml.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (app_name, root_path) if found, None otherwise.
|
|
216
|
+
"""
|
|
217
|
+
for path in [Path.cwd(), *Path.cwd().parents]:
|
|
218
|
+
pyproject = path / "pyproject.toml"
|
|
219
|
+
if pyproject.exists():
|
|
220
|
+
app_name = parse_project_name(pyproject)
|
|
221
|
+
if app_name:
|
|
222
|
+
return (app_name, path)
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse_project_name(pyproject_path: Path) -> str | None:
|
|
227
|
+
"""Parse project name from pyproject.toml.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
pyproject_path: Path to pyproject.toml
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Normalized project name or None
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
content = pyproject_path.read_text()
|
|
237
|
+
# Simple regex to find name = "..."
|
|
238
|
+
match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', content)
|
|
239
|
+
if match:
|
|
240
|
+
return normalize_app_name(match.group(1))
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def is_interactive() -> bool:
|
|
247
|
+
"""Check if running in interactive terminal."""
|
|
248
|
+
return sys.stdin.isatty()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def list_installed_apps() -> list[str]:
|
|
252
|
+
"""List installed apps from entry points.
|
|
253
|
+
|
|
254
|
+
Scans the framework_m.apps entry point group for registered apps.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
List of app names
|
|
258
|
+
"""
|
|
259
|
+
from importlib.metadata import entry_points
|
|
260
|
+
|
|
261
|
+
eps = entry_points(group=APPS_ENTRY_POINT_GROUP)
|
|
262
|
+
return [ep.name for ep in eps]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def prompt_select(prompt: str, options: list[str]) -> str | None:
|
|
266
|
+
"""Prompt user to select from a list of options.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
prompt: The prompt message
|
|
270
|
+
options: List of options to choose from
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Selected option or None if cancelled
|
|
274
|
+
"""
|
|
275
|
+
if not options:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
print(f"\n{prompt}")
|
|
279
|
+
for i, opt in enumerate(options, 1):
|
|
280
|
+
print(f" {i}. {opt}")
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
choice = input("\nEnter number (or 'q' to quit): ").strip()
|
|
284
|
+
if choice.lower() == "q":
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
idx = int(choice) - 1
|
|
288
|
+
if 0 <= idx < len(options):
|
|
289
|
+
return options[idx]
|
|
290
|
+
except (ValueError, EOFError, KeyboardInterrupt):
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def detect_app(explicit_app: str | None = None) -> str | None:
|
|
297
|
+
"""Detect app using the detection strategy.
|
|
298
|
+
|
|
299
|
+
Strategy:
|
|
300
|
+
1. Explicit --app parameter wins (unless it's '.' which means current dir)
|
|
301
|
+
2. Auto-detect from pyproject.toml in CWD or parents
|
|
302
|
+
3. None if not found (caller handles interactive/error)
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
explicit_app: Explicitly provided app name, or '.' for current dir
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
App name or None
|
|
309
|
+
"""
|
|
310
|
+
# Treat '.' as "use current directory's app"
|
|
311
|
+
if explicit_app and explicit_app != ".":
|
|
312
|
+
return normalize_app_name(explicit_app)
|
|
313
|
+
|
|
314
|
+
return detect_app_from_cwd()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def require_app(explicit_app: str | None = None) -> str:
|
|
318
|
+
"""Require an app, prompting if necessary.
|
|
319
|
+
|
|
320
|
+
Full implementation of App Detection Strategy:
|
|
321
|
+
1. Explicit --app parameter wins
|
|
322
|
+
2. Auto-detect from pyproject.toml
|
|
323
|
+
3. Interactive prompt if TTY
|
|
324
|
+
4. Fail with error if non-interactive
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
explicit_app: Explicitly provided app name
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
App name (guaranteed)
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
SystemExit: If app cannot be determined
|
|
334
|
+
"""
|
|
335
|
+
# 1. Explicit --app wins
|
|
336
|
+
if explicit_app:
|
|
337
|
+
return normalize_app_name(explicit_app)
|
|
338
|
+
|
|
339
|
+
# 2. Auto-detect from CWD
|
|
340
|
+
detected = detect_app_from_cwd()
|
|
341
|
+
if detected:
|
|
342
|
+
return detected
|
|
343
|
+
|
|
344
|
+
# 3. Interactive prompt if TTY
|
|
345
|
+
if is_interactive():
|
|
346
|
+
apps = list_installed_apps()
|
|
347
|
+
if apps:
|
|
348
|
+
print("Could not detect app from current directory.")
|
|
349
|
+
selected = prompt_select("Which app?", apps)
|
|
350
|
+
if selected:
|
|
351
|
+
return selected
|
|
352
|
+
print("\nNo app selected.")
|
|
353
|
+
else:
|
|
354
|
+
print("Could not detect app from current directory.")
|
|
355
|
+
print("No apps registered in entry points.")
|
|
356
|
+
|
|
357
|
+
# 4. Fail with clear error
|
|
358
|
+
print(
|
|
359
|
+
"Error: Cannot detect app. Use --app <name>.",
|
|
360
|
+
file=sys.stderr,
|
|
361
|
+
)
|
|
362
|
+
raise SystemExit(1)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# =============================================================================
|
|
366
|
+
# Scaffold Functions
|
|
367
|
+
# =============================================================================
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def render_template(template: str, context: dict[str, str]) -> str:
|
|
371
|
+
"""Simple template rendering (no Jinja2 dependency).
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
template: Template string with {{ var }} placeholders
|
|
375
|
+
context: Dict of variable names to values
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Rendered template
|
|
379
|
+
"""
|
|
380
|
+
result = template
|
|
381
|
+
for key, value in context.items():
|
|
382
|
+
result = result.replace("{{ " + key + " }}", value)
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def scaffold_doctype(
|
|
387
|
+
doctype_name: str,
|
|
388
|
+
output_dir: Path,
|
|
389
|
+
test_output_dir: Path | None = None,
|
|
390
|
+
) -> dict[str, Path]:
|
|
391
|
+
"""Create doctype scaffold files.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
doctype_name: Name of the doctype (e.g., "Invoice" or "sales_order")
|
|
395
|
+
output_dir: Directory to create files in
|
|
396
|
+
test_output_dir: Optional separate directory for test files (keeps them out of src/)
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Dict mapping file type to created path
|
|
400
|
+
"""
|
|
401
|
+
class_name = to_pascal_case(doctype_name)
|
|
402
|
+
snake_name = to_snake_case(doctype_name)
|
|
403
|
+
|
|
404
|
+
context = {
|
|
405
|
+
"name": doctype_name,
|
|
406
|
+
"class_name": class_name,
|
|
407
|
+
"snake_name": snake_name,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
# Ensure output directory exists
|
|
411
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
|
|
413
|
+
created_files: dict[str, Path] = {}
|
|
414
|
+
|
|
415
|
+
# Create doctype files (go in src/)
|
|
416
|
+
doctype_files = [
|
|
417
|
+
("__init__.py", INIT_TEMPLATE),
|
|
418
|
+
("doctype.py", DOCTYPE_TEMPLATE),
|
|
419
|
+
("controller.py", CONTROLLER_TEMPLATE),
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
for filename, template in doctype_files:
|
|
423
|
+
filepath = output_dir / filename
|
|
424
|
+
content = render_template(template, context)
|
|
425
|
+
filepath.write_text(content)
|
|
426
|
+
created_files[filename] = filepath
|
|
427
|
+
|
|
428
|
+
# Create test file (goes in tests/ directory if specified)
|
|
429
|
+
test_dir = test_output_dir if test_output_dir else output_dir
|
|
430
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
test_filename = f"test_{snake_name}.py"
|
|
432
|
+
test_filepath = test_dir / test_filename
|
|
433
|
+
test_content = render_template(TEST_TEMPLATE, context)
|
|
434
|
+
test_filepath.write_text(test_content)
|
|
435
|
+
created_files[test_filename] = test_filepath
|
|
436
|
+
|
|
437
|
+
return created_files
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# =============================================================================
|
|
441
|
+
# CLI Commands
|
|
442
|
+
# =============================================================================
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def new_doctype_command(
|
|
446
|
+
name: Annotated[str, cyclopts.Parameter(help="Name of the new DocType")],
|
|
447
|
+
app: Annotated[
|
|
448
|
+
str | None,
|
|
449
|
+
cyclopts.Parameter(name="--app", help="Target app name"),
|
|
450
|
+
] = None,
|
|
451
|
+
output: Annotated[
|
|
452
|
+
Path | None,
|
|
453
|
+
cyclopts.Parameter(
|
|
454
|
+
name="--output", help="Output directory (default: doctypes/<name>)"
|
|
455
|
+
),
|
|
456
|
+
] = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Create a new DocType with scaffolded files.
|
|
459
|
+
|
|
460
|
+
Creates a new DocType package with:
|
|
461
|
+
- doctype.py (schema definition)
|
|
462
|
+
- controller.py (business logic)
|
|
463
|
+
- test_*.py (pytest tests)
|
|
464
|
+
|
|
465
|
+
Examples:
|
|
466
|
+
m new:doctype Invoice
|
|
467
|
+
m new:doctype Invoice --app myapp
|
|
468
|
+
m new:doctype SalesOrder --output ./my_doctypes/sales_order
|
|
469
|
+
"""
|
|
470
|
+
# Detect app if not provided
|
|
471
|
+
detected_app = detect_app(app)
|
|
472
|
+
|
|
473
|
+
# Find app root directory (where pyproject.toml is)
|
|
474
|
+
app_info = find_app_root()
|
|
475
|
+
app_root = app_info[1] if app_info else Path.cwd()
|
|
476
|
+
|
|
477
|
+
if detected_app is None and output is None:
|
|
478
|
+
if is_interactive():
|
|
479
|
+
print("Warning: Could not detect app. Creating in current directory.")
|
|
480
|
+
else:
|
|
481
|
+
print(
|
|
482
|
+
"Error: Cannot detect app. Use --app <name> or --output <path>.",
|
|
483
|
+
file=sys.stderr,
|
|
484
|
+
)
|
|
485
|
+
raise SystemExit(1)
|
|
486
|
+
|
|
487
|
+
# Determine output directory
|
|
488
|
+
snake_name = to_snake_case(name)
|
|
489
|
+
test_output_dir: Path | None = None
|
|
490
|
+
|
|
491
|
+
if output:
|
|
492
|
+
output_dir = output
|
|
493
|
+
# If explicit output, put tests in parallel tests/ structure
|
|
494
|
+
if "src" in output.parts:
|
|
495
|
+
parts = list(output.parts)
|
|
496
|
+
src_idx = parts.index("src")
|
|
497
|
+
parts[src_idx] = "tests"
|
|
498
|
+
test_output_dir = Path(*parts)
|
|
499
|
+
elif detected_app:
|
|
500
|
+
# Use app_root (not CWD) to construct paths
|
|
501
|
+
# This ensures correct paths even when running from subdirectory
|
|
502
|
+
app_namespace_doctypes = app_root / "src" / detected_app / "doctypes"
|
|
503
|
+
src_doctypes = app_root / "src" / "doctypes"
|
|
504
|
+
|
|
505
|
+
if app_namespace_doctypes.exists():
|
|
506
|
+
# Namespaced structure: src/<app>/doctypes/<doctype>/
|
|
507
|
+
output_dir = app_namespace_doctypes / snake_name
|
|
508
|
+
test_output_dir = app_root / "tests" / "doctypes" / snake_name
|
|
509
|
+
elif src_doctypes.exists():
|
|
510
|
+
# Legacy flat structure: src/doctypes/<doctype>/
|
|
511
|
+
output_dir = src_doctypes / snake_name
|
|
512
|
+
test_output_dir = app_root / "tests" / "doctypes" / snake_name
|
|
513
|
+
else:
|
|
514
|
+
# Default: create in namespaced location
|
|
515
|
+
output_dir = app_namespace_doctypes / snake_name
|
|
516
|
+
test_output_dir = app_root / "tests" / "doctypes" / snake_name
|
|
517
|
+
else:
|
|
518
|
+
output_dir = Path.cwd() / snake_name
|
|
519
|
+
test_output_dir = Path.cwd() / "tests" / snake_name
|
|
520
|
+
|
|
521
|
+
# Check if directory already exists
|
|
522
|
+
if output_dir.exists():
|
|
523
|
+
print(f"Error: Directory already exists: {output_dir}", file=sys.stderr)
|
|
524
|
+
raise SystemExit(1)
|
|
525
|
+
|
|
526
|
+
# Scaffold
|
|
527
|
+
print(f"Creating DocType: {to_pascal_case(name)}")
|
|
528
|
+
print(f" Location: {output_dir}")
|
|
529
|
+
if test_output_dir:
|
|
530
|
+
print(f" Tests: {test_output_dir}")
|
|
531
|
+
|
|
532
|
+
created = scaffold_doctype(name, output_dir, test_output_dir)
|
|
533
|
+
|
|
534
|
+
print()
|
|
535
|
+
print("✓ Created files:")
|
|
536
|
+
for _filename, filepath in created.items():
|
|
537
|
+
print(f" - {filepath}")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# =============================================================================
|
|
541
|
+
# New App Command
|
|
542
|
+
# =============================================================================
|
|
543
|
+
|
|
544
|
+
# App template files (embedded for simplicity)
|
|
545
|
+
APP_PYPROJECT_TEMPLATE = """\
|
|
546
|
+
[project]
|
|
547
|
+
name = "{{ app_name }}"
|
|
548
|
+
version = "0.1.0"
|
|
549
|
+
description = "A Framework M application"
|
|
550
|
+
readme = "README.md"
|
|
551
|
+
requires-python = ">=3.12"
|
|
552
|
+
dependencies = [
|
|
553
|
+
"framework-m>=0.1.0",
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
[project.entry-points."framework_m.apps"]
|
|
557
|
+
{{ app_name }} = "{{ app_name }}:app"
|
|
558
|
+
|
|
559
|
+
[build-system]
|
|
560
|
+
requires = ["hatchling"]
|
|
561
|
+
build-backend = "hatchling.build"
|
|
562
|
+
|
|
563
|
+
[tool.hatch.build.targets.wheel]
|
|
564
|
+
packages = ["src/{{ app_name }}"]
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
APP_README_TEMPLATE = """\
|
|
568
|
+
# {{ class_name }}
|
|
569
|
+
|
|
570
|
+
A Framework M application.
|
|
571
|
+
|
|
572
|
+
## Getting Started
|
|
573
|
+
|
|
574
|
+
```bash
|
|
575
|
+
cd {{ app_name }}
|
|
576
|
+
uv sync
|
|
577
|
+
m dev
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## Structure
|
|
581
|
+
|
|
582
|
+
```
|
|
583
|
+
{{ app_name }}/
|
|
584
|
+
├── src/
|
|
585
|
+
│ └── {{ app_name }}/ # App namespace
|
|
586
|
+
│ ├── __init__.py # App config
|
|
587
|
+
│ ├── doctypes/ # Your DocTypes go here
|
|
588
|
+
│ ├── services/ # Business logic (optional)
|
|
589
|
+
│ └── utils/ # Utilities (optional)
|
|
590
|
+
├── tests/ # Test files
|
|
591
|
+
├── pyproject.toml
|
|
592
|
+
└── README.md
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## Adding DocTypes
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
m new:doctype Invoice
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
This creates:
|
|
602
|
+
- `src/{{ app_name }}/doctypes/invoice/` - DocType files
|
|
603
|
+
- `tests/doctypes/invoice/` - Test files
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def scaffold_app(
|
|
608
|
+
app_name: str,
|
|
609
|
+
output_dir: Path,
|
|
610
|
+
) -> dict[str, Path]:
|
|
611
|
+
"""Create app scaffold files.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
app_name: Name of the app (e.g., "myapp")
|
|
615
|
+
output_dir: Directory to create app in
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Dict mapping file type to created path
|
|
619
|
+
"""
|
|
620
|
+
class_name = to_pascal_case(app_name)
|
|
621
|
+
snake_name = to_snake_case(app_name)
|
|
622
|
+
|
|
623
|
+
context = {
|
|
624
|
+
"app_name": snake_name,
|
|
625
|
+
"class_name": class_name,
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
# Create directory structure
|
|
629
|
+
app_dir = output_dir / snake_name
|
|
630
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
|
|
632
|
+
# Create namespaced package: src/<app_name>/
|
|
633
|
+
namespace_dir = app_dir / "src" / snake_name
|
|
634
|
+
namespace_dir.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
|
|
636
|
+
# Create doctypes subdirectory
|
|
637
|
+
doctypes_dir = namespace_dir / "doctypes"
|
|
638
|
+
doctypes_dir.mkdir(parents=True, exist_ok=True)
|
|
639
|
+
|
|
640
|
+
# Create tests directory
|
|
641
|
+
tests_dir = app_dir / "tests"
|
|
642
|
+
tests_dir.mkdir(parents=True, exist_ok=True)
|
|
643
|
+
|
|
644
|
+
created_files: dict[str, Path] = {}
|
|
645
|
+
|
|
646
|
+
# Create <app_name>/__init__.py with app configuration
|
|
647
|
+
app_init_content = f'''"""{class_name} App.
|
|
648
|
+
|
|
649
|
+
A Framework M application with namespaced package structure.
|
|
650
|
+
"""
|
|
651
|
+
|
|
652
|
+
# App configuration - discovered via entry points
|
|
653
|
+
app = {{
|
|
654
|
+
"name": "{snake_name}",
|
|
655
|
+
"title": "{class_name}",
|
|
656
|
+
"description": "A Framework M application",
|
|
657
|
+
}}
|
|
658
|
+
'''
|
|
659
|
+
app_init_path = namespace_dir / "__init__.py"
|
|
660
|
+
app_init_path.write_text(app_init_content)
|
|
661
|
+
created_files[f"src/{snake_name}/__init__.py"] = app_init_path
|
|
662
|
+
|
|
663
|
+
# Create <app_name>/doctypes/__init__.py
|
|
664
|
+
doctypes_init_content = f'''"""{class_name} DocTypes Package."""
|
|
665
|
+
|
|
666
|
+
# DocTypes are auto-discovered from this package
|
|
667
|
+
'''
|
|
668
|
+
doctypes_init_path = doctypes_dir / "__init__.py"
|
|
669
|
+
doctypes_init_path.write_text(doctypes_init_content)
|
|
670
|
+
created_files[f"src/{snake_name}/doctypes/__init__.py"] = doctypes_init_path
|
|
671
|
+
|
|
672
|
+
# Create tests/__init__.py
|
|
673
|
+
tests_init_path = tests_dir / "__init__.py"
|
|
674
|
+
tests_init_path.write_text('"""Tests for the app."""\n')
|
|
675
|
+
created_files["tests/__init__.py"] = tests_init_path
|
|
676
|
+
|
|
677
|
+
# Create pyproject.toml
|
|
678
|
+
pyproject_content = render_template(APP_PYPROJECT_TEMPLATE, context)
|
|
679
|
+
pyproject_path = app_dir / "pyproject.toml"
|
|
680
|
+
pyproject_path.write_text(pyproject_content)
|
|
681
|
+
created_files["pyproject.toml"] = pyproject_path
|
|
682
|
+
|
|
683
|
+
# Create README.md
|
|
684
|
+
readme_content = render_template(APP_README_TEMPLATE, context)
|
|
685
|
+
readme_path = app_dir / "README.md"
|
|
686
|
+
readme_path.write_text(readme_content)
|
|
687
|
+
created_files["README.md"] = readme_path
|
|
688
|
+
|
|
689
|
+
return created_files
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def new_app_command(
|
|
693
|
+
name: Annotated[str, cyclopts.Parameter(help="Name of the new app")],
|
|
694
|
+
output_dir: Annotated[
|
|
695
|
+
Path,
|
|
696
|
+
cyclopts.Parameter(
|
|
697
|
+
name="--output-dir", help="Parent directory for the new app"
|
|
698
|
+
),
|
|
699
|
+
] = Path(),
|
|
700
|
+
) -> None:
|
|
701
|
+
"""Create a new Framework M app.
|
|
702
|
+
|
|
703
|
+
Creates a minimal app structure with:
|
|
704
|
+
- pyproject.toml (with Framework M dependency)
|
|
705
|
+
- README.md
|
|
706
|
+
- src/<app>/doctypes/ (namespaced structure for DocTypes)
|
|
707
|
+
|
|
708
|
+
Examples:
|
|
709
|
+
m new:app myapp
|
|
710
|
+
m new:app myapp --output-dir ./apps
|
|
711
|
+
"""
|
|
712
|
+
snake_name = to_snake_case(name)
|
|
713
|
+
app_path = output_dir / snake_name
|
|
714
|
+
|
|
715
|
+
# Check if directory already exists
|
|
716
|
+
if app_path.exists():
|
|
717
|
+
print(f"Error: Directory already exists: {app_path}", file=sys.stderr)
|
|
718
|
+
raise SystemExit(1)
|
|
719
|
+
|
|
720
|
+
print(f"Creating new app: {name}")
|
|
721
|
+
print(f" Location: {app_path}")
|
|
722
|
+
print()
|
|
723
|
+
|
|
724
|
+
created = scaffold_app(name, output_dir)
|
|
725
|
+
|
|
726
|
+
print("✓ Created files:")
|
|
727
|
+
for filepath in created.values():
|
|
728
|
+
print(f" - {filepath}")
|
|
729
|
+
|
|
730
|
+
print()
|
|
731
|
+
print("✓ Created directory structure:")
|
|
732
|
+
print(f" - {app_path}/src/{snake_name}/doctypes/")
|
|
733
|
+
print(f" - {app_path}/tests/")
|
|
734
|
+
print()
|
|
735
|
+
print("Next steps:")
|
|
736
|
+
print(f" cd {snake_name}")
|
|
737
|
+
print(" m dev")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
__all__ = [
|
|
741
|
+
"APPS_ENTRY_POINT_GROUP",
|
|
742
|
+
"detect_app",
|
|
743
|
+
"detect_app_from_cwd",
|
|
744
|
+
"is_interactive",
|
|
745
|
+
"list_installed_apps",
|
|
746
|
+
"new_app_command",
|
|
747
|
+
"new_doctype_command",
|
|
748
|
+
"prompt_select",
|
|
749
|
+
"require_app",
|
|
750
|
+
"scaffold_app",
|
|
751
|
+
"scaffold_doctype",
|
|
752
|
+
"to_pascal_case",
|
|
753
|
+
"to_snake_case",
|
|
754
|
+
]
|