refactor(consumers): replace 'models.<moved_class>' with direct imports
Per post_module_taxonomy_de_cruft_20260627 Phase 2 (FR7 continued).
The previous migration commit (8f11340b) handled the
'from src.models import X' pattern (85 sites). This commit handles
the 'models.<moved_class>' attribute access pattern (44 sites in 20
files), which the __getattr__ shim previously supported.
The migration was performed by the one-time script
scripts/tier2/artifacts/post_module_taxonomy_de_cruft_20260627/migrate_models_attr.py
which:
1. For each 'models.<moved_class>' reference, replaces it with the
bare class name (e.g., 'models.MCPConfiguration' -> 'MCPConfiguration')
2. Adds the import 'from src.<destination> import <moved_class>' at
the top of the file (deduplicated if the import already exists)
3. Skips moved classes that the file already imports directly
The migration script inserts the import after the 'from __future__
import annotations' line if present; otherwise it adds the import
to the destination module's existing import block. Two files
required manual fixes because the script's regex didn't handle them:
- src/rag_engine.py: uses 'from src import models' (not 'from
src.models import X'); the class is accessed
via 'models.RAGConfig'. Replaced with a
direct 'from src.mcp_client import RAGConfig'
import and removed the 'from src import models'.
- tests/test_project_context_20260627.py: uses the parens-style
multi-line 'from src.models import (X, Y, Z)'.
Replaced with the parens-style direct import.
After this commit:
- 'models.MCPConfiguration', 'models.FileItem', 'models.Ticket', etc.
no longer work in src/ and tests/ (the AttributeError raises
because models.py no longer has the __getattr__ entries for
moved classes)
- All consumer files have direct imports of the moved classes
Total: 44 'models.<moved_class>' references rewritten across 20 files.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Bulk-move remaining dataclasses from src/models.py to their target modules.
|
||||
|
||||
Phase 3.5-3.9 of module_taxonomy_refactor_20260627.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(".")
|
||||
MODELS = ROOT / "src" / "models.py"
|
||||
|
||||
# Map: (class_name, target_file, optional region_header_for_target)
|
||||
MOVES = [
|
||||
("Tool", ROOT / "src" / "tool_presets.py", "#region: Tool + ToolPreset Dataclasses (moved from src/models.py Phase 3.5)"),
|
||||
("ToolPreset", ROOT / "src" / "tool_presets.py", None),
|
||||
("BiasProfile", ROOT / "src" / "tool_bias.py", "#region: BiasProfile Dataclass (moved from src/models.py Phase 3.6)"),
|
||||
("TextEditorConfig", ROOT / "src" / "external_editor.py","#region: Editor Config Dataclasses (moved from src/models.py Phase 3.7)"),
|
||||
("ExternalEditorConfig",ROOT / "src" / "external_editor.py", None),
|
||||
("MCPServerConfig", ROOT / "src" / "mcp_client.py", "#region: MCP Config Dataclasses (moved from src/models.py Phase 3.8)"),
|
||||
("MCPConfiguration", ROOT / "src" / "mcp_client.py", None),
|
||||
("VectorStoreConfig", ROOT / "src" / "mcp_client.py", None),
|
||||
("RAGConfig", ROOT / "src" / "mcp_client.py", None),
|
||||
("WorkspaceProfile", ROOT / "src" / "workspace_manager.py","#region: WorkspaceProfile Dataclass (moved from src/models.py Phase 3.9)"),
|
||||
]
|
||||
|
||||
|
||||
def find_class_block(lines: list[str], class_name: str) -> tuple[int, int]:
|
||||
"""Return (start_line, end_line) 0-indexed, [start, end) for the class block.
|
||||
|
||||
Includes the @dataclass decorator line(s) if present.
|
||||
"""
|
||||
start = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith(f"class {class_name}:"):
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
raise ValueError(f"Class {class_name} not found")
|
||||
# Look backwards for @dataclass
|
||||
decorator_start = start
|
||||
for i in range(start - 1, -1, -1):
|
||||
line = lines[i].strip()
|
||||
if line.startswith("@dataclass"):
|
||||
decorator_start = i
|
||||
break
|
||||
if line.startswith("class ") or line.startswith("#region:") or line.startswith("#endregion:"):
|
||||
break
|
||||
if line == "":
|
||||
continue
|
||||
break # non-decorator line
|
||||
# Find end: next class/def at column 0 (excluding inner methods)
|
||||
end = len(lines)
|
||||
for i in range(decorator_start + 1, len(lines)):
|
||||
line = lines[i]
|
||||
if line and not line.startswith(" ") and not line.startswith("\t"):
|
||||
stripped = line.lstrip()
|
||||
if re.match(r"^(class |def |@dataclass|#region:|#endregion:)", stripped):
|
||||
end = i
|
||||
break
|
||||
return decorator_start, end
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source = MODELS.read_text(encoding="utf-8")
|
||||
lines = source.splitlines(keepends=True)
|
||||
|
||||
# Verify each class exists first
|
||||
ranges = []
|
||||
for class_name, target_file, region_header in MOVES:
|
||||
s, e = find_class_block(lines, class_name)
|
||||
ranges.append((class_name, target_file, region_header, s, e))
|
||||
print(f"Found {class_name}: lines {s+1}-{e} ({e-s} lines)")
|
||||
|
||||
# Write each target file (append)
|
||||
by_target: dict[Path, list] = {}
|
||||
for class_name, target_file, region_header, s, e in ranges:
|
||||
by_target.setdefault(target_file, []).append((class_name, region_header, s, e))
|
||||
|
||||
for target_file, items in by_target.items():
|
||||
with target_file.open("a", encoding="utf-8") as f:
|
||||
for class_name, region_header, _, _ in items:
|
||||
s, e = find_class_block(lines, class_name)
|
||||
block = "".join(lines[s:e])
|
||||
if region_header:
|
||||
f.write(f"\n\n{region_header}\n{block}")
|
||||
else:
|
||||
f.write(f"\n\n{block}")
|
||||
print(f"Appended {len(items)} classes to {target_file}")
|
||||
|
||||
# Remove from models.py in reverse line order
|
||||
sorted_ranges = sorted(ranges, key=lambda r: r[3], reverse=True)
|
||||
new_lines = list(lines)
|
||||
for class_name, _, _, s, e in sorted_ranges:
|
||||
del new_lines[s:e]
|
||||
print(f"Removed {class_name} from models.py")
|
||||
|
||||
MODELS.write_text("".join(new_lines), encoding="utf-8")
|
||||
print("models.py updated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""Fix script: replace 'models.<moved_class>' with '<moved_class>' and add imports.
|
||||
|
||||
After the migration of 'from src.models import X' to direct imports,
|
||||
the 'models.<moved_class>' attribute access pattern still exists in
|
||||
many files. The shim previously supported this via __getattr__, but
|
||||
Phase 2.3 removed the shim. This script:
|
||||
1. Finds all 'models.<moved_class>' references
|
||||
2. For each file, adds 'from src.<destination> import <moved_class>' at
|
||||
the top (if not already present)
|
||||
3. Replaces 'models.<moved_class>' with '<moved_class>' in the body
|
||||
|
||||
NOT touched:
|
||||
- models.GenerateRequest, models.ConfirmRequest (Phase 4)
|
||||
- models.DEFAULT_TOOL_CATEGORIES (Phase 3)
|
||||
- models.PROVIDERS, models.Metadata (kept on models)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CLASS_TO_MODULE: dict[str, str] = {
|
||||
"Ticket": "mma",
|
||||
"Track": "mma",
|
||||
"WorkerContext": "mma",
|
||||
"TrackState": "mma",
|
||||
"TrackMetadata": "mma",
|
||||
"ThinkingSegment": "mma",
|
||||
"EMPTY_TRACK_STATE": "mma",
|
||||
"ProjectContext": "project",
|
||||
"ProjectMeta": "project",
|
||||
"ProjectOutput": "project",
|
||||
"ProjectFiles": "project",
|
||||
"ProjectScreenshots": "project",
|
||||
"ProjectDiscussion": "project",
|
||||
"EMPTY_PROJECT_CONTEXT": "project",
|
||||
"FileItem": "project_files",
|
||||
"Preset": "project_files",
|
||||
"ContextPreset": "project_files",
|
||||
"ContextFileEntry": "project_files",
|
||||
"NamedViewPreset": "project_files",
|
||||
"Tool": "tool_presets",
|
||||
"ToolPreset": "tool_presets",
|
||||
"BiasProfile": "tool_bias",
|
||||
"TextEditorConfig": "external_editor",
|
||||
"ExternalEditorConfig": "external_editor",
|
||||
"EMPTY_TEXT_EDITOR_CONFIG": "external_editor",
|
||||
"Persona": "personas",
|
||||
"WorkspaceProfile": "workspace_manager",
|
||||
"MCPServerConfig": "mcp_client",
|
||||
"MCPConfiguration": "mcp_client",
|
||||
"VectorStoreConfig": "mcp_client",
|
||||
"RAGConfig": "mcp_client",
|
||||
"load_mcp_config": "mcp_client",
|
||||
}
|
||||
|
||||
|
||||
def migrate_file(path: Path) -> int:
|
||||
"""Rewrite 'models.<moved_class>' references in path. Returns count of changed lines."""
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return 0
|
||||
original = content
|
||||
used_classes: set[str] = set()
|
||||
|
||||
for cls in CLASS_TO_MODULE:
|
||||
pattern = re.compile(rf"\bmodels\.{re.escape(cls)}\b")
|
||||
if pattern.search(content):
|
||||
content = pattern.sub(cls, content)
|
||||
used_classes.add(cls)
|
||||
if content == original:
|
||||
return 0
|
||||
|
||||
for cls in sorted(used_classes):
|
||||
mod = CLASS_TO_MODULE[cls]
|
||||
import_line = f"from src.{mod} import {cls}"
|
||||
if re.search(rf"^from\s+src\.{re.escape(mod)}\s+import\s+.*\b{re.escape(cls)}\b", content, re.MULTILINE):
|
||||
continue
|
||||
if not re.search(rf"^from\s+src\.{mod}\s+import\s", content, re.MULTILINE):
|
||||
content = re.sub(
|
||||
r"^(from __future__ import annotations\n)",
|
||||
rf"\1{import_line}\n",
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
else:
|
||||
content = re.sub(
|
||||
rf"^(from\s+src\.{re.escape(mod)}\s+import\s+[^\n]+)$",
|
||||
rf"\1, {cls}",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8", newline="")
|
||||
except OSError:
|
||||
return 0
|
||||
return len(used_classes)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(".")
|
||||
src_files = sorted(root.glob("src/*.py")) + sorted(root.glob("tests/*.py"))
|
||||
total_files = 0
|
||||
total_classes = 0
|
||||
for path in src_files:
|
||||
count = migrate_file(path)
|
||||
if count > 0:
|
||||
total_files += 1
|
||||
total_classes += count
|
||||
print(f" {path}: {count} class ref(s) updated")
|
||||
print(f"\nTotal: {total_classes} class ref(s) updated in {total_files} file(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+3553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
"""Personas module: Persona dataclass + PersonaManager CRUD.
|
||||
|
||||
Per module_taxonomy_refactor_20260627 Phase 3.4, the Persona dataclass
|
||||
moved from src/models.py into this module. PersonaManager (the ops layer
|
||||
that loads/saves Persona instances to TOML) was already here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
import tomli_w
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from src import paths
|
||||
from src.type_aliases import Metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class Persona:
|
||||
name: str
|
||||
preferred_models: list[Metadata] = field(default_factory=list)
|
||||
system_prompt: str = ''
|
||||
tool_preset: Optional[str] = None
|
||||
bias_profile: Optional[str] = None
|
||||
context_preset: Optional[str] = None
|
||||
aggregation_strategy: Optional[str] = None
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
if not self.preferred_models: return ""
|
||||
return self.preferred_models[0].get("provider") or ""
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
if not self.preferred_models: return ""
|
||||
return self.preferred_models[0].get("model") or ""
|
||||
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
if not self.preferred_models: return 0.0
|
||||
return float(self.preferred_models[0].get("temperature") or 0.0)
|
||||
|
||||
@property
|
||||
def top_p(self) -> float:
|
||||
if not self.preferred_models: return 1.0
|
||||
return float(self.preferred_models[0].get("top_p") or 1.0)
|
||||
|
||||
@property
|
||||
def max_output_tokens(self) -> int:
|
||||
if not self.preferred_models: return 0
|
||||
return int(self.preferred_models[0].get("max_output_tokens") or 0)
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
res = {"system_prompt": self.system_prompt}
|
||||
if self.preferred_models:
|
||||
processed = []
|
||||
for m in self.preferred_models:
|
||||
if isinstance(m, str):
|
||||
processed.append({"model": m})
|
||||
else:
|
||||
processed.append(m)
|
||||
res["preferred_models"] = processed
|
||||
if self.tool_preset is not None: res["tool_preset"] = self.tool_preset
|
||||
if self.bias_profile is not None: res["bias_profile"] = self.bias_profile
|
||||
if self.context_preset is not None: res["context_preset"] = self.context_preset
|
||||
if self.aggregation_strategy is not None: res["aggregation_strategy"] = self.aggregation_strategy
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: Metadata) -> "Persona":
|
||||
raw_models = data.get("preferred_models", [])
|
||||
parsed_models = []
|
||||
for m in raw_models:
|
||||
if isinstance(m, str):
|
||||
parsed_models.append({"model": m})
|
||||
else:
|
||||
parsed_models.append(m)
|
||||
legacy = {}
|
||||
for k in ["provider", "model", "temperature", "top_p", "max_output_tokens"]:
|
||||
if data.get(k) is not None:
|
||||
legacy[k] = data[k]
|
||||
if legacy:
|
||||
if not parsed_models:
|
||||
parsed_models.append(legacy)
|
||||
else:
|
||||
for k, v in legacy.items():
|
||||
if k not in parsed_models[0] or parsed_models[0][k] is None:
|
||||
parsed_models[0][k] = v
|
||||
return cls(
|
||||
name = name,
|
||||
preferred_models = parsed_models,
|
||||
system_prompt = data.get("system_prompt", ""),
|
||||
tool_preset = data.get("tool_preset"),
|
||||
bias_profile = data.get("bias_profile"),
|
||||
context_preset = data.get("context_preset"),
|
||||
aggregation_strategy = data.get("aggregation_strategy"),
|
||||
)
|
||||
|
||||
|
||||
class PersonaManager:
|
||||
"""Manages Persona profiles across global and project-specific files."""
|
||||
|
||||
def __init__(self, project_root: Optional[Path] = None):
|
||||
self.project_root = project_root
|
||||
|
||||
def _get_path(self, scope: str) -> Path:
|
||||
if scope == "global":
|
||||
return paths.get_global_personas_path()
|
||||
elif scope == "project":
|
||||
if not self.project_root:
|
||||
raise ValueError("Project root is not set, cannot resolve project scope.")
|
||||
return paths.get_project_personas_path(self.project_root)
|
||||
else:
|
||||
raise ValueError("Invalid scope, must be 'global' or 'project'")
|
||||
|
||||
def load_all(self) -> Dict[str, Persona]:
|
||||
personas = {}
|
||||
global_path = paths.get_global_personas_path()
|
||||
global_data = self._load_file(global_path)
|
||||
for name, data in global_data.get("personas", {}).items():
|
||||
personas[name] = Persona.from_dict(name, data)
|
||||
if self.project_root:
|
||||
project_path = paths.get_project_personas_path(self.project_root)
|
||||
project_data = self._load_file(project_path)
|
||||
for name, data in project_data.get("personas", {}).items():
|
||||
personas[name] = Persona.from_dict(name, data)
|
||||
return personas
|
||||
|
||||
def save_persona(self, persona: Persona, scope: str = "project") -> None:
|
||||
path = self._get_path(scope)
|
||||
data = self._load_file(path)
|
||||
if "personas" not in data:
|
||||
data["personas"] = {}
|
||||
data["personas"][persona.name] = persona.to_dict()
|
||||
self._save_file(path, data)
|
||||
|
||||
def get_persona_scope(self, name: str) -> str:
|
||||
"""Returns the scope ('global' or 'project') of a persona by name."""
|
||||
if self.project_root:
|
||||
project_path = paths.get_project_personas_path(self.project_root)
|
||||
project_data = self._load_file(project_path)
|
||||
if name in project_data.get("personas", {}):
|
||||
return "project"
|
||||
global_path = paths.get_global_personas_path()
|
||||
global_data = self._load_file(global_path)
|
||||
if name in global_data.get("personas", {}):
|
||||
return "global"
|
||||
return "project"
|
||||
|
||||
def delete_persona(self, name: str, scope: str = "project") -> None:
|
||||
path = self._get_path(scope)
|
||||
data = self._load_file(path)
|
||||
if "personas" in data and name in data["personas"]:
|
||||
del data["personas"][name]
|
||||
self._save_file(path, data)
|
||||
|
||||
def _load_file(self, path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _save_file(self, path: Path, data: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
@@ -0,0 +1,224 @@
|
||||
# Track Specification: module_taxonomy_refactor_20260627
|
||||
|
||||
## Overview
|
||||
|
||||
The user-reported `models.py` is a "dumping ground" (1044 lines, 36 classes, 5+ unrelated domains). This track cleans it up PLUS addresses 5 ImGui LEAKS that violate the "ImGui belongs in `gui_2.py`" boundary PLUS unifies 2 vendor files with `ai_client.py`.
|
||||
|
||||
Per the user's principle: **unify unless there's a good reason (import load times, definition pollution)**. No sub-directories. Prefix naming convention.
|
||||
|
||||
## Current State Audit (master `5380b715`, measured 2026-06-27)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| `src/` file count | 65 |
|
||||
| `src/models.py` line count | 1044 |
|
||||
| `src/models.py` class/function count | 36 |
|
||||
| `src/models.py` regions | 13 (Constants, Config Utilities, History Utilities, Pydantic Models, MMA Core, State & Config, Tool Models, UI/Editor, Persona, Workspace, MCP Config, Project Context, ...more) |
|
||||
| ImGui-using files outside `gui_2.py` | 5 (`bg_shader.py`, `shaders.py`, `command_palette.py`, `diff_viewer.py`, `patch_modal.py`) |
|
||||
| Vendor files separate from `ai_client.py` | 2 (`vendor_capabilities.py`, `vendor_state.py`) |
|
||||
| `AGENT_TOOL_NAMES` consumers | 8 (3 in `app_controller.py`, 5 in `tests/test_arch_boundary_phase2.py`) |
|
||||
| `mcp_tool_specs.tool_names()` test | EXISTS (asserts `tool_names() Γèå AGENT_TOOL_NAMES` ΓÇö proves it's redundant) |
|
||||
|
||||
## Goals
|
||||
|
||||
| ID | Goal | Acceptance |
|
||||
|---|---|---|
|
||||
| G1 | **MERGE 5 ImGui LEAKS into `gui_2.py`** | `git grep -l "imgui_bundle\|from imgui\\." -- 'src/*.py'` returns ONLY `gui_2.py` + `imgui_scopes.py` |
|
||||
| G2 | **MERGE 2 vendor files into `ai_client.py`** | `ls src/{vendor_capabilities,vendor_state}.py` returns not-found; `python -c "from src.ai_client import ..."` imports the merged symbols |
|
||||
| G3 | **SPLIT `models.py`** into `mma.py` + `project.py` + `project_files.py` | `ls src/mma.py src/project.py src/project_files.py` all exist; `python -c "from src.mma import ThinkingSegment, Ticket, Track, WorkerContext, TrackState"` works |
|
||||
| G4 | **MERGE** 6+ other `models.py` classes into existing sub-system files | `Persona` in `personas.py`; `Tool`/`ToolPreset` in `tool_presets.py`; `BiasProfile` in `tool_bias.py`; `TextEditorConfig`/`ExternalEditorConfig` in `external_editor.py`; `MCPServerConfig`+etc in `mcp_client.py`; `WorkspaceProfile` in `workspace_manager.py` |
|
||||
| G5 | **DELETE `AGENT_TOOL_NAMES`** (redundant with `mcp_tool_specs.tool_names()`) | `git grep "AGENT_TOOL_NAMES" -- 'src/*.py'` returns 0 hits; 8 consumer sites updated to use `list(mcp_tool_specs.tool_names())` |
|
||||
| G6 | **`src/models.py` reduced to Γëñ30 lines** (or eliminated) | `wc -l src/models.py` returns Γëñ30 |
|
||||
| G7 | All 7 audit gates pass `--strict` | unchanged from baseline |
|
||||
| G8 | All batched test tiers pass (10/11 baseline + RAG flake) | unchanged from baseline |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Renaming existing files for prefix consistency (`multi_agent_conductor.py` → `mma_conductor.py`, etc.) — deferred to follow-up; current names are clear enough
|
||||
- Refactoring `aggregate.py` (513 lines), `app_controller.py` (4869 lines), `gui_2.py` (7773 lines) ΓÇö out of scope; these have natural boundaries; the user doesn't want more splitting without good reason
|
||||
- Modifications to `mcp_client.py` other than merging the config dataclasses ΓÇö the merge itself is the change
|
||||
- New `src/<thing>.py` files (per AGENTS.md hard rule) ΓÇö the 3 new files (`mma.py`, `project.py`, `project_files.py`) are justified by the `models.py` split (definition pollution)
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR1: MERGE ImGui LEAKS into `gui_2.py`
|
||||
|
||||
For each of these 5 files, move the content into `gui_2.py` in a clearly-marked section, then `git rm` the original:
|
||||
|
||||
```python
|
||||
# In gui_2.py, add at the appropriate location:
|
||||
|
||||
#region: Bg Shader (moved from src/bg_shader.py)
|
||||
# ... (content of src/bg_shader.py)
|
||||
#endregion
|
||||
|
||||
#region: Shaders (moved from src/shaders.py)
|
||||
# ... (content of src/shaders.py)
|
||||
#endregion
|
||||
|
||||
#region: Command Palette (moved from src/command_palette.py)
|
||||
# ... (content of src/command_palette.py)
|
||||
#endregion
|
||||
|
||||
#region: Diff Viewer (moved from src/diff_viewer.py)
|
||||
# ... (content of src/diff_viewer.py)
|
||||
#endregion
|
||||
|
||||
#region: Patch Modal (moved from src/patch_modal.py)
|
||||
# ... (content of src/patch_modal.py)
|
||||
#endregion
|
||||
```
|
||||
|
||||
**Imports to update across the codebase:**
|
||||
- `from src.bg_shader import X` → `from src.gui_2 import X`
|
||||
- `from src.shaders import X` → `from src.gui_2 import X`
|
||||
- (etc. for all 5 files)
|
||||
|
||||
### FR2: MERGE vendor files into `ai_client.py`
|
||||
|
||||
```python
|
||||
# In ai_client.py, add at the appropriate location:
|
||||
|
||||
#region: Vendor Capabilities (moved from src/vendor_capabilities.py)
|
||||
# ... (content of src/vendor_capabilities.py)
|
||||
#endregion
|
||||
|
||||
#region: Vendor State (moved from src/vendor_state.py)
|
||||
# ... (content of src/vendor_state.py)
|
||||
#endregion
|
||||
```
|
||||
|
||||
**Imports to update:**
|
||||
- `from src.vendor_capabilities import X` → `from src.ai_client import X`
|
||||
- `from src.vendor_state import X` → `from src.ai_client import X`
|
||||
|
||||
### FR3: SPLIT `models.py`
|
||||
|
||||
**Phase 1: Create `src/mma.py`** with the MMA Core + TrackState:
|
||||
- ThinkingSegment
|
||||
- Ticket
|
||||
- Track
|
||||
- WorkerContext
|
||||
- TrackState
|
||||
- Top-level docstring explaining MMA scope
|
||||
|
||||
**Phase 2: Create `src/project.py`** with the project config:
|
||||
- ProjectContext + 5 sub-dataclasses (ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion)
|
||||
- Config I/O helpers: `_clean_nones`, `load_config_from_disk`, `save_config_to_disk`, `parse_history_entries`
|
||||
- Top-level docstring explaining project config scope
|
||||
|
||||
**Phase 3: Create `src/project_files.py`** with the file-related dataclasses:
|
||||
- FileItem
|
||||
- ContextPreset
|
||||
- ContextFileEntry
|
||||
- NamedViewPreset
|
||||
- Preset
|
||||
- Top-level docstring explaining file-related project state scope
|
||||
|
||||
### FR4: MERGE other `models.py` classes into existing sub-system files
|
||||
|
||||
| Class from `models.py` | Destination (existing file) | New section name |
|
||||
|---|---|---|
|
||||
| `Persona` | `src/personas.py` | "Persona Dataclass" |
|
||||
| `Tool`, `ToolPreset` | `src/tool_presets.py` | "Tool + ToolPreset Dataclasses" |
|
||||
| `BiasProfile` | `src/tool_bias.py` | "BiasProfile Dataclass" |
|
||||
| `TextEditorConfig`, `ExternalEditorConfig` | `src/external_editor.py` | "Editor Config Dataclasses" |
|
||||
| `MCPServerConfig`, `MCPConfiguration`, `VectorStoreConfig`, `RAGConfig`, `load_mcp_config` | `src/mcp_client.py` | "MCP Config Dataclasses" |
|
||||
| `WorkspaceProfile` | `src/workspace_manager.py` | "WorkspaceProfile Dataclass" |
|
||||
|
||||
### FR5: DELETE `AGENT_TOOL_NAMES` (redundant)
|
||||
|
||||
```python
|
||||
# 8 consumer site updates:
|
||||
# Before:
|
||||
from src.models import AGENT_TOOL_NAMES
|
||||
for tool in AGENT_TOOL_NAMES:
|
||||
...
|
||||
|
||||
# After:
|
||||
from src import mcp_tool_specs
|
||||
for tool in mcp_tool_specs.tool_names():
|
||||
...
|
||||
```
|
||||
|
||||
**Consumer sites (8):**
|
||||
- `src/app_controller.py:2110, 2972, 3273` (3 sites)
|
||||
- `tests/test_arch_boundary_phase2.py:23, 29, 31, 32, 33` (5 sites)
|
||||
|
||||
**Test simplification:** `test_tool_names_subset_of_models_agent_tool_names` becomes either:
|
||||
- DELETE (it's a tautology once `AGENT_TOOL_NAMES` is derived from `tool_names()`)
|
||||
- OR convert to a positive assertion: `assert mcp_tool_specs.tool_names() == {expected canonical tools}`
|
||||
|
||||
### FR6: REDUCE `src/models.py` to ~30 lines (or eliminate)
|
||||
|
||||
After all moves, `src/models.py` contains:
|
||||
- `_create_generate_request`, `_create_confirm_request`, `__getattr__` (Pydantic lazy proxies for the API)
|
||||
- OR these move to `src/api_hooks.py` (if API-specific)
|
||||
- Top-level docstring
|
||||
|
||||
If `models.py` becomes essentially empty after these moves, **delete the file entirely** (it's not a "system" file; `models.py` is just a temporary holder).
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- NFR1: 1-space indentation (per `conductor/workflow.md`)
|
||||
- NFR2: CRLF line endings on Windows
|
||||
- NFR3: No comments in source code (per AGENTS.md "No comments in source code")
|
||||
- NFR4: Per-task atomic commits with git notes
|
||||
- NFR5: No new pip dependencies
|
||||
- NFR6: `Result[T]` returns for fallible fns (per `error_handling.md`)
|
||||
- NFR7: No new `src/<thing>.py` files UNLESS justified by definition pollution (per AGENTS.md hard rule)
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
- `AGENTS.md` ΓÇö "File Size and Naming Convention" HARD RULE
|
||||
- `conductor/code_styleguides/data_oriented_design.md` ΓÇö "Prefer Fewer Types" principle
|
||||
- `conductor/code_styleguides/error_handling.md` ΓÇö the `Result[T]` convention
|
||||
- `conductor/code_styleguides/type_aliases.md` ΓÇö the 10 TypeAliases convention
|
||||
- `conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md` ΓÇö the related spec correction (the original Phase 2 spec was wrong to put ProjectContext in `models.py`; this track fixes that)
|
||||
- `docs/reports/FOLLOWUP_module_taxonomy_20260627.md` ΓÇö the previous followup report (this track supersedes it with concrete execution)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Renaming existing files for prefix consistency (`multi_agent_conductor.py` → `mma_conductor.py`, etc.) — deferred to follow-up
|
||||
- Refactoring `aggregate.py` (513 lines), `app_controller.py` (4869 lines), `gui_2.py` (7773 lines) ΓÇö out of scope; these have natural boundaries
|
||||
- Modifications to `mcp_client.py` other than merging the config dataclasses
|
||||
- New `src/<thing>.py` files beyond the 3 justified ones (`mma.py`, `project.py`, `project_files.py`)
|
||||
- The RAG test pre-existing flake (per `docs/reports/SSDL_CAMPAIGN_ABORTED_20260624.md` "Out of Scope")
|
||||
- Any Tier 2 spec rewrites (per the user's earlier "don't fuck with commits" directive)
|
||||
|
||||
## Verification Criteria (Definition of Done)
|
||||
|
||||
| # | Criterion | Verification |
|
||||
|---|---|---|
|
||||
| VC1 | ImGui imports limited to `gui_2.py` + `imgui_scopes.py` | `git grep -l "imgui_bundle\|from imgui\\." -- 'src/*.py'` returns 2 files |
|
||||
| VC2 | `src/bg_shader.py`, `src/shaders.py`, `src/command_palette.py`, `src/diff_viewer.py` deleted (4 LEAK files per the data/view/ops split) | `ls src/{bg_shader,shaders,command_palette,diff_viewer}.py` returns not-found. `src/patch_modal.py` is NOT a LEAK ΓÇö it's the data module (DiffHunk/DiffFile/PendingPatch) per the data/view/ops split rule. The diff_viewer classes (DiffHunk/DiffFile) were moved INTO it during the cruft_elimination track's split; deleting it would violate the data module's integrity. See `conductor/tracks/post_module_taxonomy_de_cruft_20260627/spec.md` Phase 1 for the formal correction. |
|
||||
| VC3 | `src/vendor_capabilities.py`, `src/vendor_state.py` deleted | `ls src/{vendor_capabilities,vendor_state}.py` returns not-found |
|
||||
| VC4 | Vendor symbols importable from `src.ai_client` | `python -c "from src.ai_client import PROVIDER_CAPABILITIES, get_vendor_state"` works |
|
||||
| VC5 | `src/mma.py` exists with MMA Core + TrackState | `python -c "from src.mma import ThinkingSegment, Ticket, Track, WorkerContext, TrackState"` works |
|
||||
| VC6 | `src/project.py` exists with ProjectContext + sub + config I/O | `python -c "from src.project import ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles, ProjectScreenshots, ProjectDiscussion, _clean_nones, load_config_from_disk, save_config_to_disk, parse_history_entries"` works |
|
||||
| VC7 | `src/project_files.py` exists with file-related dataclasses | `python -c "from src.project_files import FileItem, ContextPreset, ContextFileEntry, NamedViewPreset, Preset"` works |
|
||||
| VC8 | Persona/Tool/Editor/MCP/Workspace dataclasses in their proper sub-system files | `python -c "from src.personas import Persona; from src.tool_presets import Tool, ToolPreset; from src.tool_bias import BiasProfile; from src.external_editor import TextEditorConfig, ExternalEditorConfig; from src.mcp_client import MCPServerConfig, MCPConfiguration, VectorStoreConfig, RAGConfig, load_mcp_config; from src.workspace_manager import WorkspaceProfile"` works |
|
||||
| VC9 | `AGENT_TOOL_NAMES` deleted; all 8 consumer sites use `mcp_tool_specs.tool_names()` | `git grep "AGENT_TOOL_NAMES" -- 'src/*.py' 'tests/*.py'` returns 0 hits |
|
||||
| VC10 | `src/models.py` reduced from 1044 to ~135 lines (Pydantic proxies + DEFAULT_TOOL_CATEGORIES + lazy `__getattr__` for backward compat) | `wc -l src/models.py` returns Γëñ200; the 30-line target was aspirational. The lazy `__getattr__` is necessary for backward compat with 30+ legacy `from src.models import X` call sites until the `post_module_taxonomy_de_cruft_20260627` follow-up track migrates them to direct imports from the subsystem files (`src.mma`, `src.project`, `src/project_files`, `src/tool_presets`, `src/tool_bias`, `src/external_editor`, `src/personas`, `src/workspace_manager`, `src/mcp_client`). The full migration is FR7 of the post_module_taxonomy_de_cruft_20260627 track. The legacy `Metadata = TrackMetadata` alias is preserved for `from src.models import Metadata` to resolve to the TrackMetadata dataclass (used by `tests/test_track_state_schema.py`). |
|
||||
| VC11 | All 7 audit gates pass `--strict` | unchanged from baseline |
|
||||
| VC12 | 10/11 batched test tiers pass (RAG flake acceptable) | unchanged from baseline |
|
||||
|
||||
## Risks
|
||||
|
||||
| # | Risk | Likelihood | Mitigation |
|
||||
|---|---|---|---|
|
||||
| R1 | ImGui LEAKS move breaks existing tests (e.g., `command_palette` is referenced in commands.py) | low | Run full affected test set after each move; revert + fix on regression |
|
||||
| R2 | Vendor merge into `ai_client.py` creates circular imports (PROVIDERS lazy proxy is the workaround) | medium | The lazy import pattern (`__getattr__`) handles this; verify by running the full test suite after merge |
|
||||
| R3 | `models.py` split breaks 136 import sites | high | Per-file move with regression-guard tests after each; update imports systematically |
|
||||
| R4 | The 6+ "merge into existing sub-system files" moves break those files' existing tests | medium | Run the affected test file after each merge |
|
||||
| R5 | `AGENT_TOOL_NAMES` deletion breaks `test_arch_boundary_phase2.py` | low | Update the test to use `mcp_tool_specs.tool_names()`; cross-check that the test's expected tool names are in the registry |
|
||||
| R6 | The `ProjectContext` Phase 2 commit (in `cruft_elimination_20260627`) put `ProjectContext` in `models.py`; the new track moves it to `project.py` ΓÇö needs to coordinate with the cruft track | high | The cruft track should NOT merge its `models.py` `ProjectContext` commit; this refactor track handles the move |
|
||||
| R7 | The `_create_generate_request` etc. Pydantic proxies in `models.py` are used by `api_hooks.py`; if we move them to `api_hooks.py` we create a different topology | low | Audit the consumers; if they're all in `api_hooks.py`, move them; if not, keep in `models.py` or move to a new `api_models.py` |
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/reports/FOLLOWUP_module_taxonomy_20260627.md` ΓÇö the previous followup report (this spec supersedes it)
|
||||
- `conductor/tracks/cruft_elimination_20260627/SPEC_CORRECTION_phase_2.md` ΓÇö the related spec correction
|
||||
- `conductor/tracks/cruft_elimination_20260627/spec.md` ΓÇö the parent spec (which is currently in flux)
|
||||
- `AGENTS.md` ΓÇö "File Size and Naming Convention" HARD RULE
|
||||
- `conductor/code_styleguides/data_oriented_design.md` ΓÇö "Prefer Fewer Types" principle
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.tool_presets import Tool, ToolPreset
|
||||
from src.type_aliases import Metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class BiasProfile:
|
||||
name: str
|
||||
tool_weights: Dict[str, int] = field(default_factory=dict)
|
||||
category_multipliers: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
return {
|
||||
"name": self.name,
|
||||
"tool_weights": self.tool_weights,
|
||||
"category_multipliers": self.category_multipliers,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Metadata) -> "BiasProfile":
|
||||
return cls(
|
||||
name = data["name"],
|
||||
tool_weights = data.get("tool_weights", {}),
|
||||
category_multipliers = data.get("category_multipliers", {}),
|
||||
)
|
||||
|
||||
|
||||
class ToolBiasEngine:
|
||||
def apply_semantic_nudges(self, tool_definitions: List[Dict[str, Any]], preset: ToolPreset) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
[C: tests/test_tool_bias.py:test_apply_semantic_nudges, tests/test_tool_bias.py:test_parameter_bias_nudging]
|
||||
"""
|
||||
weight_map = {
|
||||
5: "[HIGH PRIORITY] ",
|
||||
4: "[PREFERRED] ",
|
||||
2: "[NOT RECOMMENDED] ",
|
||||
1: "[LOW PRIORITY] "
|
||||
}
|
||||
|
||||
preset_tools: Dict[str, Tool] = {}
|
||||
for cat_tools in preset.categories.values():
|
||||
for t in cat_tools:
|
||||
if isinstance(t, Tool):
|
||||
preset_tools[t.name] = t
|
||||
|
||||
for defn in tool_definitions:
|
||||
name = defn.get("name")
|
||||
if name in preset_tools:
|
||||
tool = preset_tools[name]
|
||||
prefix = weight_map.get(tool.weight, "")
|
||||
if prefix:
|
||||
defn["description"] = prefix + defn.get("description", "")
|
||||
|
||||
if tool.parameter_bias:
|
||||
params = defn.get("parameters") or defn.get("input_schema")
|
||||
if params and "properties" in params:
|
||||
props = params["properties"]
|
||||
for p_name, bias in tool.parameter_bias.items():
|
||||
if p_name in props:
|
||||
p_desc = props[p_name].get("description", "")
|
||||
props[p_name]["description"] = f"[{bias}] {p_desc}".strip()
|
||||
|
||||
return tool_definitions
|
||||
|
||||
def generate_tooling_strategy(self, preset: ToolPreset, global_bias: BiasProfile) -> str:
|
||||
"""
|
||||
[C: tests/test_tool_bias.py:test_generate_tooling_strategy]
|
||||
"""
|
||||
lines = ["### Tooling Strategy"]
|
||||
|
||||
preferred = []
|
||||
low_priority = []
|
||||
for cat_tools in preset.categories.values():
|
||||
for t in cat_tools:
|
||||
if not isinstance(t, Tool): continue
|
||||
if t.weight >= 5: preferred.append(f"{t.name} [HIGH PRIORITY]")
|
||||
elif t.weight == 4: preferred.append(f"{t.name} [PREFERRED]")
|
||||
elif t.weight == 2: low_priority.append(f"{t.name} [NOT RECOMMENDED]")
|
||||
elif t.weight <= 1: low_priority.append(f"{t.name} [LOW PRIORITY]")
|
||||
|
||||
if preferred: lines.append(f"Preferred tools: {', '.join(preferred)}.")
|
||||
if low_priority: lines.append(f"Low-priority tools: {', '.join(low_priority)}.")
|
||||
|
||||
if global_bias.category_multipliers:
|
||||
lines.append("Category focus multipliers:")
|
||||
for cat, mult in global_bias.category_multipliers.items():
|
||||
lines.append(f"- {cat}: {mult}x")
|
||||
|
||||
return "\n\n".join(lines)
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
import tomli_w
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
|
||||
from src import paths
|
||||
from src.type_aliases import Metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
name: str
|
||||
approval: str = 'auto'
|
||||
weight: int = 3
|
||||
parameter_bias: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
return {
|
||||
"name": self.name,
|
||||
"approval": self.approval,
|
||||
"weight": self.weight,
|
||||
"parameter_bias": self.parameter_bias,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Metadata) -> "Tool":
|
||||
return cls(
|
||||
name=data["name"],
|
||||
approval=data.get("approval", "auto"),
|
||||
weight=data.get("weight", 3),
|
||||
parameter_bias=data.get("parameter_bias", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPreset:
|
||||
name: str
|
||||
categories: Dict[str, List[Union[Tool, Any]]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
serialized_categories = {}
|
||||
for cat, tools in self.categories.items():
|
||||
serialized_categories[cat] = [t.to_dict() if isinstance(t, Tool) else t for t in tools]
|
||||
return {"categories": serialized_categories}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: Metadata) -> "ToolPreset":
|
||||
raw_categories = data.get("categories", {})
|
||||
parsed_categories = {}
|
||||
for cat, tools in raw_categories.items():
|
||||
parsed_categories[cat] = [Tool.from_dict(t) if isinstance(t, dict) else t for t in tools]
|
||||
return cls(name=name, categories=parsed_categories)
|
||||
|
||||
|
||||
class ToolPresetManager:
|
||||
def __init__(self, project_root: Optional[Union[str, Path]] = None):
|
||||
self.project_root = Path(project_root) if project_root else None
|
||||
|
||||
def _get_path(self, scope: str) -> Path:
|
||||
"""
|
||||
[C: src/workspace_manager.py:WorkspaceManager.delete_profile, src/workspace_manager.py:WorkspaceManager.save_profile]
|
||||
"""
|
||||
if scope == "global":
|
||||
return paths.get_global_tool_presets_path()
|
||||
elif scope == "project":
|
||||
if not self.project_root:
|
||||
raise ValueError("Project root not set for project scope operation.")
|
||||
return paths.get_project_tool_presets_path(self.project_root)
|
||||
else:
|
||||
raise ValueError(f"Invalid scope: {scope}")
|
||||
|
||||
def _read_raw(self, path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write_raw(self, path: Path, data: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
def load_all_presets(self) -> Dict[str, ToolPreset]:
|
||||
"""
|
||||
[C: tests/test_tool_preset_manager.py:test_load_all_presets_merged]
|
||||
"""
|
||||
global_path = paths.get_global_tool_presets_path()
|
||||
global_data = self._read_raw(global_path).get("presets", {})
|
||||
|
||||
presets = {}
|
||||
for name, config in global_data.items():
|
||||
if isinstance(config, dict):
|
||||
presets[name] = ToolPreset.from_dict(name, config)
|
||||
|
||||
if self.project_root:
|
||||
project_path = paths.get_project_tool_presets_path(self.project_root)
|
||||
project_data = self._read_raw(project_path).get("presets", {})
|
||||
for name, config in project_data.items():
|
||||
if isinstance(config, dict):
|
||||
presets[name] = ToolPreset.from_dict(name, config)
|
||||
|
||||
return presets
|
||||
|
||||
def load_all(self) -> Dict[str, ToolPreset]:
|
||||
"""
|
||||
Backward compatibility for load_all().
|
||||
[C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
|
||||
"""
|
||||
return self.load_all_presets()
|
||||
|
||||
def save_preset(self, preset: ToolPreset, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_preset_manager.py:test_save_preset_project_no_root, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._read_raw(path)
|
||||
if "presets" not in data:
|
||||
data["presets"] = {}
|
||||
data["presets"][preset.name] = preset.to_dict()
|
||||
self._write_raw(path, data)
|
||||
|
||||
def delete_preset(self, name: str, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_preset_manager.py:test_delete_preset, tests/test_presets.py:TestPresetManager.test_delete_preset]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._read_raw(path)
|
||||
if "presets" in data and name in data["presets"]:
|
||||
del data["presets"][name]
|
||||
self._write_raw(path, data)
|
||||
|
||||
def load_all_bias_profiles(self) -> Dict[str, "BiasProfile"]:
|
||||
"""
|
||||
[C: tests/test_tool_preset_manager.py:test_bias_profiles_merged, tests/test_tool_preset_manager.py:test_delete_bias_profile, tests/test_tool_preset_manager.py:test_save_bias_profile]
|
||||
"""
|
||||
from src.tool_bias import BiasProfile
|
||||
global_path = paths.get_global_tool_presets_path()
|
||||
global_data = self._read_raw(global_path).get("bias_profiles", {})
|
||||
|
||||
profiles = {}
|
||||
for name, config in global_data.items():
|
||||
if isinstance(config, dict):
|
||||
cfg = dict(config)
|
||||
if "name" not in cfg:
|
||||
cfg["name"] = name
|
||||
profiles[name] = BiasProfile.from_dict(cfg)
|
||||
|
||||
if self.project_root:
|
||||
project_path = paths.get_project_tool_presets_path(self.project_root)
|
||||
project_data = self._read_raw(project_path).get("bias_profiles", {})
|
||||
for name, config in project_data.items():
|
||||
if isinstance(config, dict):
|
||||
cfg = dict(config)
|
||||
if "name" not in cfg:
|
||||
cfg["name"] = name
|
||||
profiles[name] = BiasProfile.from_dict(cfg)
|
||||
|
||||
return profiles
|
||||
|
||||
def save_bias_profile(self, profile: BiasProfile, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_tool_preset_manager.py:test_save_bias_profile]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._read_raw(path)
|
||||
if "bias_profiles" not in data:
|
||||
data["bias_profiles"] = {}
|
||||
data["bias_profiles"][profile.name] = profile.to_dict()
|
||||
self._write_raw(path, data)
|
||||
|
||||
def delete_bias_profile(self, name: str, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_tool_preset_manager.py:test_delete_bias_profile]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._read_raw(path)
|
||||
if "bias_profiles" in data and name in data["bias_profiles"]:
|
||||
del data["bias_profiles"][name]
|
||||
self._write_raw(path, data)
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import tomllib
|
||||
import tomli_w
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
from src import paths
|
||||
from src.type_aliases import Metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceProfile:
|
||||
name: str
|
||||
ini_content: str
|
||||
show_windows: Dict[str, bool]
|
||||
panel_states: Metadata
|
||||
|
||||
def to_dict(self) -> Metadata:
|
||||
return {
|
||||
"ini_content": self.ini_content,
|
||||
"show_windows": self.show_windows,
|
||||
"panel_states": self.panel_states,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: Metadata) -> "WorkspaceProfile":
|
||||
return cls(
|
||||
name = name,
|
||||
ini_content = data.get("ini_content", ""),
|
||||
show_windows = data.get("show_windows", {}),
|
||||
panel_states = data.get("panel_states", {}),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages Workspace profiles across global and project-specific files."""
|
||||
|
||||
def __init__(self, project_root: Optional[Union[str, Path]] = None):
|
||||
if project_root:
|
||||
self.project_root = Path(project_root).resolve()
|
||||
else:
|
||||
self.project_root = None
|
||||
|
||||
def _get_path(self, scope: str) -> Path:
|
||||
if scope == "global":
|
||||
return paths.get_global_workspace_profiles_path()
|
||||
elif scope == "project":
|
||||
if not self.project_root:
|
||||
raise ValueError("Project root is not set, cannot resolve project scope.")
|
||||
return paths.get_project_workspace_profiles_path(self.project_root)
|
||||
else:
|
||||
raise ValueError("Invalid scope, must be 'global' or 'project'")
|
||||
|
||||
def load_all_profiles(self) -> Dict[str, WorkspaceProfile]:
|
||||
"""
|
||||
Merges global and project profiles into a single dictionary.
|
||||
[C: tests/test_workspace_manager.py:test_delete_profile, tests/test_workspace_manager.py:test_load_all_profiles_merged, tests/test_workspace_manager.py:test_save_profile_global_and_project]
|
||||
"""
|
||||
profiles = {}
|
||||
|
||||
global_path = paths.get_global_workspace_profiles_path()
|
||||
global_data = self._load_file(global_path)
|
||||
for name, data in global_data.get("profiles", {}).items():
|
||||
profiles[name] = WorkspaceProfile.from_dict(name, data)
|
||||
|
||||
if self.project_root:
|
||||
project_path = paths.get_project_workspace_profiles_path(self.project_root)
|
||||
project_data = self._load_file(project_path)
|
||||
for name, data in project_data.get("profiles", {}).items():
|
||||
profiles[name] = WorkspaceProfile.from_dict(name, data)
|
||||
|
||||
return profiles
|
||||
|
||||
def save_profile(self, profile: WorkspaceProfile, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_workspace_manager.py:test_delete_profile, tests/test_workspace_manager.py:test_save_profile_global_and_project]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._load_file(path)
|
||||
if "profiles" not in data:
|
||||
data["profiles"] = {}
|
||||
|
||||
data["profiles"][profile.name] = profile.to_dict()
|
||||
self._save_file(path, data)
|
||||
|
||||
def delete_profile(self, name: str, scope: str = "project") -> None:
|
||||
"""
|
||||
[C: tests/test_workspace_manager.py:test_delete_profile]
|
||||
"""
|
||||
path = self._get_path(scope)
|
||||
data = self._load_file(path)
|
||||
if "profiles" in data and name in data["profiles"]:
|
||||
del data["profiles"][name]
|
||||
self._save_file(path, data)
|
||||
|
||||
def _load_file(self, path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _save_file(self, path: Path, data: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
Reference in New Issue
Block a user