viperx 0.9.14__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.
viperx/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from propy!")
@@ -0,0 +1,141 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+
6
+ from viperx.core import ProjectGenerator
7
+ from viperx.constants import DEFAULT_LICENSE, DEFAULT_BUILDER, TYPE_CLASSIC, FRAMEWORK_PYTORCH
8
+
9
+ console = Console()
10
+
11
+ class ConfigEngine:
12
+ """
13
+ Orchestrates project creation and updates based on a declarative YAML config.
14
+ Implements the 'Infrastructure as Code' pattern for ViperX.
15
+ """
16
+
17
+ def __init__(self, config_path: Path, verbose: bool = False):
18
+ self.config_path = config_path
19
+ self.verbose = verbose
20
+ self.config = self._load_config()
21
+ self.root_path = Path.cwd()
22
+
23
+ def _load_config(self) -> dict:
24
+ """Load and validate the YAML configuration."""
25
+ if not self.config_path.exists():
26
+ console.print(f"[bold red]Error:[/bold red] Config file not found at {self.config_path}")
27
+ raise FileNotFoundError(f"Config file not found: {self.config_path}")
28
+
29
+ with open(self.config_path, "r") as f:
30
+ try:
31
+ data = yaml.safe_load(f)
32
+ except yaml.YAMLError as e:
33
+ console.print(f"[bold red]Error:[/bold red] Invalid YAML format: {e}")
34
+ raise ValueError("Invalid YAML")
35
+
36
+ # Basic Validation
37
+ if "project" not in data or "name" not in data["project"]:
38
+ console.print("[bold red]Error:[/bold red] Config must contain 'project.name'")
39
+ raise ValueError("Missing project.name")
40
+
41
+ return data
42
+
43
+ def apply(self):
44
+ """Apply the configuration to the current directory."""
45
+ project_conf = self.config.get("project", {})
46
+ settings_conf = self.config.get("settings", {})
47
+ workspace_conf = self.config.get("workspace", {})
48
+
49
+ project_name = project_conf.get("name")
50
+ target_dir = self.root_path / project_name
51
+
52
+ # 1. Root Project Handling
53
+ # If we are NOT already in the project dir (checking name), we might need to create it
54
+ # Or if we are running in the root of where `viperx init` is called.
55
+
56
+ # Heuristic: Are we already in a folder named 'project_name'?
57
+ if self.root_path.name == project_name:
58
+ # We are inside the project folder
59
+ if not (self.root_path / "pyproject.toml").exists():
60
+ console.print(Panel(f"⚠️ [bold yellow]Current directory matches name but is not initialized. Hydrating:[/bold yellow] {project_name}", border_style="yellow"))
61
+ gen = ProjectGenerator(
62
+ name=project_name,
63
+ description=project_conf.get("description", ""),
64
+ type=settings_conf.get("type", TYPE_CLASSIC),
65
+ author=project_conf.get("author", None),
66
+ license=project_conf.get("license", DEFAULT_LICENSE),
67
+ builder=project_conf.get("builder", DEFAULT_BUILDER),
68
+ use_env=settings_conf.get("use_env", False),
69
+ use_config=settings_conf.get("use_config", True),
70
+ use_tests=settings_conf.get("use_tests", True),
71
+ framework=settings_conf.get("framework", FRAMEWORK_PYTORCH),
72
+ verbose=self.verbose
73
+ )
74
+ # generate() expects parent dir, and will operate on parent/name (which is self.root_path)
75
+ gen.generate(self.root_path.parent)
76
+ else:
77
+ console.print(Panel(f"♻️ [bold blue]Syncing Project:[/bold blue] {project_name}", border_style="blue"))
78
+ current_root = self.root_path
79
+ else:
80
+ # We are outside, check if it exists
81
+ if target_dir.exists() and (target_dir / "pyproject.toml").exists():
82
+ console.print(Panel(f"♻️ [bold blue]Updating Existing Project:[/bold blue] {project_name}", border_style="blue"))
83
+ current_root = target_dir
84
+ else:
85
+ if target_dir.exists():
86
+ console.print(Panel(f"⚠️ [bold yellow]Directory exists but not initialized. Hydrating:[/bold yellow] {project_name}", border_style="yellow"))
87
+ else:
88
+ console.print(Panel(f"🚀 [bold green]Creating New Project:[/bold green] {project_name}", border_style="green"))
89
+
90
+ # Create Root (or Hydrate)
91
+ gen = ProjectGenerator(
92
+ name=project_name,
93
+ description=project_conf.get("description", ""),
94
+ type=settings_conf.get("type", TYPE_CLASSIC),
95
+ author=project_conf.get("author", None),
96
+ license=project_conf.get("license", DEFAULT_LICENSE),
97
+ builder=project_conf.get("builder", DEFAULT_BUILDER),
98
+ use_env=settings_conf.get("use_env", False),
99
+ use_config=settings_conf.get("use_config", True),
100
+ use_tests=settings_conf.get("use_tests", True),
101
+ framework=settings_conf.get("framework", FRAMEWORK_PYTORCH),
102
+ verbose=self.verbose
103
+ )
104
+ gen.generate(self.root_path)
105
+ current_root = target_dir
106
+
107
+ # 2. Copy Config to Root (Source of Truth)
108
+ # Only if we aren't reading the one already there
109
+ system_config_path = current_root / "viperx.yaml"
110
+ if self.config_path.absolute() != system_config_path.absolute():
111
+ import shutil
112
+ shutil.copy2(self.config_path, system_config_path)
113
+ console.print(f"[dim]Saved configuration to {system_config_path.name}[/dim]")
114
+
115
+ # 3. Handle Workspace Packages
116
+ packages = workspace_conf.get("packages", [])
117
+ if packages:
118
+ console.print(f"\n📦 [bold]Processing {len(packages)} workspace packages...[/bold]")
119
+
120
+ for pkg in packages:
121
+ pkg_name = pkg.get("name")
122
+ pkg_path = current_root / "src" / pkg_name.replace("-", "_") # Approximate check
123
+
124
+ # We instantiate a generator for this package
125
+ pkg_gen = ProjectGenerator(
126
+ name=pkg_name,
127
+ description=pkg.get("description", ""),
128
+ type=pkg.get("type", TYPE_CLASSIC),
129
+ author=project_conf.get("author", "Your Name"), # Inherit author
130
+ use_env=pkg.get("use_env", settings_conf.get("use_env", False)), # Inherit settings or default False
131
+ use_config=pkg.get("use_config", settings_conf.get("use_config", True)), # Inherit or default True
132
+ use_readme=pkg.get("use_readme", True),
133
+ use_tests=pkg.get("use_tests", settings_conf.get("use_tests", True)),
134
+ framework=pkg.get("framework", FRAMEWORK_PYTORCH),
135
+ verbose=self.verbose
136
+ )
137
+
138
+ # Check if package seems to exist (ProjectGenerator handles upgrade logic too)
139
+ pkg_gen.add_to_workspace(current_root)
140
+
141
+ console.print(Panel(f"✨ [bold green]Configuration Applied Successfully![/bold green]\nProject is up to date with {self.config_path.name}", border_style="green"))
viperx/constants.py ADDED
@@ -0,0 +1,35 @@
1
+ from pathlib import Path
2
+
3
+ # Project Defaults
4
+ DEFAULT_VERSION = "0.1.0"
5
+ DEFAULT_PYTHON_VERSION = "3.11"
6
+ DEFAULT_LICENSE = "MIT"
7
+ DEFAULT_BUILDER = "uv"
8
+
9
+ # File Names
10
+ CONFIG_FILENAME = "config.yaml"
11
+ PYPROJECT_FILENAME = "pyproject.toml"
12
+ PACKAGE_NAME = "viperx"
13
+ README_FILENAME = "README.md"
14
+ INIT_FILENAME = "__init__.py"
15
+ MAIN_FILENAME = "main.py"
16
+
17
+ # Directory Names
18
+ SRC_DIR = "src"
19
+ NOTEBOOKS_DIR = "notebooks"
20
+ TESTS_DIR = "tests"
21
+
22
+ # Templates
23
+ TEMPLATE_DIR_NAME = "templates"
24
+ TEMPLATES_DIR = Path(__file__).parent / TEMPLATE_DIR_NAME
25
+
26
+ # Types
27
+ TYPE_CLASSIC = "classic"
28
+ TYPE_ML = "ml"
29
+ TYPE_DL = "dl"
30
+ PROJECT_TYPES = [TYPE_CLASSIC, TYPE_ML, TYPE_DL]
31
+
32
+ # DL Frameworks
33
+ FRAMEWORK_PYTORCH = "pytorch"
34
+ FRAMEWORK_TENSORFLOW = "tensorflow"
35
+ DL_FRAMEWORKS = [FRAMEWORK_PYTORCH, FRAMEWORK_TENSORFLOW]
viperx/core.py ADDED
@@ -0,0 +1,416 @@
1
+ import os
2
+ import sys
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from jinja2 import Environment, PackageLoader, select_autoescape
8
+ from datetime import datetime
9
+ from rich.console import Console
10
+
11
+ from viperx.constants import (
12
+ TEMPLATES_DIR,
13
+ DEFAULT_VERSION,
14
+ DEFAULT_PYTHON_VERSION,
15
+ DEFAULT_LICENSE,
16
+ DEFAULT_BUILDER,
17
+ TYPE_CLASSIC,
18
+ TYPE_ML,
19
+ TYPE_DL,
20
+ SRC_DIR,
21
+ NOTEBOOKS_DIR,
22
+ TESTS_DIR,
23
+ )
24
+ from .utils import sanitize_project_name, get_author_from_git
25
+ from .licenses import LICENSES
26
+
27
+ console = Console()
28
+
29
+ class ProjectGenerator:
30
+ def __init__(self, name: str, description: str, type: str,
31
+ author: str,
32
+ use_env: bool = False, use_config: bool = True,
33
+ use_readme: bool = True, use_tests: bool = True,
34
+ license: str = DEFAULT_LICENSE,
35
+ builder: str = DEFAULT_BUILDER,
36
+ framework: str = "pytorch",
37
+ verbose: bool = False):
38
+ self.raw_name = name
39
+ self.project_name = sanitize_project_name(name)
40
+ self.description = description or name
41
+ self.type = type
42
+ self.framework = framework
43
+ self.author = author
44
+ if not self.author or self.author == "Your Name":
45
+ self.author, self.author_email = get_author_from_git()
46
+ else:
47
+ self.author_email = "your.email@example.com"
48
+
49
+ self.license = license
50
+ self.builder = builder
51
+ self.use_env = use_env
52
+ self.use_config = use_config
53
+ self.use_readme = use_readme
54
+ self.use_tests = use_tests
55
+ self.verbose = verbose
56
+
57
+ # Detect System Python
58
+ self.python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
59
+
60
+ # Jinja Setup
61
+ self.env = Environment(
62
+ loader=PackageLoader("viperx", "templates"),
63
+ autoescape=select_autoescape()
64
+ )
65
+
66
+ def log(self, message: str, style: str = "dim"):
67
+ if self.verbose:
68
+ console.print(f" [{style}]{message}[/{style}]")
69
+
70
+ def generate(self, target_dir: Optional[Path] = None):
71
+ """Main generation flow using uv init."""
72
+ if target_dir is None:
73
+ target_dir = Path.cwd()
74
+
75
+ project_dir = target_dir / self.raw_name
76
+
77
+ if project_dir.exists():
78
+ console.print(f"[bold red]Error:[/bold red] Directory {project_dir} already exists.")
79
+ return
80
+
81
+ console.print(f"[bold green]Creating project {self.raw_name} ({self.type})...[/bold green]")
82
+
83
+ self.log(f"Target directory: {project_dir}")
84
+ self.log(f"Python version: {self.python_version}")
85
+
86
+
87
+ def generate(self, target_dir: Path, is_subpackage: bool = False):
88
+ """Main generation flow using uv init."""
89
+ project_dir = target_dir / self.raw_name
90
+
91
+ # 1. Scaffolding with uv init
92
+ try:
93
+ if project_dir.exists():
94
+ if (project_dir / "pyproject.toml").exists():
95
+ console.print(f"[bold red]Error:[/bold red] Directory {project_dir} is already a project.")
96
+ return
97
+ # Hydrate existing directory
98
+ console.print(f" [yellow]Hydrating existing directory {project_dir}...[/yellow]")
99
+ subprocess.run(
100
+ ["uv", "init", "--package", "--no-workspace"],
101
+ check=True, cwd=project_dir, capture_output=True
102
+ )
103
+ else:
104
+ # Create new
105
+ subprocess.run(
106
+ ["uv", "init", "--package", "--no-workspace", self.raw_name],
107
+ check=True, cwd=target_dir, capture_output=True
108
+ )
109
+ console.print(" [blue]✓ Scaffolding created with uv init[/blue]")
110
+ except subprocess.CalledProcessError as e:
111
+ console.print(f"[bold red]Error running uv init:[/bold red] {e}")
112
+ return
113
+
114
+ # 2. Restructure / Clean up
115
+ # If is_subpackage, convert to Ultra-Flat Layout (Code at Root)
116
+ # Target: root/__init__.py (and siblings)
117
+ # Source: root/src/pkg/__init__.py (from uv init --lib)
118
+ if is_subpackage:
119
+ src_pkg_path = project_dir / "src" / self.project_name
120
+ import shutil
121
+
122
+ if src_pkg_path.exists():
123
+ # Move children of src/pkg to root
124
+ for item in src_pkg_path.iterdir():
125
+ shutil.move(str(item), str(project_dir))
126
+
127
+ # Cleanup src/pkg and src
128
+ shutil.rmtree(src_pkg_path)
129
+ if (project_dir / "src").exists() and not any((project_dir / "src").iterdir()):
130
+ shutil.rmtree(project_dir / "src")
131
+ self.log("Converted to Ultra-Flat Layout (Code at Root)")
132
+
133
+ # 3. Create extra directories (First, so templates have target dirs)
134
+ self._create_extra_dirs(project_dir, is_subpackage)
135
+
136
+ # 4. Overwrite/Add Files
137
+ self._generate_files(project_dir, is_subpackage)
138
+
139
+ # 5. Git & Final Steps
140
+ console.print(f"\n[bold green]✓ Project {self.raw_name} created successfully![/bold green]")
141
+ if not is_subpackage:
142
+ console.print(f" [dim]cd {self.raw_name} && uv sync[/dim]")
143
+
144
+ def _create_extra_dirs(self, root: Path, is_subpackage: bool = False):
145
+ if is_subpackage:
146
+ # Ultra-Flat Layout: root IS the package root
147
+ pkg_root = root
148
+ else:
149
+ # Standard Layout: root / src / package_name
150
+ pkg_root = root / SRC_DIR / self.project_name
151
+
152
+ # Notebooks for ML/DL (Only for Root Project usually)
153
+ if not is_subpackage and self.type in [TYPE_ML, TYPE_DL]:
154
+ (root / NOTEBOOKS_DIR).mkdir(exist_ok=True)
155
+ self.log("Created notebooks directory")
156
+
157
+ # Tests
158
+ if self.use_tests:
159
+ # For Flat Layout (Subpackage), tests usually go to `tests/` at root
160
+ # For Src Layout (Root), inside `src/pkg/tests`?
161
+ # User request: "create dossier tests ... que ce soit au init general ou pour sous package"
162
+ # User request: "tests/ pour le package principal est dans src/name_package/ tout est isolé"
163
+ # So tests are INSIDE the package.
164
+ tests_dir = pkg_root / TESTS_DIR
165
+ tests_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ with open(tests_dir / "__init__.py", "w") as f:
168
+ pass
169
+ with open(tests_dir / "test_core.py", "w") as f:
170
+ f.write("def test_dummy():\n assert True\n")
171
+ self.log(f"Created tests directory at {tests_dir.relative_to(root)}")
172
+
173
+ def _generate_files(self, root: Path, is_subpackage: bool = False):
174
+ self.log(f"Generating files for {self.project_name}...")
175
+ context = {
176
+ "project_name": self.raw_name,
177
+ "package_name": self.project_name,
178
+ "description": self.description,
179
+ "version": DEFAULT_VERSION,
180
+ "python_version": self.python_version or DEFAULT_PYTHON_VERSION,
181
+ "author_name": self.author,
182
+ "author_email": self.author_email,
183
+ "license": self.license,
184
+ "project_type": self.type,
185
+ "use_uv": self.builder == "uv",
186
+ "has_config": self.use_config,
187
+ "use_readme": self.use_readme,
188
+ "use_env": self.use_env,
189
+ "framework": self.framework,
190
+ }
191
+
192
+ # pyproject.toml (Overwrite uv's basic one to add our specific deps)
193
+ self._render("pyproject.toml.j2", root / "pyproject.toml", context)
194
+
195
+ # Determine Package Root
196
+ if is_subpackage:
197
+ # Ultra-Flat Layout: root IS the package root
198
+ pkg_root = root
199
+ else:
200
+ # Standard Layout: root / src / package_name
201
+ pkg_root = root / SRC_DIR / self.project_name
202
+
203
+ # Ensure pkg root exists
204
+ if not pkg_root.exists():
205
+ # If we messed up logic or uv failed
206
+ pkg_root.mkdir(parents=True, exist_ok=True)
207
+ self.log(f"Created package root {pkg_root}")
208
+
209
+ # __init__.py
210
+ self._render("__init__.py.j2", pkg_root / "__init__.py", context)
211
+
212
+ # README.md
213
+ if self.use_readme:
214
+ self._render("README.md.j2", root / "README.md", context)
215
+ else:
216
+ if (root / "README.md").exists():
217
+ (root / "README.md").unlink()
218
+ self.log("Removed default README.md (requested --no-readme)")
219
+
220
+ # LICENSE
221
+ license_text = LICENSES.get(self.license, LICENSES["MIT"])
222
+ license_text = license_text.format(year=datetime.now().year, author=self.author)
223
+ with open(root / "LICENSE", "w") as f:
224
+ f.write(license_text)
225
+ self.log(f"Generated LICENSE ({self.license})")
226
+
227
+ # Config files
228
+ if self.use_config:
229
+ self._render("config.yaml.j2", pkg_root / "config.yaml", context)
230
+ self._render("config.py.j2", pkg_root / "config.py", context)
231
+
232
+ # Entry points & Logic
233
+ self._render("main.py.j2", pkg_root / "main.py", context)
234
+
235
+ if not is_subpackage and self.type in [TYPE_ML, TYPE_DL]:
236
+ # Render Notebooks
237
+ self._render("Base_Kaggle.ipynb.j2", root / NOTEBOOKS_DIR / "Base_Kaggle.ipynb", context)
238
+ self._render("Base_General.ipynb.j2", root / NOTEBOOKS_DIR / "Base_General.ipynb", context)
239
+ # Render Data Loader
240
+ self._render("data_loader.py.j2", pkg_root / "data_loader.py", context)
241
+ self.log("Generated wrappers: Base_Kaggle.ipynb, Base_General.ipynb, data_loader.py")
242
+
243
+ # .env (Strict Isolation: In pkg_root)
244
+ if self.use_env:
245
+ with open(pkg_root / ".env", "w") as f:
246
+ f.write("# Environment Variables (Isolated)\n")
247
+ with open(pkg_root / ".env.example", "w") as f:
248
+ f.write("# Environment Variables Example\n")
249
+ self.log(f"Created .env and .env.example in {pkg_root.relative_to(root)}")
250
+
251
+ # .gitignore
252
+ with open(root / ".gitignore", "a") as f:
253
+ # Add data/ to gitignore but allow .gitkeep
254
+ f.write("\n# ViperX specific\n.ipynb_checkpoints/\n# Isolated Env\nsrc/**/.env\n# Data (Local)\ndata/*\n!data/.gitkeep\n")
255
+ self.log("Updated .gitignore")
256
+
257
+ def _render(self, template_name: str, target_path: Path, context: dict):
258
+ template = self.env.get_template(template_name)
259
+ content = template.render(**context)
260
+ with open(target_path, "w") as f:
261
+ f.write(content)
262
+ self.log(f"Rendered {target_path.name}")
263
+
264
+ def add_to_workspace(self, workspace_root: Path):
265
+ """Add a new package to an existing workspace."""
266
+ console.print(f"[bold green]Adding package {self.raw_name} to workspace...[/bold green]")
267
+
268
+ pyproject_path = workspace_root / "pyproject.toml"
269
+ if not pyproject_path.exists():
270
+ console.print("[red]Error: Not in a valid project root (pyproject.toml missing).[/red]")
271
+ return
272
+
273
+ # Read pyproject.toml
274
+ with open(pyproject_path, "r") as f:
275
+ content = f.read()
276
+
277
+ # Check if it's already a workspace
278
+ is_workspace = "[tool.uv.workspace]" in content
279
+
280
+ if not is_workspace:
281
+ console.print("[yellow]Upgrading project to Workspace...[/yellow]")
282
+ # Append workspace definition
283
+ with open(pyproject_path, "a") as f:
284
+ f.write(f"\n[tool.uv.workspace]\nmembers = [\"src/{self.raw_name}\"]\n")
285
+ self.log("Added [tool.uv.workspace] section")
286
+ else:
287
+ # Add member to specific list if it exists
288
+ # We use a simple regex approach to find 'members = [...]'
289
+ import re
290
+ members_pattern = r'members\s*=\s*\[(.*?)\]'
291
+ match = re.search(members_pattern, content, re.DOTALL)
292
+
293
+ if match:
294
+ current_members = match.group(1)
295
+ # Check if already present
296
+ if f'"{self.raw_name}"' in current_members or f"'{self.raw_name}'" in current_members:
297
+ self.log(f"Package {self.raw_name} is already in workspace members.")
298
+ else:
299
+ # Append new member
300
+ # We inject it into the list
301
+ self.log("Adding member to existing workspace list")
302
+ # Naively replace the closing bracket
303
+ # Better: parse, but for now robust string insertion
304
+ # Cleanest way without breaking formatting involves finding the last element
305
+ # Cleanest way without breaking formatting involves finding the last element
306
+ new_member = f', "src/{self.raw_name}"'
307
+ # Warning: This regex replace is basic. `uv` handles toml well, maybe we should just edit safely.
308
+ # Let's try to append to the end of the content of the list
309
+ new_content = re.sub(members_pattern, lambda m: f'members = [{m.group(1)}{new_member}]', content, flags=re.DOTALL)
310
+ with open(pyproject_path, "w") as f:
311
+ f.write(new_content)
312
+ else:
313
+ # Section exists but members key might be missing? Or weird formatting.
314
+ # Append to section?
315
+ # Safe fallback
316
+ console.print("[yellow]Warning: Could not parse members list. Adding manually at end.[/yellow]")
317
+ with open(pyproject_path, "a") as f:
318
+ f.write(f"\n# Added by viperx\n[tool.uv.workspace]\nmembers = [\"{self.raw_name}\"]\n")
319
+
320
+ # Generate the package in the root IF it doesn't exist
321
+ pkg_dir = workspace_root / SRC_DIR / self.raw_name
322
+ if pkg_dir.exists():
323
+ self.log(f"Package directory {self.raw_name} exists. Skipping generation.")
324
+ else:
325
+ # Generate as SUBPACKAGE (Flat Layout)
326
+ # We pass workspace_root / SRC_DIR as the target for generation
327
+ # self.generate(target = root/src) -> uv init root/src/pkg -> moves to flat
328
+ self.generate(workspace_root / SRC_DIR, is_subpackage=True)
329
+
330
+ # Post-generation: Ensure root knows about it
331
+ console.print(f"[bold green]✓ Synced {self.raw_name} with workspace.[/bold green]")
332
+ # console.print(f" Run [bold]uv sync[/bold] to link the new package.")
333
+
334
+ def delete_from_workspace(self, workspace_root: Path):
335
+ """Remove a package from the workspace."""
336
+ console.print(f"[bold red]Removing package {self.raw_name} from workspace...[/bold red]")
337
+
338
+ target_dir = workspace_root / SRC_DIR / self.raw_name
339
+ pyproject_path = workspace_root / "pyproject.toml"
340
+
341
+ # 1. Remove directory
342
+ if target_dir.exists():
343
+ import shutil
344
+ shutil.rmtree(target_dir)
345
+ self.log(f"Removed directory {target_dir}")
346
+ else:
347
+ console.print(f"[yellow]Directory {target_dir} not found.[/yellow]")
348
+
349
+ # 2. Update pyproject.toml
350
+ if pyproject_path.exists():
351
+ with open(pyproject_path, "r") as f:
352
+ content = f.read()
353
+
354
+ # Regex to remove member
355
+ # This handles "member", 'member' and checks for commas
356
+ import re
357
+ # Patter matches the member string with optional surrounding whitespace and optional following comma
358
+ # It's tricky to be perfect with regex on TOML logic, but reasonably safe for standard lists
359
+ # We look for the exact string inside the brackets
360
+
361
+ # Simple approach: Load content, replace the specific member string literal with empty
362
+ # But we need to handle commas.
363
+ # Let's try to just remove the string and cleanup commas?
364
+ # Or better: specific regex for the element.
365
+
366
+ member_str_double = f'"src/{self.raw_name}"'
367
+ member_str_single = f"'src/{self.raw_name}'"
368
+
369
+ new_content = content
370
+ if member_str_double in new_content:
371
+ new_content = new_content.replace(member_str_double, "")
372
+ elif member_str_single in new_content:
373
+ new_content = new_content.replace(member_str_single, "")
374
+ else:
375
+ self.log("Member not found in pyproject.toml list string")
376
+ return
377
+
378
+ # Cleanup double commas or trailing commas/empty items inside the list [ , , ]
379
+ # This is "dirty" but works for simple lists
380
+ new_content = re.sub(r'\[\s*,', '[', new_content) # Leading comma
381
+ new_content = re.sub(r',\s*,', ',', new_content) # Double comma
382
+ new_content = re.sub(r',\s*\]', ']', new_content) # Trailing comma
383
+
384
+ with open(pyproject_path, "w") as f:
385
+ f.write(new_content)
386
+ self.log("Removed member from pyproject.toml")
387
+
388
+ console.print(f"[bold green]✓ Removed {self.raw_name} successfully.[/bold green]")
389
+
390
+ def update_package(self, workspace_root: Path):
391
+ """Update a package (dependencies)."""
392
+ console.print(f"[bold blue]Updating package {self.raw_name}...[/bold blue]")
393
+
394
+ target_dir = workspace_root / SRC_DIR / self.raw_name
395
+ if not target_dir.exists():
396
+ console.print(f"[red]Error: Package {self.raw_name} does not exist.[/red]")
397
+ return
398
+
399
+ # Run uv lock --upgrade
400
+ # Actually in a workspace, usually we run sync from root or lock from root?
401
+ # If it's a workspace member, `uv lock` at root updates everything.
402
+ # But maybe we want specific update?
403
+ # Let's run `uv lock --upgrade-package name` if supported or just universal update.
404
+ # For now, let's assume we run `uv lock --upgrade` inside the package or root.
405
+ # Running inside the package dir usually affects the workspace lock if using "workspaces".
406
+
407
+ import subprocess
408
+ try:
409
+ cmd = ["uv", "lock", "--upgrade"]
410
+ self.log(f"Running {' '.join(cmd)}")
411
+ subprocess.run(cmd, cwd=target_dir, check=True)
412
+ console.print(f"[bold green]✓ Updated {self.raw_name} dependencies.[/bold green]")
413
+ except subprocess.CalledProcessError:
414
+ console.print(f"[red]Failed to update {self.raw_name}.[/red]")
415
+
416
+