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,239 @@
1
+ """Client SDK Generator for Framework M.
2
+
3
+ Generates TypeScript and Python client code from OpenAPI schemas.
4
+ Uses stdlib only - no external dependencies required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.request import Request, urlopen
13
+
14
+
15
+ def fetch_openapi_schema(openapi_url: str, *, timeout: int = 30) -> dict[str, Any]:
16
+ """Fetch OpenAPI schema from a running server.
17
+
18
+ Args:
19
+ openapi_url: URL to fetch OpenAPI schema from.
20
+ timeout: Request timeout in seconds.
21
+
22
+ Returns:
23
+ OpenAPI schema as dict.
24
+ """
25
+ request = Request(openapi_url)
26
+ request.add_header("Accept", "application/json")
27
+
28
+ with urlopen(request, timeout=timeout) as response:
29
+ data: dict[str, Any] = json.loads(response.read().decode("utf-8"))
30
+ return data
31
+
32
+
33
+ def _openapi_type_to_typescript(openapi_type: str, format_: str | None = None) -> str:
34
+ """Convert OpenAPI type to TypeScript type."""
35
+ type_map = {
36
+ "string": "string",
37
+ "integer": "number",
38
+ "number": "number",
39
+ "boolean": "boolean",
40
+ "array": "any[]",
41
+ "object": "Record<string, any>",
42
+ }
43
+ if format_ == "date" or format_ == "date-time":
44
+ return "string" # ISO date strings
45
+ return type_map.get(openapi_type, "any")
46
+
47
+
48
+ def _openapi_type_to_python(openapi_type: str, format_: str | None = None) -> str:
49
+ """Convert OpenAPI type to Python type."""
50
+ type_map = {
51
+ "string": "str",
52
+ "integer": "int",
53
+ "number": "float",
54
+ "boolean": "bool",
55
+ "array": "list",
56
+ "object": "dict",
57
+ }
58
+ if format_ == "date":
59
+ return "date"
60
+ if format_ == "date-time":
61
+ return "datetime"
62
+ return type_map.get(openapi_type, "Any")
63
+
64
+
65
+ def generate_typescript_types(schema: dict[str, Any]) -> str:
66
+ """Generate TypeScript interface definitions from OpenAPI schemas.
67
+
68
+ Args:
69
+ schema: OpenAPI schema dict.
70
+
71
+ Returns:
72
+ TypeScript code string.
73
+ """
74
+ lines = [
75
+ "// Auto-generated TypeScript types from OpenAPI schema",
76
+ "// Generated by Framework M Studio",
77
+ "",
78
+ ]
79
+
80
+ components = schema.get("components", {})
81
+ schemas = components.get("schemas", {})
82
+
83
+ for name, definition in schemas.items():
84
+ if definition.get("type") == "object":
85
+ lines.append(f"export interface {name} {{")
86
+ properties = definition.get("properties", {})
87
+ required = definition.get("required", [])
88
+
89
+ for prop_name, prop_def in properties.items():
90
+ prop_type = _openapi_type_to_typescript(
91
+ prop_def.get("type", "any"),
92
+ prop_def.get("format"),
93
+ )
94
+ optional = "" if prop_name in required else "?"
95
+ lines.append(f" {prop_name}{optional}: {prop_type};")
96
+
97
+ lines.append("}")
98
+ lines.append("")
99
+
100
+ return "\n".join(lines)
101
+
102
+
103
+ def generate_typescript_client(schema: dict[str, Any]) -> str:
104
+ """Generate TypeScript fetch client from OpenAPI schema.
105
+
106
+ Args:
107
+ schema: OpenAPI schema dict.
108
+
109
+ Returns:
110
+ TypeScript client code string.
111
+ """
112
+ lines = [
113
+ "// Auto-generated TypeScript client from OpenAPI schema",
114
+ "// Generated by Framework M Studio",
115
+ "",
116
+ "const BASE_URL = '';",
117
+ "",
118
+ "async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {",
119
+ " const response = await fetch(`${BASE_URL}${path}`, options);",
120
+ " if (!response.ok) throw new Error(`HTTP ${response.status}`);",
121
+ " return response.json();",
122
+ "}",
123
+ "",
124
+ ]
125
+
126
+ paths = schema.get("paths", {})
127
+
128
+ for path, methods in paths.items():
129
+ for method, operation in methods.items():
130
+ if isinstance(operation, dict) and "operationId" in operation:
131
+ op_id = operation["operationId"]
132
+ http_method = method.upper()
133
+
134
+ lines.append(f"export async function {op_id}(): Promise<any> {{")
135
+ lines.append(
136
+ f" return fetchAPI('{path}', {{ method: '{http_method}' }});"
137
+ )
138
+ lines.append("}")
139
+ lines.append("")
140
+
141
+ return "\n".join(lines)
142
+
143
+
144
+ def generate_python_models(schema: dict[str, Any]) -> str:
145
+ """Generate Python Pydantic models from OpenAPI schemas.
146
+
147
+ Args:
148
+ schema: OpenAPI schema dict.
149
+
150
+ Returns:
151
+ Python code string.
152
+ """
153
+ lines = [
154
+ '"""Auto-generated Pydantic models from OpenAPI schema.',
155
+ "",
156
+ "Generated by Framework M Studio",
157
+ '"""',
158
+ "",
159
+ "from __future__ import annotations",
160
+ "",
161
+ "from datetime import date, datetime",
162
+ "from typing import Any, Optional",
163
+ "",
164
+ "from pydantic import BaseModel",
165
+ "",
166
+ ]
167
+
168
+ components = schema.get("components", {})
169
+ schemas = components.get("schemas", {})
170
+
171
+ for name, definition in schemas.items():
172
+ if definition.get("type") == "object":
173
+ lines.append(f"class {name}(BaseModel):")
174
+
175
+ properties = definition.get("properties", {})
176
+ required = definition.get("required", [])
177
+
178
+ if not properties:
179
+ lines.append(" pass")
180
+ else:
181
+ for prop_name, prop_def in properties.items():
182
+ prop_type = _openapi_type_to_python(
183
+ prop_def.get("type", "Any"),
184
+ prop_def.get("format"),
185
+ )
186
+ if prop_name not in required:
187
+ prop_type = f"Optional[{prop_type}]"
188
+ lines.append(f" {prop_name}: {prop_type}")
189
+
190
+ lines.append("")
191
+
192
+ return "\n".join(lines)
193
+
194
+
195
+ def run_codegen(
196
+ lang: str = "ts",
197
+ out: str = "./generated",
198
+ openapi_url: str = "http://localhost:8000/schema/openapi.json",
199
+ ) -> None:
200
+ """Run the client SDK generator.
201
+
202
+ Args:
203
+ lang: Target language (ts or py).
204
+ out: Output directory.
205
+ openapi_url: URL to fetch OpenAPI schema from.
206
+ """
207
+ output_dir = Path(out)
208
+ output_dir.mkdir(parents=True, exist_ok=True)
209
+
210
+ print(f"🔧 Generating {lang.upper()} client from {openapi_url}")
211
+
212
+ # Fetch schema
213
+ schema = fetch_openapi_schema(openapi_url)
214
+ print(f" ✓ Fetched OpenAPI schema: {schema.get('info', {}).get('title', 'API')}")
215
+
216
+ if lang == "ts":
217
+ # Generate TypeScript
218
+ types_code = generate_typescript_types(schema)
219
+ client_code = generate_typescript_client(schema)
220
+
221
+ (output_dir / "types.ts").write_text(types_code)
222
+ (output_dir / "client.ts").write_text(client_code)
223
+
224
+ print(f" ✓ Generated: {output_dir}/types.ts")
225
+ print(f" ✓ Generated: {output_dir}/client.ts")
226
+
227
+ elif lang == "py":
228
+ # Generate Python
229
+ models_code = generate_python_models(schema)
230
+ (output_dir / "models.py").write_text(models_code)
231
+
232
+ print(f" ✓ Generated: {output_dir}/models.py")
233
+
234
+ else:
235
+ print(f" ❌ Unknown language: {lang}")
236
+ print(" Supported: ts (TypeScript), py (Python)")
237
+ return
238
+
239
+ print(" ✅ Code generation complete!")
@@ -0,0 +1,295 @@
1
+ """Workspace Manager for Studio Cloud Mode.
2
+
3
+ Manages ephemeral Git workspaces - cloning repos to temporary directories
4
+ and tracking sessions for the Studio UI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import shutil
11
+ import tempfile
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .git.protocol import GitAdapterProtocol
20
+
21
+
22
+ @dataclass
23
+ class WorkspaceSession:
24
+ """Represents an active workspace session."""
25
+
26
+ id: str
27
+ """Unique session identifier."""
28
+
29
+ repo_url: str
30
+ """Original repository URL (without embedded token)."""
31
+
32
+ workspace_path: Path
33
+ """Local path to the cloned repository."""
34
+
35
+ created_at: datetime
36
+ """When the session was created."""
37
+
38
+ last_accessed: datetime
39
+ """When the session was last accessed."""
40
+
41
+ branch: str = "main"
42
+ """Current working branch."""
43
+
44
+ auth_token: str | None = None
45
+ """Stored auth token for push operations."""
46
+
47
+
48
+ @dataclass
49
+ class WorkspaceInfo:
50
+ """Public workspace info (safe to return to client)."""
51
+
52
+ id: str
53
+ repo_url: str
54
+ branch: str
55
+ created_at: datetime
56
+ last_accessed: datetime
57
+
58
+
59
+ class WorkspaceManager:
60
+ """Manages ephemeral Git workspaces.
61
+
62
+ Handles the lifecycle of Git workspace sessions:
63
+ - Connect: Clone repo to temp directory
64
+ - Access: Get workspace path for editing
65
+ - Commit: Commit and push changes
66
+ - Disconnect: Cleanup workspace
67
+
68
+ Sessions are stored in-memory (suitable for singleton deployment).
69
+ """
70
+
71
+ DEFAULT_TTL = timedelta(hours=4)
72
+ """Default session TTL before cleanup."""
73
+
74
+ def __init__(
75
+ self,
76
+ git_adapter: GitAdapterProtocol,
77
+ base_dir: Path | None = None,
78
+ session_ttl: timedelta | None = None,
79
+ ):
80
+ """Initialize WorkspaceManager.
81
+
82
+ Args:
83
+ git_adapter: Git adapter for operations.
84
+ base_dir: Base directory for workspaces (default: system temp).
85
+ session_ttl: Session TTL before cleanup.
86
+ """
87
+ self._git = git_adapter
88
+ self._base_dir = base_dir or Path(tempfile.gettempdir())
89
+ self._session_ttl = session_ttl or self.DEFAULT_TTL
90
+ self._sessions: dict[str, WorkspaceSession] = {}
91
+ self._lock = asyncio.Lock()
92
+
93
+ async def connect(
94
+ self,
95
+ repo_url: str,
96
+ auth_token: str | None = None,
97
+ branch: str | None = None,
98
+ ) -> WorkspaceInfo:
99
+ """Clone a repository and create a workspace session.
100
+
101
+ Args:
102
+ repo_url: Repository URL to clone.
103
+ auth_token: Personal access token for authentication.
104
+ branch: Branch to checkout (default: default branch).
105
+
106
+ Returns:
107
+ WorkspaceInfo with session ID and metadata.
108
+
109
+ Raises:
110
+ GitError: If clone fails.
111
+ """
112
+ session_id = str(uuid.uuid4())
113
+ workspace_path = self._base_dir / f"workspace_{session_id}"
114
+
115
+ # Clone the repository
116
+ await self._git.clone(
117
+ repo_url,
118
+ workspace_path,
119
+ auth_token=auth_token,
120
+ branch=branch,
121
+ )
122
+
123
+ # Get actual branch name
124
+ actual_branch = await self._git.get_current_branch(workspace_path)
125
+
126
+ now = datetime.now(UTC)
127
+ session = WorkspaceSession(
128
+ id=session_id,
129
+ repo_url=repo_url,
130
+ workspace_path=workspace_path,
131
+ created_at=now,
132
+ last_accessed=now,
133
+ branch=actual_branch,
134
+ auth_token=auth_token,
135
+ )
136
+
137
+ async with self._lock:
138
+ self._sessions[session_id] = session
139
+
140
+ return self._to_info(session)
141
+
142
+ async def get_workspace_path(self, session_id: str) -> Path:
143
+ """Get the local path for a workspace.
144
+
145
+ Args:
146
+ session_id: Session identifier.
147
+
148
+ Returns:
149
+ Path to the workspace directory.
150
+
151
+ Raises:
152
+ KeyError: If session not found.
153
+ """
154
+ session = await self._get_session(session_id)
155
+ return session.workspace_path
156
+
157
+ async def get_info(self, session_id: str) -> WorkspaceInfo:
158
+ """Get workspace info.
159
+
160
+ Args:
161
+ session_id: Session identifier.
162
+
163
+ Returns:
164
+ WorkspaceInfo with session metadata.
165
+
166
+ Raises:
167
+ KeyError: If session not found.
168
+ """
169
+ session = await self._get_session(session_id)
170
+ return self._to_info(session)
171
+
172
+ async def commit(
173
+ self,
174
+ session_id: str,
175
+ message: str,
176
+ *,
177
+ push: bool = True,
178
+ ) -> str:
179
+ """Commit changes in a workspace.
180
+
181
+ Args:
182
+ session_id: Session identifier.
183
+ message: Commit message.
184
+ push: If True, push after commit.
185
+
186
+ Returns:
187
+ Commit SHA.
188
+
189
+ Raises:
190
+ KeyError: If session not found.
191
+ GitError: If commit/push fails.
192
+ """
193
+ session = await self._get_session(session_id)
194
+
195
+ result = await self._git.commit(session.workspace_path, message)
196
+
197
+ if push and session.auth_token:
198
+ await self._git.push(session.workspace_path, session.branch)
199
+
200
+ return result.sha
201
+
202
+ async def pull(self, session_id: str) -> None:
203
+ """Pull latest changes for a workspace.
204
+
205
+ Args:
206
+ session_id: Session identifier.
207
+
208
+ Raises:
209
+ KeyError: If session not found.
210
+ GitError: If pull fails.
211
+ """
212
+ session = await self._get_session(session_id)
213
+ await self._git.pull(session.workspace_path)
214
+
215
+ async def create_branch(
216
+ self,
217
+ session_id: str,
218
+ branch_name: str,
219
+ ) -> None:
220
+ """Create and checkout a new branch.
221
+
222
+ Args:
223
+ session_id: Session identifier.
224
+ branch_name: Name for the new branch.
225
+
226
+ Raises:
227
+ KeyError: If session not found.
228
+ GitError: If branch creation fails.
229
+ """
230
+ session = await self._get_session(session_id)
231
+ await self._git.create_branch(session.workspace_path, branch_name)
232
+ session.branch = branch_name
233
+
234
+ async def disconnect(self, session_id: str) -> None:
235
+ """Cleanup a workspace session.
236
+
237
+ Args:
238
+ session_id: Session identifier.
239
+
240
+ Raises:
241
+ KeyError: If session not found.
242
+ """
243
+ async with self._lock:
244
+ session = self._sessions.pop(session_id, None)
245
+
246
+ if session and session.workspace_path.exists():
247
+ shutil.rmtree(session.workspace_path, ignore_errors=True)
248
+
249
+ async def cleanup_expired(self) -> int:
250
+ """Remove expired sessions.
251
+
252
+ Returns:
253
+ Number of sessions cleaned up.
254
+ """
255
+ now = datetime.now(UTC)
256
+ expired: list[str] = []
257
+
258
+ async with self._lock:
259
+ for session_id, session in self._sessions.items():
260
+ if now - session.last_accessed > self._session_ttl:
261
+ expired.append(session_id)
262
+
263
+ for session_id in expired:
264
+ await self.disconnect(session_id)
265
+
266
+ return len(expired)
267
+
268
+ async def list_sessions(self) -> list[WorkspaceInfo]:
269
+ """List all active sessions.
270
+
271
+ Returns:
272
+ List of WorkspaceInfo for all sessions.
273
+ """
274
+ async with self._lock:
275
+ return [self._to_info(s) for s in self._sessions.values()]
276
+
277
+ async def _get_session(self, session_id: str) -> WorkspaceSession:
278
+ """Get and update session last_accessed."""
279
+ async with self._lock:
280
+ if session_id not in self._sessions:
281
+ raise KeyError(f"Workspace session '{session_id}' not found")
282
+
283
+ session = self._sessions[session_id]
284
+ session.last_accessed = datetime.now(UTC)
285
+ return session
286
+
287
+ def _to_info(self, session: WorkspaceSession) -> WorkspaceInfo:
288
+ """Convert session to public info (without token)."""
289
+ return WorkspaceInfo(
290
+ id=session.id,
291
+ repo_url=session.repo_url,
292
+ branch=session.branch,
293
+ created_at=session.created_at,
294
+ last_accessed=session.last_accessed,
295
+ )
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: framework-m-studio
3
+ Version: 0.2.2
4
+ Summary: Framework M Studio - Visual DocType Builder & Developer Tools
5
+ Project-URL: Homepage, https://gitlab.com/castlecraft/framework-m
6
+ Project-URL: Documentation, https://gitlab.com/castlecraft/framework-m#readme
7
+ Project-URL: Repository, https://gitlab.com/castlecraft/framework-m
8
+ Author: Framework M Contributors
9
+ License: MIT
10
+ Keywords: codegen,developer-tools,doctype,framework,studio
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: framework-m
21
+ Requires-Dist: jinja2>=3.1.0
22
+ Requires-Dist: libcst>=1.0.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Framework M Studio
26
+
27
+ Visual DocType builder and developer tools for Framework M.
28
+
29
+ [![PyPI version](https://badge.fury.io/py/framework-m-studio.svg)](https://badge.fury.io/py/framework-m-studio)
30
+ [![GitLab Pipeline Status](https://gitlab.com/castlecraft/framework-m/badges/main/pipeline.svg)](https://gitlab.com/castlecraft/framework-m/-/pipelines)
31
+
32
+ ## Overview
33
+
34
+ `framework-m-studio` provides development-time tools that are NOT included in the production runtime:
35
+
36
+ - **Studio UI**: Visual DocType builder (React + Vite)
37
+ - **Code Generators**: LibCST-based Python code generation
38
+ - **DevTools CLI**: `m codegen`, `m docs:generate`
39
+
40
+ > **Note:** Studio is for developers to build DocTypes. The **Desk** (end-user data management UI) is a separate frontend that connects to the Framework M backend.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ # Add to your project's dev dependencies
46
+ uv add --dev framework-m-studio
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ # Start Studio UI
53
+ m studio
54
+
55
+ # Generate TypeScript client from OpenAPI
56
+ m codegen client --lang ts --out ./frontend/src/api
57
+ ```
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ cd apps/studio
63
+ uv sync
64
+ uv run pytest
65
+ ```
@@ -0,0 +1,24 @@
1
+ framework_m_studio/__init__.py,sha256=ZFi5wqq0o7i19WtMdC2ZDQ5KRBfIZiGr2ONodzdIykg,438
2
+ framework_m_studio/app.py,sha256=7olDenwNM8bjS-HdqLYYEPDTqobCCecJ7Kh5rNeJ7jo,8934
3
+ framework_m_studio/cli.py,sha256=kwq8w0Zi382MpMStg9nYLwO4bpTWa3yGsTjZ0aIDyjs,6581
4
+ framework_m_studio/discovery.py,sha256=VzFwgsP_OMjBPTAV9pP-hwMVQIe-GloWM7HBUVBI4UU,5415
5
+ framework_m_studio/docs_generator.py,sha256=T6RGd3mJNOr2kOoLfhHGTZRyPskergADnNot2GYj_yc,9140
6
+ framework_m_studio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ framework_m_studio/routes.py,sha256=s5qClvnU4ggb7S4ej32Nq10-AYZc8iA3fGg_mF4mQFM,17504
8
+ framework_m_studio/sdk_generator.py,sha256=L48vURRZauURDmB_F_yYs01kauvnt2SvWkSv3EgzrD8,7283
9
+ framework_m_studio/workspace.py,sha256=VSJfX3WWXLGKBZ-lmQmw3IBtrLUjtI0W8vxJoZa1CNY,8234
10
+ framework_m_studio/codegen/__init__.py,sha256=t7DB_zR1DEovL9TYXbsDyQuMyMBTG6YLrVt5wK1OeLI,814
11
+ framework_m_studio/codegen/generator.py,sha256=QIOMAS3HyBF_bNzMVEPRBDLYhRQSweUayy0YAK1-ae0,8901
12
+ framework_m_studio/codegen/parser.py,sha256=JCr9Ml_KHdQ5H3qxoR5MWw_x4Mvz1Mzymx_1gNp9Yok,18978
13
+ framework_m_studio/codegen/test_generator.py,sha256=jcP215Nmx83pFIJxeurEqfh5I9T9RZyRyrH2k2byOIo,10726
14
+ framework_m_studio/codegen/transformer.py,sha256=Ux8-A6UqRaVWKcf_y079WhTchciUunv-CocPXjC326I,12144
15
+ framework_m_studio/codegen/templates/doctype.py.jinja2,sha256=tJOxtqKgHjp-zVieemYI3UA0aGZOReil3fXCDpqtaGo,2223
16
+ framework_m_studio/codegen/templates/test_doctype.py.jinja2,sha256=Zeo1Mj67pkVIVfcJWo3VgPtaZDOoWeRKAAyHag1nW0I,1614
17
+ framework_m_studio/git/__init__.py,sha256=wsRJ77pUCS081CCN2hx6EpgU9DfY-Uel6cnQqcwBpU0,35
18
+ framework_m_studio/git/adapter.py,sha256=hdpsRPS4wbOA6gX7V9Q4bsmuykmLI2UnTUuwATKhBOU,8973
19
+ framework_m_studio/git/github_provider.py,sha256=Lw_-1Pxm19VKArH4f5e0-la0Vi1HGVCuXnrTbEyR_9s,8522
20
+ framework_m_studio/git/protocol.py,sha256=GyswO1ZlL1BzqM9pAQA7XRj8X1tw04E3jRYaUhdz-Ew,5665
21
+ framework_m_studio-0.2.2.dist-info/METADATA,sha256=8Pgpb2DoH_-0Q0wAh1ljKTHSPdS6j53BWDX6DVYiSgM,2059
22
+ framework_m_studio-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
23
+ framework_m_studio-0.2.2.dist-info/entry_points.txt,sha256=qF7BwegvM5NHTWELnlt4_jSHB-T-7ulUznpKe8uaxkU,154
24
+ framework_m_studio-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [framework_m.cli_commands]
2
+ codegen = framework_m_studio.cli:codegen_app
3
+ docs = framework_m_studio.cli:docs_app
4
+ studio = framework_m_studio.cli:studio_app