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,157 @@
|
|
|
1
|
+
"""Quality assurance commands for Framework M."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import cyclopts
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_pythonpath() -> str:
|
|
14
|
+
"""Construct PYTHONPATH including current directory and src."""
|
|
15
|
+
cwd = Path.cwd()
|
|
16
|
+
paths = [str(cwd)]
|
|
17
|
+
|
|
18
|
+
src = cwd / "src"
|
|
19
|
+
if src.exists():
|
|
20
|
+
paths.append(str(src))
|
|
21
|
+
|
|
22
|
+
return ":".join(paths)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_pytest_command(
|
|
26
|
+
verbose: bool = False,
|
|
27
|
+
coverage: bool = False,
|
|
28
|
+
filter_expr: str | None = None,
|
|
29
|
+
extra_args: tuple[str, ...] = (),
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
"""Build pytest command."""
|
|
32
|
+
cmd = [sys.executable, "-m", "pytest"]
|
|
33
|
+
if verbose:
|
|
34
|
+
cmd.append("-v")
|
|
35
|
+
if coverage:
|
|
36
|
+
cmd.append("--cov")
|
|
37
|
+
if filter_expr:
|
|
38
|
+
cmd.extend(["-k", filter_expr])
|
|
39
|
+
if extra_args:
|
|
40
|
+
cmd.extend(extra_args)
|
|
41
|
+
else:
|
|
42
|
+
cmd.append(".")
|
|
43
|
+
return cmd
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_ruff_check_command(
|
|
47
|
+
fix: bool = False,
|
|
48
|
+
extra_args: tuple[str, ...] = (),
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""Build ruff check command."""
|
|
51
|
+
cmd = [sys.executable, "-m", "ruff", "check"]
|
|
52
|
+
if fix:
|
|
53
|
+
cmd.append("--fix")
|
|
54
|
+
if extra_args:
|
|
55
|
+
cmd.extend(extra_args)
|
|
56
|
+
return cmd
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_ruff_format_command(
|
|
60
|
+
check: bool = False,
|
|
61
|
+
extra_args: tuple[str, ...] = (),
|
|
62
|
+
) -> list[str]:
|
|
63
|
+
"""Build ruff format command."""
|
|
64
|
+
cmd = [sys.executable, "-m", "ruff", "format"]
|
|
65
|
+
if check:
|
|
66
|
+
cmd.append("--check")
|
|
67
|
+
if extra_args:
|
|
68
|
+
cmd.extend(extra_args)
|
|
69
|
+
return cmd
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_mypy_command(
|
|
73
|
+
strict: bool = False,
|
|
74
|
+
extra_args: tuple[str, ...] = (),
|
|
75
|
+
) -> list[str]:
|
|
76
|
+
"""Build mypy command."""
|
|
77
|
+
cmd = [sys.executable, "-m", "mypy"]
|
|
78
|
+
if strict:
|
|
79
|
+
cmd.append("--strict")
|
|
80
|
+
if extra_args:
|
|
81
|
+
cmd.extend(extra_args)
|
|
82
|
+
return cmd
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_command(cmd: list[str]) -> None:
|
|
86
|
+
"""Run a command and exit with its return code."""
|
|
87
|
+
try:
|
|
88
|
+
subprocess.run(cmd, check=True)
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
except subprocess.CalledProcessError as e:
|
|
91
|
+
sys.exit(e.returncode)
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
print("\nInterrupted.")
|
|
94
|
+
sys.exit(130)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_command(
|
|
98
|
+
path: Annotated[
|
|
99
|
+
str | None, cyclopts.Parameter(name="--path", help="Test path")
|
|
100
|
+
] = None,
|
|
101
|
+
verbose: Annotated[
|
|
102
|
+
bool, cyclopts.Parameter(name="--verbose", help="Verbose output")
|
|
103
|
+
] = False,
|
|
104
|
+
coverage: Annotated[
|
|
105
|
+
bool, cyclopts.Parameter(name="--coverage", help="Enable coverage")
|
|
106
|
+
] = False,
|
|
107
|
+
filter: Annotated[
|
|
108
|
+
str | None, cyclopts.Parameter(name="--filter", help="Filter tests")
|
|
109
|
+
] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Run tests using pytest."""
|
|
112
|
+
extra = (path,) if path else ()
|
|
113
|
+
cmd = build_pytest_command(
|
|
114
|
+
verbose=verbose, coverage=coverage, filter_expr=filter, extra_args=extra
|
|
115
|
+
)
|
|
116
|
+
_run_command(cmd)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lint_command(
|
|
120
|
+
fix: Annotated[bool, cyclopts.Parameter(name="--fix", help="Fix issues")] = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Lint code using ruff."""
|
|
123
|
+
cmd = build_ruff_check_command(fix=fix)
|
|
124
|
+
_run_command(cmd)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def format_command(
|
|
128
|
+
check: Annotated[
|
|
129
|
+
bool, cyclopts.Parameter(name="--check", help="Check only")
|
|
130
|
+
] = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Format code using ruff."""
|
|
133
|
+
cmd = build_ruff_format_command(check=check)
|
|
134
|
+
_run_command(cmd)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def typecheck_command(
|
|
138
|
+
strict: Annotated[
|
|
139
|
+
bool, cyclopts.Parameter(name="--strict", help="Strict mode")
|
|
140
|
+
] = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Type check code using mypy."""
|
|
143
|
+
cmd = build_mypy_command(strict=strict)
|
|
144
|
+
_run_command(cmd)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
"build_mypy_command",
|
|
149
|
+
"build_pytest_command",
|
|
150
|
+
"build_ruff_check_command",
|
|
151
|
+
"build_ruff_format_command",
|
|
152
|
+
"format_command",
|
|
153
|
+
"get_pythonpath",
|
|
154
|
+
"lint_command",
|
|
155
|
+
"test_command",
|
|
156
|
+
"typecheck_command",
|
|
157
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Studio CLI Command - Admin Interface Server.
|
|
2
|
+
|
|
3
|
+
This module provides the `m studio` CLI command for running
|
|
4
|
+
Framework M Studio - the admin and development interface.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
m studio # Start Studio on default port (9000)
|
|
8
|
+
m studio --port 9001 # Custom port
|
|
9
|
+
m studio --reload # Enable auto-reload
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated
|
|
19
|
+
|
|
20
|
+
import cyclopts
|
|
21
|
+
|
|
22
|
+
# Default Studio configuration
|
|
23
|
+
DEFAULT_STUDIO_PORT = 9000
|
|
24
|
+
DEFAULT_STUDIO_APP = "framework_m_studio.app:app"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_studio_app() -> str:
|
|
28
|
+
"""Get the Studio app path.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Studio app path in module:attribute format
|
|
32
|
+
"""
|
|
33
|
+
return DEFAULT_STUDIO_APP
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_pythonpath() -> str:
|
|
37
|
+
"""Get PYTHONPATH with src/ directory included.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
PYTHONPATH string with src/ prepended
|
|
41
|
+
"""
|
|
42
|
+
cwd = Path.cwd()
|
|
43
|
+
src_path = cwd / "src"
|
|
44
|
+
|
|
45
|
+
paths = []
|
|
46
|
+
|
|
47
|
+
# Add src/ if it exists
|
|
48
|
+
if src_path.exists():
|
|
49
|
+
paths.append(str(src_path))
|
|
50
|
+
|
|
51
|
+
# Add current directory
|
|
52
|
+
paths.append(str(cwd))
|
|
53
|
+
|
|
54
|
+
# Preserve existing PYTHONPATH
|
|
55
|
+
existing = os.environ.get("PYTHONPATH", "")
|
|
56
|
+
if existing:
|
|
57
|
+
paths.append(existing)
|
|
58
|
+
|
|
59
|
+
return os.pathsep.join(paths)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_studio_command(
|
|
63
|
+
host: str = "127.0.0.1",
|
|
64
|
+
port: int = DEFAULT_STUDIO_PORT,
|
|
65
|
+
reload: bool = False,
|
|
66
|
+
log_level: str | None = None,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
"""Build the uvicorn command line for Studio.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
host: Host to bind to
|
|
72
|
+
port: Port to bind to
|
|
73
|
+
reload: Enable auto-reload
|
|
74
|
+
log_level: Log level (debug, info, warning, error)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Complete command as list of strings
|
|
78
|
+
"""
|
|
79
|
+
app_path = get_studio_app()
|
|
80
|
+
cmd = [sys.executable, "-m", "uvicorn", app_path]
|
|
81
|
+
|
|
82
|
+
cmd.extend(["--host", host])
|
|
83
|
+
cmd.extend(["--port", str(port)])
|
|
84
|
+
|
|
85
|
+
if reload:
|
|
86
|
+
cmd.append("--reload")
|
|
87
|
+
|
|
88
|
+
if log_level:
|
|
89
|
+
cmd.extend(["--log-level", log_level])
|
|
90
|
+
|
|
91
|
+
return cmd
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def studio_command(
|
|
95
|
+
host: Annotated[
|
|
96
|
+
str,
|
|
97
|
+
cyclopts.Parameter(name="--host", help="Host to bind to"),
|
|
98
|
+
] = "127.0.0.1",
|
|
99
|
+
port: Annotated[
|
|
100
|
+
int,
|
|
101
|
+
cyclopts.Parameter(name="--port", help="Port to bind to"),
|
|
102
|
+
] = DEFAULT_STUDIO_PORT,
|
|
103
|
+
reload: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
cyclopts.Parameter(name="--reload", help="Enable auto-reload for development"),
|
|
106
|
+
] = False,
|
|
107
|
+
log_level: Annotated[
|
|
108
|
+
str | None,
|
|
109
|
+
cyclopts.Parameter(
|
|
110
|
+
name="--log-level", help="Log level (debug, info, warning, error)"
|
|
111
|
+
),
|
|
112
|
+
] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Start Framework M Studio - the admin interface.
|
|
115
|
+
|
|
116
|
+
Studio provides a web-based admin UI for:
|
|
117
|
+
- Browsing and editing DocTypes
|
|
118
|
+
- Monitoring jobs and events
|
|
119
|
+
- Running queries and reports
|
|
120
|
+
|
|
121
|
+
Examples:
|
|
122
|
+
m studio # Default port 9000
|
|
123
|
+
m studio --port 9001 # Custom port
|
|
124
|
+
m studio --reload # Dev mode with auto-reload
|
|
125
|
+
"""
|
|
126
|
+
# Set up environment
|
|
127
|
+
env = os.environ.copy()
|
|
128
|
+
env["PYTHONPATH"] = get_pythonpath()
|
|
129
|
+
|
|
130
|
+
# Build command
|
|
131
|
+
cmd = build_studio_command(
|
|
132
|
+
host=host,
|
|
133
|
+
port=port,
|
|
134
|
+
reload=reload,
|
|
135
|
+
log_level=log_level,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
print("Starting Framework M Studio")
|
|
139
|
+
print("=" * 40)
|
|
140
|
+
print(f" URL: http://{host}:{port}")
|
|
141
|
+
print(f" Reload: {reload}")
|
|
142
|
+
print()
|
|
143
|
+
|
|
144
|
+
# Execute uvicorn
|
|
145
|
+
try:
|
|
146
|
+
subprocess.run(cmd, env=env, check=True)
|
|
147
|
+
except subprocess.CalledProcessError as e:
|
|
148
|
+
raise SystemExit(e.returncode) from e
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
print("\nStudio stopped.")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
"DEFAULT_STUDIO_APP",
|
|
155
|
+
"DEFAULT_STUDIO_PORT",
|
|
156
|
+
"build_studio_command",
|
|
157
|
+
"get_studio_app",
|
|
158
|
+
"studio_command",
|
|
159
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Utility CLI commands for Framework M Studio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import cyclopts
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_ipython_installed() -> bool:
|
|
14
|
+
"""Check if IPython is installed."""
|
|
15
|
+
try:
|
|
16
|
+
import IPython # type: ignore # noqa: F401
|
|
17
|
+
|
|
18
|
+
return True
|
|
19
|
+
except ImportError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def console_command() -> None:
|
|
24
|
+
"""Start an interactive console."""
|
|
25
|
+
if check_ipython_installed():
|
|
26
|
+
import IPython
|
|
27
|
+
|
|
28
|
+
IPython.embed(header="Framework M Console")
|
|
29
|
+
else:
|
|
30
|
+
import code
|
|
31
|
+
|
|
32
|
+
code.interact(banner="Framework M Console (IPython not installed)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def routes_command(
|
|
36
|
+
app_path: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
cyclopts.Parameter(name="--app", help="App path to inspect"),
|
|
39
|
+
] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List API routes."""
|
|
42
|
+
console.print("Routes: /api/v1 (Mock)")
|
|
43
|
+
if app_path:
|
|
44
|
+
console.print(f"Inspecting app: {app_path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"console_command",
|
|
49
|
+
"routes_command",
|
|
50
|
+
]
|
|
@@ -44,9 +44,13 @@ def _get_jinja_env() -> Environment:
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def _to_snake_case(name: str) -> str:
|
|
47
|
-
"""Convert PascalCase to snake_case."""
|
|
47
|
+
"""Convert PascalCase or 'Spaced Words' to snake_case."""
|
|
48
|
+
# First, replace spaces and hyphens with underscores
|
|
49
|
+
name = name.replace(" ", "_").replace("-", "_")
|
|
48
50
|
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
49
|
-
|
|
51
|
+
result = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
52
|
+
# Collapse multiple underscores
|
|
53
|
+
return re.sub("_+", "_", result)
|
|
50
54
|
|
|
51
55
|
|
|
52
56
|
def _get_test_value(field_type: str) -> str:
|
|
@@ -41,6 +41,8 @@ class FieldSchema:
|
|
|
41
41
|
required: bool = True
|
|
42
42
|
description: str | None = None
|
|
43
43
|
label: str | None = None
|
|
44
|
+
link_doctype: str | None = None # For Link fields - which DocType to link to
|
|
45
|
+
table_doctype: str | None = None # For Table fields - which DocType for child table
|
|
44
46
|
validators: dict[str, Any] = field(default_factory=dict)
|
|
45
47
|
|
|
46
48
|
|
|
@@ -54,6 +56,7 @@ class ConfigSchema:
|
|
|
54
56
|
is_submittable: bool = False
|
|
55
57
|
is_tree: bool = False
|
|
56
58
|
track_changes: bool = True
|
|
59
|
+
permissions: dict[str, list[str]] | None = None
|
|
57
60
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
58
61
|
|
|
59
62
|
|
|
@@ -199,8 +202,58 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
199
202
|
validators: dict[str, Any] = {}
|
|
200
203
|
description: str | None = None
|
|
201
204
|
label: str | None = None
|
|
205
|
+
link_doctype: str | None = None
|
|
206
|
+
table_doctype: str | None = None
|
|
207
|
+
|
|
208
|
+
# Helper to check Annotated type recursively (handles Union/BinaryOp)
|
|
209
|
+
def extract_annotated_metadata(
|
|
210
|
+
type_node: cst.BaseExpression,
|
|
211
|
+
) -> tuple[str | None, str | None]:
|
|
212
|
+
"""Extract link_doctype/table_doctype from Annotated, even in unions."""
|
|
213
|
+
nonlocal field_type
|
|
214
|
+
|
|
215
|
+
# Handle BinaryOp (e.g., Type | None)
|
|
216
|
+
if isinstance(type_node, cst.BinaryOperation):
|
|
217
|
+
# Check left side (usually the main type)
|
|
218
|
+
left_link, left_table = extract_annotated_metadata(type_node.left)
|
|
219
|
+
if left_link or left_table:
|
|
220
|
+
return left_link, left_table
|
|
221
|
+
# Check right side
|
|
222
|
+
return extract_annotated_metadata(type_node.right)
|
|
223
|
+
|
|
224
|
+
# Handle Subscript (Annotated, Optional, etc.)
|
|
225
|
+
if isinstance(type_node, cst.Subscript):
|
|
226
|
+
subscript_value = _node_to_source(type_node.value)
|
|
227
|
+
|
|
228
|
+
if subscript_value == "Annotated":
|
|
229
|
+
slice_elements = type_node.slice
|
|
230
|
+
if isinstance(slice_elements, tuple) and len(slice_elements) >= 1:
|
|
231
|
+
# Extract base type
|
|
232
|
+
first_elem = slice_elements[0]
|
|
233
|
+
if isinstance(first_elem.slice, cst.Index):
|
|
234
|
+
field_type = _node_to_source(first_elem.slice.value)
|
|
235
|
+
else:
|
|
236
|
+
field_type = _node_to_source(first_elem.slice)
|
|
237
|
+
|
|
238
|
+
# Look for Link() in metadata
|
|
239
|
+
for elem in slice_elements[1:]:
|
|
240
|
+
if isinstance(elem.slice, cst.Index):
|
|
241
|
+
value = elem.slice.value
|
|
242
|
+
else:
|
|
243
|
+
value = elem.slice
|
|
244
|
+
|
|
245
|
+
if isinstance(value, cst.Call):
|
|
246
|
+
func_name = _node_to_source(value.func)
|
|
247
|
+
if func_name == "Link" and value.args:
|
|
248
|
+
arg_value = _node_to_source(value.args[0].value)
|
|
249
|
+
return arg_value.strip("\"'"), None
|
|
250
|
+
|
|
251
|
+
return None, None
|
|
252
|
+
|
|
253
|
+
# Extract Link/Table metadata from type annotation
|
|
254
|
+
link_doctype, table_doctype = extract_annotated_metadata(field_type_raw)
|
|
202
255
|
|
|
203
|
-
#
|
|
256
|
+
# Also check for Annotated at top level (non-union case)
|
|
204
257
|
if isinstance(field_type_raw, cst.Subscript):
|
|
205
258
|
subscript_value = _node_to_source(field_type_raw.value)
|
|
206
259
|
if subscript_value == "Annotated":
|
|
@@ -214,7 +267,7 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
214
267
|
else:
|
|
215
268
|
field_type = _node_to_source(first_elem.slice)
|
|
216
269
|
|
|
217
|
-
# Look for Field() in remaining elements
|
|
270
|
+
# Look for Link() or Field() in remaining elements
|
|
218
271
|
for elem in slice_elements[1:]:
|
|
219
272
|
if isinstance(elem.slice, cst.Index):
|
|
220
273
|
value = elem.slice.value
|
|
@@ -223,7 +276,12 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
223
276
|
|
|
224
277
|
if isinstance(value, cst.Call):
|
|
225
278
|
func_name = _node_to_source(value.func)
|
|
226
|
-
if func_name == "
|
|
279
|
+
if func_name == "Link":
|
|
280
|
+
# Extract linked DocType: Link("Supplier")
|
|
281
|
+
if value.args:
|
|
282
|
+
arg_value = _node_to_source(value.args[0].value)
|
|
283
|
+
link_doctype = arg_value.strip("\"'")
|
|
284
|
+
elif func_name == "Field":
|
|
227
285
|
default_value, validators, description, label = (
|
|
228
286
|
_parse_field_call(value)
|
|
229
287
|
)
|
|
@@ -232,7 +290,6 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
232
290
|
default_value = None
|
|
233
291
|
elif default_value is not None:
|
|
234
292
|
required = False
|
|
235
|
-
break
|
|
236
293
|
|
|
237
294
|
# Check for Optional type
|
|
238
295
|
if "Optional" in field_type or "None" in field_type:
|
|
@@ -258,6 +315,12 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
258
315
|
elif node.value:
|
|
259
316
|
default_value = _node_to_source(node.value)
|
|
260
317
|
|
|
318
|
+
# Override type for special field types
|
|
319
|
+
if link_doctype:
|
|
320
|
+
field_type = "Link" # Frontend expects "Link" as the type
|
|
321
|
+
elif table_doctype:
|
|
322
|
+
field_type = "Table" # Frontend expects "Table" as the type
|
|
323
|
+
|
|
261
324
|
field_schema = FieldSchema(
|
|
262
325
|
name=field_name,
|
|
263
326
|
type=field_type,
|
|
@@ -265,6 +328,8 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
265
328
|
required=required,
|
|
266
329
|
description=description,
|
|
267
330
|
label=label,
|
|
331
|
+
link_doctype=link_doctype,
|
|
332
|
+
table_doctype=table_doctype,
|
|
268
333
|
validators=validators,
|
|
269
334
|
)
|
|
270
335
|
self._current_fields.append(field_schema)
|
|
@@ -309,6 +374,9 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
309
374
|
self._current_config.is_tree = value.lower() == "true"
|
|
310
375
|
elif name == "track_changes":
|
|
311
376
|
self._current_config.track_changes = value.lower() != "false"
|
|
377
|
+
elif name == "permissions":
|
|
378
|
+
# Parse permissions dict from source code string
|
|
379
|
+
self._current_config.permissions = _parse_permissions_dict(value)
|
|
312
380
|
else:
|
|
313
381
|
self._current_config.extra[name] = value
|
|
314
382
|
|
|
@@ -318,6 +386,32 @@ class DocTypeParserVisitor(cst.CSTVisitor):
|
|
|
318
386
|
# =============================================================================
|
|
319
387
|
|
|
320
388
|
|
|
389
|
+
def _parse_permissions_dict(value: str) -> dict[str, list[str]] | None:
|
|
390
|
+
"""Parse permissions dict from source code string.
|
|
391
|
+
|
|
392
|
+
Converts source code like:
|
|
393
|
+
{"read": ["Employee"], "write": ["Manager"]}
|
|
394
|
+
to an actual Python dict.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
value: Source code string representing the dict
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Parsed permissions dict or None if parsing fails
|
|
401
|
+
"""
|
|
402
|
+
import ast
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
# Use ast.literal_eval to safely parse the dict
|
|
406
|
+
parsed = ast.literal_eval(value)
|
|
407
|
+
if isinstance(parsed, dict):
|
|
408
|
+
# Ensure all values are lists of strings
|
|
409
|
+
return {k: v if isinstance(v, list) else [v] for k, v in parsed.items()}
|
|
410
|
+
except (ValueError, SyntaxError):
|
|
411
|
+
pass
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
321
415
|
def _get_attribute_name(node: cst.Attribute) -> str:
|
|
322
416
|
"""Get full attribute name from Attribute node."""
|
|
323
417
|
parts: list[str] = []
|
|
@@ -517,6 +611,8 @@ def doctype_to_dict(doctype: DocTypeSchema) -> dict[str, Any]:
|
|
|
517
611
|
"required": f.required,
|
|
518
612
|
"label": f.label,
|
|
519
613
|
"description": f.description,
|
|
614
|
+
"link_doctype": f.link_doctype, # Include Link field metadata
|
|
615
|
+
"table_doctype": f.table_doctype, # Include Table field metadata
|
|
520
616
|
"validators": f.validators,
|
|
521
617
|
}
|
|
522
618
|
for f in doctype.fields
|
|
@@ -528,6 +624,7 @@ def doctype_to_dict(doctype: DocTypeSchema) -> dict[str, Any]:
|
|
|
528
624
|
"is_submittable": doctype.config.is_submittable,
|
|
529
625
|
"is_tree": doctype.config.is_tree,
|
|
530
626
|
"track_changes": doctype.config.track_changes,
|
|
627
|
+
"permissions": doctype.config.permissions,
|
|
531
628
|
**doctype.config.extra,
|
|
532
629
|
},
|
|
533
630
|
"docstring": doctype.docstring,
|
|
@@ -5,7 +5,7 @@ Generated by Framework M Studio.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import ClassVar
|
|
8
|
+
from typing import ClassVar{% if fields|selectattr('link_doctype')|list or fields|selectattr('table_doctype')|list %}, Annotated{% endif %}
|
|
9
9
|
|
|
10
10
|
{% if imports %}
|
|
11
11
|
{% for imp in imports %}
|
|
@@ -13,7 +13,10 @@ from typing import ClassVar
|
|
|
13
13
|
{% endfor %}
|
|
14
14
|
{% endif %}
|
|
15
15
|
from pydantic import Field
|
|
16
|
-
from
|
|
16
|
+
from framework_m_core.domain.base_doctype import BaseDocType
|
|
17
|
+
{% if fields|selectattr('link_doctype')|list %}
|
|
18
|
+
from framework_m_core.domain.mixins import Link
|
|
19
|
+
{% endif %}
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
class {{ name }}(BaseDocType):
|
|
@@ -24,8 +27,14 @@ class {{ name }}(BaseDocType):
|
|
|
24
27
|
{% if fields %}
|
|
25
28
|
{% for field in fields %}
|
|
26
29
|
{%- set has_field_args = field.description or field.label or field.validators %}
|
|
30
|
+
{%- set field_type = field.type %}
|
|
31
|
+
{%- if field.type == 'Link' or field.link_doctype %}
|
|
32
|
+
{%- set field_type = 'Annotated[str, Link("' + field.link_doctype + '")]' %}
|
|
33
|
+
{%- elif field.type == 'Table' or field.table_doctype %}
|
|
34
|
+
{%- set field_type = 'list[' + field.table_doctype + ']' %}
|
|
35
|
+
{%- endif %}
|
|
27
36
|
{% if has_field_args %}
|
|
28
|
-
{{ field.name }}: {{
|
|
37
|
+
{{ field.name }}: {{ field_type }}{{ ' | None' if not field.required and 'None' not in field_type else '' }} = Field(
|
|
29
38
|
{%- if field.required and (not field.default or field.default == 'None') %}...{% elif field.default and field.default != 'None' %}{{ field.default }}{% else %}None{% endif %}
|
|
30
39
|
{%- if field.description %}, description="{{ field.description }}"{% endif %}
|
|
31
40
|
{%- if field.label %}, label="{{ field.label }}"{% endif %}
|
|
@@ -33,16 +42,16 @@ class {{ name }}(BaseDocType):
|
|
|
33
42
|
{%- if field.validators.min_length %}, min_length={{ field.validators.min_length }}{% endif %}
|
|
34
43
|
{%- if field.validators.max_length %}, max_length={{ field.validators.max_length }}{% endif %}
|
|
35
44
|
{%- if field.validators.pattern %}, pattern=r"{{ field.validators.pattern }}"{% endif %}
|
|
36
|
-
{%- if field.validators.min_value is
|
|
37
|
-
{%- if field.validators.max_value is
|
|
38
|
-
{%- endif
|
|
39
|
-
|
|
45
|
+
{%- if field.validators.get('min_value') is not none %}, ge={{ field.validators.min_value }}{% endif %}
|
|
46
|
+
{%- if field.validators.get('max_value') is not none %}, le={{ field.validators.max_value }}{% endif %}
|
|
47
|
+
{%- endif -%}
|
|
48
|
+
)
|
|
40
49
|
{% elif field.required and not field.default %}
|
|
41
|
-
{{ field.name }}: {{
|
|
50
|
+
{{ field.name }}: {{ field_type }}
|
|
42
51
|
{% elif field.default %}
|
|
43
|
-
{{ field.name }}: {{
|
|
52
|
+
{{ field.name }}: {{ field_type }} = {{ field.default }}
|
|
44
53
|
{% else %}
|
|
45
|
-
{{ field.name }}: {{
|
|
54
|
+
{{ field.name }}: {{ field_type }}{{ ' | None' if 'None' not in field_type else '' }} = None
|
|
46
55
|
{% endif %}
|
|
47
56
|
{% endfor %}
|
|
48
57
|
{% else %}
|
|
@@ -39,9 +39,13 @@ def _get_jinja_env() -> Environment:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def _to_snake_case(name: str) -> str:
|
|
42
|
-
"""Convert PascalCase to snake_case."""
|
|
42
|
+
"""Convert PascalCase or 'Spaced Words' to snake_case."""
|
|
43
|
+
# First, replace spaces and hyphens with underscores
|
|
44
|
+
name = name.replace(" ", "_").replace("-", "_")
|
|
43
45
|
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
44
|
-
|
|
46
|
+
result = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
47
|
+
# Collapse multiple underscores
|
|
48
|
+
return re.sub("_+", "_", result)
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
def _get_test_value(field_type: str, field_name: str = "") -> str:
|