framework-m-studio 0.2.2__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.
@@ -11,6 +11,11 @@ runtime lightweight. Install as a dev dependency.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- __version__ = "0.1.0"
14
+ import importlib.metadata
15
+
16
+ try:
17
+ __version__ = importlib.metadata.version("framework-m-studio")
18
+ except importlib.metadata.PackageNotFoundError:
19
+ __version__ = "0.0.0"
15
20
 
16
21
  __all__ = ["__version__"]
framework_m_studio/app.py CHANGED
@@ -43,19 +43,64 @@ async def api_root() -> dict[str, Any]:
43
43
  }
44
44
 
45
45
 
46
- def _get_spa_response(path: str) -> Response[Any]:
47
- """Helper to serve SPA files."""
46
+ def _get_spa_response(path: str) -> Response[bytes | dict[str, str]]:
47
+ """Helper to serve SPA files.
48
+
49
+ Returns HTML content for missing builds, or serves actual static files
50
+ when the SPA is built. This is the standard approach for SPAs - serving
51
+ HTML directly without requiring a template engine configuration.
52
+ """
48
53
  if not STATIC_DIR.exists():
49
54
  # Development mode: no built assets yet
55
+ # Return HTML placeholder directly (no template engine needed)
56
+ html_content = """<!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
61
+ <title>Framework M Studio - Build Required</title>
62
+ <style>
63
+ body {
64
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
65
+ max-width: 600px;
66
+ margin: 100px auto;
67
+ padding: 20px;
68
+ line-height: 1.6;
69
+ }
70
+ h1 { color: #333; }
71
+ code {
72
+ background: #f4f4f4;
73
+ padding: 2px 6px;
74
+ border-radius: 3px;
75
+ font-family: monospace;
76
+ }
77
+ .api-links { margin-top: 30px; }
78
+ .api-links a {
79
+ display: block;
80
+ margin: 10px 0;
81
+ color: #0066cc;
82
+ text-decoration: none;
83
+ }
84
+ .api-links a:hover { text-decoration: underline; }
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <h1>Studio UI Not Built</h1>
89
+ <p>The Studio UI assets have not been built yet.</p>
90
+ <p>To build the UI, run:</p>
91
+ <pre><code>cd apps/studio/studio_ui && pnpm install && pnpm build</code></pre>
92
+
93
+ <div class="api-links">
94
+ <h3>Available API Endpoints:</h3>
95
+ <a href="/studio/api/health">Health Check</a>
96
+ <a href="/studio/api/doctypes">DocTypes API</a>
97
+ <a href="/studio/api/field-types">Field Types API</a>
98
+ </div>
99
+ </body>
100
+ </html>"""
50
101
  return Response(
51
- content={
52
- "message": "Studio UI not built yet",
53
- "hint": "Run: cd apps/studio/studio_ui && pnpm build",
54
- "api_health": "/studio/api/health",
55
- "api_doctypes": "/studio/api/doctypes",
56
- "api_field_types": "/studio/api/field-types",
57
- },
58
- media_type="application/json",
102
+ content=html_content.encode("utf-8"),
103
+ media_type="text/html; charset=utf-8",
59
104
  )
60
105
 
61
106
  # Check for actual file
@@ -125,7 +170,7 @@ async def list_field_types() -> dict[str, Any]:
125
170
  Uses FieldRegistry for dynamic discovery.
126
171
  """
127
172
  try:
128
- from framework_m.adapters.db.field_registry import FieldRegistry
173
+ from framework_m_standard.adapters.db.field_registry import FieldRegistry
129
174
 
130
175
  types_list = []
131
176
  for type_info in FieldRegistry.get_instance().get_all_types():
@@ -0,0 +1,421 @@
1
+ """Checklist Parser - Extract features from Phase checklists.
2
+
3
+ This module parses markdown checklist files to extract:
4
+ - Completed features ([x])
5
+ - Feature metadata (phase, category, description)
6
+ - Implementation status
7
+
8
+ Used for generating:
9
+ - Features documentation page
10
+ - Release notes from version diffs
11
+ - Progress tracking
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ @dataclass
23
+ class ChecklistItem:
24
+ """A single checklist item from a phase."""
25
+
26
+ phase: str
27
+ section: str
28
+ description: str
29
+ completed: bool
30
+ subsection: str | None = None
31
+ line_number: int = 0
32
+ indent_level: int = 0
33
+
34
+
35
+ @dataclass
36
+ class PhaseInfo:
37
+ """Information about a phase from its checklist."""
38
+
39
+ phase_id: str
40
+ title: str
41
+ objective: str
42
+ items: list[ChecklistItem] = field(default_factory=list)
43
+ completion_percentage: float = 0.0
44
+
45
+
46
+ def parse_checklist_file(filepath: Path) -> PhaseInfo:
47
+ """Parse a phase checklist markdown file.
48
+
49
+ Args:
50
+ filepath: Path to the checklist markdown file.
51
+
52
+ Returns:
53
+ PhaseInfo with extracted items and metadata.
54
+
55
+ Example:
56
+ >>> phase = parse_checklist_file(Path("checklists/phase-01-core-kernel.md"))
57
+ >>> print(f"{phase.title}: {phase.completion_percentage:.0f}% complete")
58
+ Phase 01: Core Kernel & Interfaces: 95% complete
59
+ """
60
+ content = filepath.read_text()
61
+ lines = content.split("\n")
62
+
63
+ # Extract phase metadata from filename and first heading
64
+ phase_id = _extract_phase_id(filepath.name)
65
+ title = ""
66
+ objective = ""
67
+ current_section = "Unknown"
68
+ current_subsection: str | None = None
69
+ items: list[ChecklistItem] = []
70
+
71
+ for line_num, line in enumerate(lines, 1):
72
+ # Extract title from first H1
73
+ if line.startswith("# ") and not title:
74
+ title = line[2:].strip()
75
+ continue
76
+
77
+ # Extract objective
78
+ if line.startswith("**Objective**:"):
79
+ objective = line.split(":", 1)[1].strip()
80
+ continue
81
+
82
+ # Track current section (## headers)
83
+ if line.startswith("## "):
84
+ current_section = line[3:].strip()
85
+ current_subsection = None
86
+ continue
87
+
88
+ # Track subsections (### headers)
89
+ if line.startswith("### "):
90
+ current_subsection = line[4:].strip()
91
+ continue
92
+
93
+ # Parse checklist items
94
+ if match := re.match(r"^(\s*)- \[([ x])\] (.+)$", line):
95
+ indent = match.group(1)
96
+ is_checked = match.group(2) == "x"
97
+ description = match.group(3).strip()
98
+
99
+ items.append(
100
+ ChecklistItem(
101
+ phase=phase_id,
102
+ section=current_section,
103
+ subsection=current_subsection,
104
+ description=description,
105
+ completed=is_checked,
106
+ line_number=line_num,
107
+ indent_level=len(indent),
108
+ )
109
+ )
110
+
111
+ # Calculate completion percentage
112
+ completed_count = sum(1 for item in items if item.completed)
113
+ total_count = len(items)
114
+ completion_percentage = (
115
+ (completed_count / total_count * 100) if total_count > 0 else 0.0
116
+ )
117
+
118
+ return PhaseInfo(
119
+ phase_id=phase_id,
120
+ title=title,
121
+ objective=objective,
122
+ items=items,
123
+ completion_percentage=completion_percentage,
124
+ )
125
+
126
+
127
+ def _extract_phase_id(filename: str) -> str:
128
+ """Extract phase ID from filename.
129
+
130
+ Args:
131
+ filename: Checklist filename (e.g., "phase-01-core-kernel.md")
132
+
133
+ Returns:
134
+ Phase ID (e.g., "01")
135
+
136
+ Example:
137
+ >>> _extract_phase_id("phase-01-core-kernel.md")
138
+ '01'
139
+ >>> _extract_phase_id("phase-11-package-split.md")
140
+ '11'
141
+ """
142
+ match = re.search(r"phase-(\d+[a-z]?)", filename)
143
+ return match.group(1) if match else "unknown"
144
+
145
+
146
+ def scan_all_checklists(checklists_dir: Path) -> list[PhaseInfo]:
147
+ """Scan all checklist files in a directory.
148
+
149
+ Args:
150
+ checklists_dir: Directory containing phase-*.md files.
151
+
152
+ Returns:
153
+ List of PhaseInfo objects, sorted by phase ID.
154
+
155
+ Example:
156
+ >>> phases = scan_all_checklists(Path("checklists"))
157
+ >>> for phase in phases:
158
+ ... print(f"{phase.phase_id}: {phase.completion_percentage:.0f}%")
159
+ """
160
+ checklist_files = sorted(checklists_dir.glob("phase-*.md"))
161
+ phases = [parse_checklist_file(f) for f in checklist_files]
162
+ return sorted(phases, key=lambda p: p.phase_id)
163
+
164
+
165
+ def group_by_category(items: list[ChecklistItem]) -> dict[str, list[ChecklistItem]]:
166
+ """Group checklist items by section.
167
+
168
+ Args:
169
+ items: List of checklist items.
170
+
171
+ Returns:
172
+ Dictionary mapping section names to items.
173
+
174
+ Example:
175
+ >>> items = phase.items
176
+ >>> grouped = group_by_category(items)
177
+ >>> for category, category_items in grouped.items():
178
+ ... print(f"{category}: {len(category_items)} items")
179
+ """
180
+ groups: dict[str, list[ChecklistItem]] = {}
181
+ for item in items:
182
+ section_key = f"{item.section}"
183
+ if item.subsection:
184
+ section_key = f"{item.section} > {item.subsection}"
185
+
186
+ if section_key not in groups:
187
+ groups[section_key] = []
188
+ groups[section_key].append(item)
189
+
190
+ return groups
191
+
192
+
193
+ def filter_completed(items: list[ChecklistItem]) -> list[ChecklistItem]:
194
+ """Filter to only completed items.
195
+
196
+ Args:
197
+ items: List of checklist items.
198
+
199
+ Returns:
200
+ List of completed items only.
201
+ """
202
+ return [item for item in items if item.completed]
203
+
204
+
205
+ def generate_features_summary(phases: list[PhaseInfo]) -> str:
206
+ """Generate a markdown summary of all features.
207
+
208
+ Args:
209
+ phases: List of phase information objects.
210
+
211
+ Returns:
212
+ Markdown-formatted feature summary.
213
+
214
+ Example:
215
+ >>> phases = scan_all_checklists(Path("checklists"))
216
+ >>> summary = generate_features_summary(phases)
217
+ >>> print(summary)
218
+ """
219
+ lines = [
220
+ "# Framework M Features",
221
+ "",
222
+ "Auto-generated feature list from phase checklists.",
223
+ "",
224
+ "## Overview",
225
+ "",
226
+ "| Phase | Title | Completion |",
227
+ "|-------|-------|------------|",
228
+ ]
229
+
230
+ # Overview table
231
+ for phase in phases:
232
+ completion_bar = _progress_bar(phase.completion_percentage)
233
+ lines.append(
234
+ f"| {phase.phase_id} | [{phase.title}](#{_slugify(phase.title)}) | {completion_bar} {phase.completion_percentage:.0f}% |"
235
+ )
236
+
237
+ lines.extend(["", "---", ""])
238
+
239
+ # Detailed sections
240
+ for phase in phases:
241
+ lines.append(f"## {phase.title}")
242
+ lines.append("")
243
+ lines.append(f"**Phase**: {phase.phase_id}")
244
+ lines.append(f"**Objective**: {phase.objective}")
245
+ lines.append(f"**Status**: {phase.completion_percentage:.0f}% Complete")
246
+ lines.append("")
247
+
248
+ # Group by category
249
+ grouped = group_by_category(phase.items)
250
+ for category, items in grouped.items():
251
+ completed = [i for i in items if i.completed]
252
+ pending = [i for i in items if not i.completed]
253
+
254
+ lines.append(f"### {category}")
255
+ lines.append("")
256
+ lines.append(
257
+ f"**Progress**: {len(completed)}/{len(items)} ({len(completed) / len(items) * 100:.0f}%)"
258
+ )
259
+ lines.append("")
260
+
261
+ if completed:
262
+ lines.append("**Completed:**")
263
+ lines.append("")
264
+ for item in completed:
265
+ lines.append(f"- ✅ {item.description}")
266
+ lines.append("")
267
+
268
+ if pending:
269
+ lines.append("**Pending:**")
270
+ lines.append("")
271
+ for item in pending:
272
+ lines.append(f"- ⏳ {item.description}")
273
+ lines.append("")
274
+
275
+ lines.append("---")
276
+ lines.append("")
277
+
278
+ return "\n".join(lines)
279
+
280
+
281
+ def _progress_bar(percentage: float, width: int = 10) -> str:
282
+ """Generate a text progress bar.
283
+
284
+ Args:
285
+ percentage: Completion percentage (0-100).
286
+ width: Width of the progress bar in characters.
287
+
288
+ Returns:
289
+ Unicode progress bar string.
290
+
291
+ Example:
292
+ >>> _progress_bar(75, 10)
293
+ '████████░░'
294
+ """
295
+ filled = int(percentage / 100 * width)
296
+ empty = width - filled
297
+ return "█" * filled + "░" * empty
298
+
299
+
300
+ def _slugify(text: str) -> str:
301
+ """Convert text to URL-friendly slug.
302
+
303
+ Args:
304
+ text: Text to slugify.
305
+
306
+ Returns:
307
+ Lowercase slug with hyphens.
308
+
309
+ Example:
310
+ >>> _slugify("Core Kernel & Interfaces")
311
+ 'core-kernel--interfaces'
312
+ """
313
+ slug = text.lower()
314
+ slug = re.sub(r"[^\w\s-]", "", slug)
315
+ slug = re.sub(r"[-\s]+", "-", slug)
316
+ return slug.strip("-")
317
+
318
+
319
+ def compare_versions(
320
+ old_items: list[ChecklistItem],
321
+ new_items: list[ChecklistItem],
322
+ ) -> dict[str, Any]:
323
+ """Compare two versions of checklist items to find changes.
324
+
325
+ Args:
326
+ old_items: Checklist items from old version.
327
+ new_items: Checklist items from new version.
328
+
329
+ Returns:
330
+ Dictionary with newly_completed, newly_added, and removed items.
331
+
332
+ Example:
333
+ >>> changes = compare_versions(old_phase.items, new_phase.items)
334
+ >>> print(f"Newly completed: {len(changes['newly_completed'])}")
335
+ """
336
+ # Create lookup by description for comparison
337
+ old_map = {item.description: item for item in old_items}
338
+ new_map = {item.description: item for item in new_items}
339
+
340
+ newly_completed = []
341
+ newly_added = []
342
+ removed = []
343
+
344
+ # Find newly completed items
345
+ for desc, new_item in new_map.items():
346
+ old_item = old_map.get(desc)
347
+ if old_item and not old_item.completed and new_item.completed:
348
+ newly_completed.append(new_item)
349
+ elif not old_item:
350
+ newly_added.append(new_item)
351
+
352
+ # Find removed items
353
+ for desc in old_map:
354
+ if desc not in new_map:
355
+ removed.append(old_map[desc])
356
+
357
+ return {
358
+ "newly_completed": newly_completed,
359
+ "newly_added": newly_added,
360
+ "removed": removed,
361
+ }
362
+
363
+
364
+ def generate_release_notes(changes: dict[str, Any], version: str) -> str:
365
+ """Generate release notes from checklist changes.
366
+
367
+ Args:
368
+ changes: Dictionary from compare_versions().
369
+ version: Version string (e.g., "1.0.0").
370
+
371
+ Returns:
372
+ Markdown-formatted release notes.
373
+
374
+ Example:
375
+ >>> changes = compare_versions(old_items, new_items)
376
+ >>> notes = generate_release_notes(changes, "1.0.0")
377
+ """
378
+ lines = [
379
+ f"# Release {version}",
380
+ "",
381
+ "## What's New",
382
+ "",
383
+ ]
384
+
385
+ newly_completed = changes.get("newly_completed", [])
386
+ newly_added = changes.get("newly_added", [])
387
+
388
+ if newly_completed:
389
+ # Group by phase
390
+ by_phase: dict[str, list[ChecklistItem]] = {}
391
+ for item in newly_completed:
392
+ if item.phase not in by_phase:
393
+ by_phase[item.phase] = []
394
+ by_phase[item.phase].append(item)
395
+
396
+ lines.append("### Completed Features")
397
+ lines.append("")
398
+
399
+ for phase_id in sorted(by_phase.keys()):
400
+ items = by_phase[phase_id]
401
+ lines.append(f"#### Phase {phase_id}")
402
+ lines.append("")
403
+ for item in items:
404
+ section_info = f"{item.section}"
405
+ if item.subsection:
406
+ section_info += f" > {item.subsection}"
407
+ lines.append(f"- **{section_info}**: {item.description}")
408
+ lines.append("")
409
+
410
+ if newly_added:
411
+ lines.append("### New Checklist Items")
412
+ lines.append("")
413
+ for item in newly_added:
414
+ lines.append(f"- {item.description}")
415
+ lines.append("")
416
+
417
+ if not newly_completed and not newly_added:
418
+ lines.append("_No new features in this release._")
419
+ lines.append("")
420
+
421
+ return "\n".join(lines)