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,552 @@
1
+ """Studio API Routes - File System API.
2
+
3
+ This module provides the REST API for DocType file operations:
4
+ - GET /studio/api/doctypes - List all DocTypes in project
5
+ - GET /studio/api/doctype/{name} - Get DocType schema
6
+ - POST /studio/api/doctype/{name} - Create/update DocType
7
+ - DELETE /studio/api/doctype/{name} - Delete DocType
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, ClassVar
14
+
15
+ from litestar import Controller, delete, get, post
16
+ from litestar.exceptions import NotFoundException
17
+ from pydantic import BaseModel
18
+
19
+ from framework_m_studio.discovery import (
20
+ doctype_to_dict,
21
+ scan_doctypes,
22
+ )
23
+
24
+ # =============================================================================
25
+ # Request/Response Models
26
+ # =============================================================================
27
+
28
+
29
+ class ValidatorSchema(BaseModel):
30
+ """Validator constraints for a field."""
31
+
32
+ min_length: int | None = None
33
+ max_length: int | None = None
34
+ pattern: str | None = None
35
+ min_value: float | None = None
36
+ max_value: float | None = None
37
+
38
+
39
+ class FieldSchema(BaseModel):
40
+ """Field definition for DocType creation/update."""
41
+
42
+ name: str
43
+ type: str
44
+ label: str | None = None
45
+ default: str | None = None
46
+ required: bool = True
47
+ description: str | None = None
48
+ hidden: bool = False
49
+ read_only: bool = False
50
+ validators: ValidatorSchema | None = None
51
+
52
+
53
+ class DocTypeSchema(BaseModel):
54
+ """DocType schema for creation/update."""
55
+
56
+ name: str
57
+ module: str | None = None
58
+ docstring: str | None = None
59
+ fields: list[FieldSchema] = []
60
+
61
+
62
+ class DocTypeListResponse(BaseModel):
63
+ """Response for listing DocTypes."""
64
+
65
+ doctypes: list[dict[str, Any]]
66
+ count: int
67
+ project_root: str
68
+
69
+
70
+ class DocTypeResponse(BaseModel):
71
+ """Response for single DocType."""
72
+
73
+ name: str
74
+ module: str
75
+ file_path: str
76
+ docstring: str | None
77
+ fields: list[dict[str, Any]]
78
+ meta: dict[str, Any]
79
+
80
+
81
+ class CreateDocTypeRequest(BaseModel):
82
+ """Request body for creating a DocType."""
83
+
84
+ name: str
85
+ app: str | None = None
86
+ docstring: str | None = None
87
+ fields: list[FieldSchema] = []
88
+
89
+
90
+ class MessageResponse(BaseModel):
91
+ """Generic message response."""
92
+
93
+ message: str
94
+ file_path: str | None = None
95
+
96
+
97
+ # =============================================================================
98
+ # Project Root Detection
99
+ # =============================================================================
100
+
101
+
102
+ def get_project_root() -> Path:
103
+ """Get the project root directory.
104
+
105
+ Priority:
106
+ 1. If CWD has src/doctypes directory, use CWD (local app mode)
107
+ 2. If CWD has doctypes directory, use CWD
108
+ 3. Look for pyproject.toml or .git in CWD only (don't traverse up)
109
+ 4. Fall back to CWD
110
+
111
+ This ensures Studio scans only the current directory, not parent repos.
112
+ """
113
+ cwd = Path.cwd()
114
+
115
+ # Priority 1: CWD has src/doctypes (standard app structure)
116
+ if (cwd / "src" / "doctypes").exists():
117
+ return cwd
118
+
119
+ # Priority 2: CWD has doctypes directly
120
+ if (cwd / "doctypes").exists():
121
+ return cwd
122
+
123
+ # Priority 3: CWD has project markers (don't traverse up!)
124
+ if (cwd / "pyproject.toml").exists() or (cwd / ".git").exists():
125
+ return cwd
126
+
127
+ # Fall back to CWD
128
+ return cwd
129
+
130
+
131
+ # =============================================================================
132
+ # File System API Controller
133
+ # =============================================================================
134
+
135
+
136
+ class DocTypeController(Controller):
137
+ """Controller for DocType file system operations."""
138
+
139
+ path = "/studio/api"
140
+ tags: ClassVar[list[str]] = ["Studio - DocTypes"] # type: ignore[misc]
141
+
142
+ @get("/doctypes")
143
+ async def list_doctypes(self) -> DocTypeListResponse:
144
+ """List all DocTypes in the project.
145
+
146
+ Scans the project for Python files containing DocType class definitions.
147
+ """
148
+ project_root = get_project_root()
149
+
150
+ # Scan for DocTypes
151
+ doctypes = scan_doctypes(project_root)
152
+
153
+ return DocTypeListResponse(
154
+ doctypes=[doctype_to_dict(dt) for dt in doctypes],
155
+ count=len(doctypes),
156
+ project_root=str(project_root),
157
+ )
158
+
159
+ @get("/doctype/{name:str}")
160
+ async def get_doctype(self, name: str) -> DocTypeResponse:
161
+ """Get a specific DocType by name.
162
+
163
+ Args:
164
+ name: DocType class name (e.g., "Todo", "User")
165
+ """
166
+ project_root = get_project_root()
167
+
168
+ # Scan for DocTypes and find the matching one
169
+ doctypes = scan_doctypes(project_root)
170
+
171
+ for doctype in doctypes:
172
+ if doctype.name == name:
173
+ # Helper to clean validators
174
+ def clean_validators(
175
+ validators: dict[str, Any],
176
+ ) -> dict[str, Any] | None:
177
+ if not validators:
178
+ return None
179
+ # Remove None/empty values
180
+ cleaned = {k: v for k, v in validators.items() if v is not None}
181
+ return cleaned if cleaned else None
182
+
183
+ return DocTypeResponse(
184
+ name=doctype.name,
185
+ module=doctype.module,
186
+ file_path=doctype.file_path,
187
+ docstring=doctype.docstring,
188
+ fields=[
189
+ {
190
+ "name": f.name,
191
+ "type": f.type,
192
+ "default": f.default,
193
+ "required": f.required,
194
+ "label": f.label,
195
+ "description": f.description,
196
+ "validators": clean_validators(f.validators),
197
+ }
198
+ for f in doctype.fields
199
+ ],
200
+ meta=doctype.meta,
201
+ )
202
+
203
+ raise NotFoundException(f"DocType '{name}' not found")
204
+
205
+ @post("/doctype/{name:str}")
206
+ async def create_or_update_doctype(
207
+ self,
208
+ name: str,
209
+ data: CreateDocTypeRequest,
210
+ ) -> MessageResponse:
211
+ """Create or update a DocType.
212
+
213
+ Creates a folder structure with:
214
+ - doctype.py: DocType class definition
215
+ - controller.py: Controller with lifecycle hooks
216
+ - __init__.py: Package exports
217
+ - test_<name>.py: Test file
218
+
219
+ Args:
220
+ name: Original DocType class name (from URL path)
221
+ data: DocType schema including fields (data.name is the new name)
222
+ """
223
+ project_root = get_project_root()
224
+
225
+ # Sanitize the DocType name for valid Python identifier
226
+ # e.g., "Sales Invoice" -> class "SalesInvoice", folder "sales_invoice"
227
+ sanitized_class_name = _sanitize_doctype_name(data.name)
228
+ sanitized_folder_name = _to_snake_case(sanitized_class_name)
229
+
230
+ # Update the data name to the sanitized version
231
+ data.name = sanitized_class_name
232
+
233
+ # Check if this is a rename operation
234
+ is_rename = name != sanitized_class_name
235
+ old_folder_path = None
236
+
237
+ if is_rename:
238
+ # Find the old folder and mark for deletion
239
+ doctypes = scan_doctypes(project_root)
240
+ for doctype in doctypes:
241
+ if doctype.name == name:
242
+ old_file = Path(doctype.file_path)
243
+ # Check if it's in a folder structure or flat file
244
+ if old_file.parent.name == _to_snake_case(name):
245
+ old_folder_path = old_file.parent
246
+ else:
247
+ # Old flat file structure - delete both files
248
+ old_folder_path = None
249
+ if old_file.exists():
250
+ old_file.unlink()
251
+ old_controller = (
252
+ old_file.parent / f"{old_file.stem}_controller.py"
253
+ )
254
+ if old_controller.exists():
255
+ old_controller.unlink()
256
+ break
257
+
258
+ # Determine target directory
259
+ if data.app:
260
+ # Use specified app directory
261
+ target_dir = project_root / data.app / "src"
262
+ if not target_dir.exists():
263
+ target_dir = project_root / data.app
264
+ else:
265
+ # Default to src/
266
+ target_dir = project_root / "src"
267
+ if not target_dir.exists():
268
+ target_dir = project_root
269
+
270
+ # Create folder structure: src/doctypes/<name>/
271
+ doctype_folder = target_dir / "doctypes" / sanitized_folder_name
272
+ doctype_folder.mkdir(parents=True, exist_ok=True)
273
+
274
+ # Generate and write doctype.py
275
+ doctype_code = _generate_doctype_code(data)
276
+ (doctype_folder / "doctype.py").write_text(doctype_code, encoding="utf-8")
277
+
278
+ # Generate and write controller.py
279
+ controller_code = _generate_controller_code(sanitized_class_name)
280
+ (doctype_folder / "controller.py").write_text(controller_code, encoding="utf-8")
281
+
282
+ # Generate and write __init__.py
283
+ init_code = _generate_init_code(sanitized_class_name)
284
+ (doctype_folder / "__init__.py").write_text(init_code, encoding="utf-8")
285
+
286
+ # Generate and write test file
287
+ test_code = _generate_test_code(sanitized_class_name, sanitized_folder_name)
288
+ (doctype_folder / f"test_{sanitized_folder_name}.py").write_text(
289
+ test_code, encoding="utf-8"
290
+ )
291
+
292
+ # Delete old folder if this was a rename
293
+ if is_rename and old_folder_path and old_folder_path.exists():
294
+ import shutil
295
+
296
+ shutil.rmtree(old_folder_path)
297
+
298
+ action = "renamed" if is_rename else "created"
299
+ return MessageResponse(
300
+ message=f"DocType '{sanitized_class_name}' {action} successfully",
301
+ file_path=str(doctype_folder),
302
+ )
303
+
304
+ @delete("/doctype/{name:str}", status_code=200)
305
+ async def delete_doctype(self, name: str) -> MessageResponse:
306
+ """Delete a DocType directory and all its files.
307
+
308
+ Args:
309
+ name: DocType class name to delete
310
+ """
311
+ import shutil
312
+
313
+ project_root = get_project_root()
314
+
315
+ # Find the DocType file
316
+ doctypes = scan_doctypes(project_root)
317
+
318
+ for doctype in doctypes:
319
+ if doctype.name == name:
320
+ file_path = Path(doctype.file_path)
321
+ doctype_dir = file_path.parent
322
+
323
+ # Delete the entire DocType directory
324
+ if doctype_dir.exists() and doctype_dir.is_dir():
325
+ shutil.rmtree(doctype_dir)
326
+ return MessageResponse(
327
+ message=f"DocType '{name}' deleted successfully",
328
+ file_path=str(doctype_dir),
329
+ )
330
+ elif file_path.exists():
331
+ # Fallback: delete just the file if no directory
332
+ file_path.unlink()
333
+ return MessageResponse(
334
+ message=f"DocType '{name}' deleted successfully",
335
+ file_path=str(file_path),
336
+ )
337
+
338
+ raise NotFoundException(f"DocType '{name}' not found")
339
+
340
+
341
+ # =============================================================================
342
+ # Code Generation Helpers
343
+ # =============================================================================
344
+
345
+
346
+ def _sanitize_doctype_name(name: str) -> str:
347
+ """Sanitize DocType name to be valid Python identifier.
348
+
349
+ - Replace spaces with underscores
350
+ - Remove all special characters except underscores
351
+ - Convert to PascalCase
352
+
353
+ Args:
354
+ name: Raw DocType name (e.g., "Sales Invoice", "sales-order")
355
+
356
+ Returns:
357
+ Sanitized PascalCase name (e.g., "SalesInvoice", "SalesOrder")
358
+ """
359
+ import re
360
+
361
+ # Replace hyphens and spaces with underscores first
362
+ s = re.sub(r"[-\s]+", "_", name)
363
+
364
+ # Remove any non-alphanumeric characters except underscores
365
+ s = re.sub(r"[^a-zA-Z0-9_]", "", s)
366
+
367
+ # Split by underscores and capitalize first letter of each word
368
+ # Use word[0].upper() + word[1:] instead of capitalize() to preserve case
369
+ parts = s.split("_")
370
+ pascal = "".join((word[0].upper() + word[1:]) if word else "" for word in parts)
371
+
372
+ return pascal or "DocType"
373
+
374
+
375
+ def _to_snake_case(name: str) -> str:
376
+ """Convert PascalCase to snake_case."""
377
+ import re
378
+
379
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
380
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
381
+
382
+
383
+ def _generate_doctype_code(schema: CreateDocTypeRequest) -> str:
384
+ """Generate Python code for a DocType.
385
+
386
+ Now uses the Jinja2 template system for consistent code generation.
387
+ """
388
+ from framework_m_studio.codegen.generator import generate_doctype_source
389
+
390
+ # Convert CreateDocTypeRequest to dict format expected by generator
391
+ schema_dict = {
392
+ "name": schema.name,
393
+ "docstring": schema.docstring,
394
+ "fields": [
395
+ {
396
+ "name": f.name,
397
+ "type": f.type,
398
+ "required": f.required,
399
+ "label": f.label,
400
+ "default": f.default,
401
+ "description": f.description,
402
+ "validators": (
403
+ {
404
+ "min_length": f.validators.min_length,
405
+ "max_length": f.validators.max_length,
406
+ "pattern": f.validators.pattern,
407
+ "min_value": f.validators.min_value,
408
+ "max_value": f.validators.max_value,
409
+ }
410
+ if f.validators
411
+ else {}
412
+ ),
413
+ }
414
+ for f in schema.fields
415
+ ],
416
+ }
417
+
418
+ return str(generate_doctype_source(schema_dict))
419
+
420
+
421
+ def _generate_controller_code(name: str) -> str:
422
+ """Generate Python code for a Controller.
423
+
424
+ Args:
425
+ name: The DocType class name (e.g., "Invoice")
426
+
427
+ Returns:
428
+ Generated controller Python code
429
+ """
430
+ lines = [
431
+ f'"""Controller for {name} DocType."""',
432
+ "",
433
+ "from __future__ import annotations",
434
+ "",
435
+ "from framework_m.core.domain.base_controller import BaseController",
436
+ f"from .doctype import {name}",
437
+ "",
438
+ "",
439
+ f"class {name}Controller(BaseController[{name}]):",
440
+ f' """Controller for {name} DocType.',
441
+ "",
442
+ " Implement custom business logic here.",
443
+ "",
444
+ " Lifecycle Hooks:",
445
+ " - validate: Called before save to validate data",
446
+ " - before_save: Called before persisting to database",
447
+ " - after_save: Called after successful save",
448
+ " - before_delete: Called before deleting a document",
449
+ ' """',
450
+ "",
451
+ f" doctype = {name}",
452
+ "",
453
+ f" async def validate(self, doc: {name}) -> None:",
454
+ ' """Validate document before saving.',
455
+ "",
456
+ " Raise ValueError for validation errors.",
457
+ "",
458
+ " Example:",
459
+ " if not doc.name:",
460
+ ' raise ValueError("Name is required")',
461
+ ' """',
462
+ " pass",
463
+ "",
464
+ f" async def before_save(self, doc: {name}) -> None:",
465
+ ' """Called before saving a document.',
466
+ "",
467
+ " Use for setting computed fields or timestamps.",
468
+ ' """',
469
+ " pass",
470
+ "",
471
+ f" async def after_save(self, doc: {name}) -> None:",
472
+ ' """Called after saving a document.',
473
+ "",
474
+ " Use for side effects like sending notifications.",
475
+ ' """',
476
+ " pass",
477
+ "",
478
+ f" async def before_delete(self, doc: {name}) -> None:",
479
+ ' """Called before deleting a document.',
480
+ "",
481
+ " Use for cleanup or validation before delete.",
482
+ ' """',
483
+ " pass",
484
+ "",
485
+ ]
486
+
487
+ return "\n".join(lines)
488
+
489
+
490
+ def _generate_init_code(name: str) -> str:
491
+ """Generate __init__.py code for a DocType package.
492
+
493
+ Args:
494
+ name: The DocType class name (e.g., "Invoice")
495
+
496
+ Returns:
497
+ Generated __init__.py Python code
498
+ """
499
+ return f'''"""{{ class_name }} DocType package.
500
+
501
+ Auto-generated by Framework M Studio.
502
+ """
503
+
504
+ from .controller import {name}Controller
505
+ from .doctype import {name}
506
+
507
+ __all__ = ["{name}", "{name}Controller"]
508
+ '''.replace("{{ class_name }}", name)
509
+
510
+
511
+ def _generate_test_code(name: str, snake_name: str) -> str:
512
+ """Generate test file code for a DocType.
513
+
514
+ Args:
515
+ name: The DocType class name (e.g., "Invoice")
516
+ snake_name: Snake case name (e.g., "invoice")
517
+
518
+ Returns:
519
+ Generated test Python code
520
+ """
521
+ return f'''"""Tests for {name} DocType.
522
+
523
+ Auto-generated by Framework M Studio.
524
+ """
525
+
526
+ from __future__ import annotations
527
+
528
+ import pytest
529
+
530
+ from .doctype import {name}
531
+
532
+
533
+ class Test{name}:
534
+ """Tests for {name}."""
535
+
536
+ def test_create_{snake_name}(self) -> None:
537
+ """{name} should be creatable."""
538
+ doc = {name}(name="test-001")
539
+ assert doc.name == "test-001"
540
+
541
+ def test_{snake_name}_doctype_name(self) -> None:
542
+ """{name} should have correct class name."""
543
+ assert {name}.__name__ == "{name}"
544
+ '''
545
+
546
+
547
+ __all__ = [
548
+ "CreateDocTypeRequest",
549
+ "DocTypeController",
550
+ "DocTypeListResponse",
551
+ "DocTypeResponse",
552
+ ]