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.
- framework_m_studio/__init__.py +16 -0
- framework_m_studio/app.py +283 -0
- framework_m_studio/cli.py +247 -0
- framework_m_studio/codegen/__init__.py +34 -0
- framework_m_studio/codegen/generator.py +291 -0
- framework_m_studio/codegen/parser.py +545 -0
- framework_m_studio/codegen/templates/doctype.py.jinja2 +69 -0
- framework_m_studio/codegen/templates/test_doctype.py.jinja2 +58 -0
- framework_m_studio/codegen/test_generator.py +368 -0
- framework_m_studio/codegen/transformer.py +406 -0
- framework_m_studio/discovery.py +193 -0
- framework_m_studio/docs_generator.py +318 -0
- framework_m_studio/git/__init__.py +1 -0
- framework_m_studio/git/adapter.py +309 -0
- framework_m_studio/git/github_provider.py +321 -0
- framework_m_studio/git/protocol.py +249 -0
- framework_m_studio/py.typed +0 -0
- framework_m_studio/routes.py +552 -0
- framework_m_studio/sdk_generator.py +239 -0
- framework_m_studio/workspace.py +295 -0
- framework_m_studio-0.2.2.dist-info/METADATA +65 -0
- framework_m_studio-0.2.2.dist-info/RECORD +24 -0
- framework_m_studio-0.2.2.dist-info/WHEEL +4 -0
- framework_m_studio-0.2.2.dist-info/entry_points.txt +4 -0
|
@@ -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
|
+
[](https://badge.fury.io/py/framework-m-studio)
|
|
30
|
+
[](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,,
|