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.
@@ -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
+ ]