devit-cli 0.1.5__tar.gz → 0.1.6__tar.gz

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.
Files changed (58) hide show
  1. {devit_cli-0.1.5/devit_cli.egg-info → devit_cli-0.1.6}/PKG-INFO +1 -1
  2. {devit_cli-0.1.5 → devit_cli-0.1.6/devit_cli.egg-info}/PKG-INFO +1 -1
  3. {devit_cli-0.1.5 → devit_cli-0.1.6}/devit_cli.egg-info/SOURCES.txt +1 -0
  4. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/__init__.py +1 -1
  5. devit_cli-0.1.6/devkit_cli/commands/deps.py +732 -0
  6. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/main.py +2 -0
  7. {devit_cli-0.1.5 → devit_cli-0.1.6}/pyproject.toml +1 -1
  8. {devit_cli-0.1.5 → devit_cli-0.1.6}/LICENSE +0 -0
  9. {devit_cli-0.1.5 → devit_cli-0.1.6}/README.md +0 -0
  10. {devit_cli-0.1.5 → devit_cli-0.1.6}/_devkit_entry.py +0 -0
  11. {devit_cli-0.1.5 → devit_cli-0.1.6}/devit_cli.egg-info/dependency_links.txt +0 -0
  12. {devit_cli-0.1.5 → devit_cli-0.1.6}/devit_cli.egg-info/entry_points.txt +0 -0
  13. {devit_cli-0.1.5 → devit_cli-0.1.6}/devit_cli.egg-info/requires.txt +0 -0
  14. {devit_cli-0.1.5 → devit_cli-0.1.6}/devit_cli.egg-info/top_level.txt +0 -0
  15. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/__init__.py +0 -0
  16. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/archive.py +0 -0
  17. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/clean.py +0 -0
  18. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/env.py +0 -0
  19. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/find.py +0 -0
  20. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/info.py +0 -0
  21. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/init.py +0 -0
  22. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/commands/run.py +0 -0
  23. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/.gitignore +0 -0
  24. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/README.md +0 -0
  25. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/requirements.txt +0 -0
  26. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/scripts/__init__.py +0 -0
  27. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/scripts/ec2.py +0 -0
  28. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/scripts/main.py +0 -0
  29. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/scripts/s3.py +0 -0
  30. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/aws/tests/test_scripts.py +0 -0
  31. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/.gitignore +0 -0
  32. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/README.md +0 -0
  33. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/apps/__init__.py +0 -0
  34. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/apps/core/__init__.py +0 -0
  35. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/apps/core/apps.py +0 -0
  36. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/apps/core/urls.py +0 -0
  37. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/apps/core/views.py +0 -0
  38. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/manage.py +0 -0
  39. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/requirements.txt +0 -0
  40. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/{{module_name}}/__init__.py +0 -0
  41. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/{{module_name}}/settings.py +0 -0
  42. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/{{module_name}}/urls.py +0 -0
  43. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/django/{{module_name}}/wsgi.py +0 -0
  44. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/.gitignore +0 -0
  45. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/README.md +0 -0
  46. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/app/__init__.py +0 -0
  47. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/app/routers/__init__.py +0 -0
  48. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/app/routers/health.py +0 -0
  49. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/main.py +0 -0
  50. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/requirements.txt +0 -0
  51. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/fastapi/tests/test_api.py +0 -0
  52. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/.gitignore +0 -0
  53. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/README.md +0 -0
  54. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/pyproject.toml +0 -0
  55. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/tests/test_core.py +0 -0
  56. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/{{module_name}}/__init__.py +0 -0
  57. {devit_cli-0.1.5 → devit_cli-0.1.6}/devkit_cli/templates/package/{{module_name}}/core.py +0 -0
  58. {devit_cli-0.1.5 → devit_cli-0.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devit-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: A full-featured CLI framework for professional Python developers
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/dipenpadhiyar/devit-cli
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devit-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: A full-featured CLI framework for professional Python developers
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/dipenpadhiyar/devit-cli
@@ -13,6 +13,7 @@ devkit_cli/main.py
13
13
  devkit_cli/commands/__init__.py
14
14
  devkit_cli/commands/archive.py
15
15
  devkit_cli/commands/clean.py
16
+ devkit_cli/commands/deps.py
16
17
  devkit_cli/commands/env.py
17
18
  devkit_cli/commands/find.py
18
19
  devkit_cli/commands/info.py
@@ -1,4 +1,4 @@
1
1
  """devkit-cli — A professional developer toolkit for the terminal."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.1.6"
4
4
  __author__ = "devkit-cli contributors"
@@ -0,0 +1,732 @@
1
+ """devit deps — Dependency manager (wraps pip with nice output)."""
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import questionary
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+ from rich.table import Table
16
+
17
+ console = Console()
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Helpers
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _venv_python(root: Path) -> str:
25
+ """Return the venv python for the project, or sys.executable as fallback."""
26
+ venv = root / ".venv"
27
+ if venv.exists():
28
+ win_py = venv / "Scripts" / "python.exe"
29
+ unix_py = venv / "bin" / "python"
30
+ if win_py.exists():
31
+ return str(win_py)
32
+ if unix_py.exists():
33
+ return str(unix_py)
34
+ return sys.executable
35
+
36
+
37
+ def _pip(py: str, args: list[str], capture: bool = False) -> subprocess.CompletedProcess:
38
+ """Run pip via the given interpreter. Returns CompletedProcess."""
39
+ cmd = [py, "-m", "pip"] + args
40
+ try:
41
+ return subprocess.run(cmd, capture_output=capture, text=True)
42
+ except FileNotFoundError:
43
+ console.print(f"[red]✗[/red] Python interpreter not found: [bold]{py}[/bold]")
44
+ raise SystemExit(1)
45
+
46
+
47
+ def _installed_version(py: str, name: str) -> str | None:
48
+ """Return the installed version of a package, or None if not found."""
49
+ r = _pip(py, ["show", name], capture=True)
50
+ if r.returncode != 0:
51
+ return None
52
+ for line in r.stdout.splitlines():
53
+ if line.startswith("Version:"):
54
+ return line.split(":", 1)[1].strip()
55
+ return None
56
+
57
+
58
+ def _bare_name(pkg_spec: str) -> str:
59
+ """Strip any version specifiers/extras from a package spec → bare name."""
60
+ return re.split(r"[=<>!~\[\s]", pkg_spec)[0].strip()
61
+
62
+
63
+ def _detect_req_file(root: Path) -> Path | None:
64
+ req = root / "requirements.txt"
65
+ return req if req.exists() else None
66
+
67
+
68
+ def _req_add(req_file: Path, name: str, version: str) -> None:
69
+ """Add or replace a package pin in requirements.txt."""
70
+ try:
71
+ lines = req_file.read_text(encoding="utf-8").splitlines()
72
+ except OSError as e:
73
+ raise click.ClickException(f"Cannot read {req_file.name}: {e}")
74
+
75
+ pattern = re.compile(r"^" + re.escape(name) + r"([=<>!~\[\s]|$)", re.IGNORECASE)
76
+ new_line = f"{name}=={version}"
77
+ new_lines, updated = [], False
78
+ for line in lines:
79
+ if pattern.match(line.strip()):
80
+ new_lines.append(new_line)
81
+ updated = True
82
+ else:
83
+ new_lines.append(line)
84
+ if not updated:
85
+ new_lines.append(new_line)
86
+
87
+ try:
88
+ req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
89
+ except OSError as e:
90
+ raise click.ClickException(f"Cannot write {req_file.name}: {e}")
91
+
92
+
93
+ def _req_remove(req_file: Path, name: str) -> bool:
94
+ """Remove a package entry from requirements.txt. Returns True if found."""
95
+ try:
96
+ lines = req_file.read_text(encoding="utf-8").splitlines()
97
+ except OSError as e:
98
+ raise click.ClickException(f"Cannot read {req_file.name}: {e}")
99
+
100
+ pattern = re.compile(r"^" + re.escape(name) + r"([=<>!~\[\s]|$)", re.IGNORECASE)
101
+ new_lines = [l for l in lines if not pattern.match(l.strip())]
102
+ if len(new_lines) == len(lines):
103
+ return False
104
+
105
+ try:
106
+ req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
107
+ except OSError as e:
108
+ raise click.ClickException(f"Cannot write {req_file.name}: {e}")
109
+ return True
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # CLI group
114
+ # ---------------------------------------------------------------------------
115
+
116
+ @click.group()
117
+ def deps():
118
+ """Manage project dependencies (wraps pip with nice output)."""
119
+ pass
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # devit deps add
124
+ # ---------------------------------------------------------------------------
125
+
126
+ @deps.command("add")
127
+ @click.argument("packages", nargs=-1, required=True)
128
+ @click.option("--no-save", is_flag=True, default=False,
129
+ help="Install without updating requirements.txt.")
130
+ @click.option("--dir", "project_dir", default=None,
131
+ help="Project directory (default: current directory).")
132
+ def deps_add(packages, no_save, project_dir):
133
+ """
134
+ Install package(s) and save to requirements.txt.
135
+ Use [bold].[/bold] as the package name to upgrade ALL outdated packages at once.
136
+
137
+ \b
138
+ Examples:
139
+ devit deps add requests
140
+ devit deps add "flask>=3.0" sqlalchemy
141
+ devit deps add numpy --no-save
142
+ devit deps add . # upgrade all outdated packages
143
+ """
144
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
145
+ py = _venv_python(root)
146
+
147
+ # ── special case: "devit deps add ." → upgrade all outdated ──────────
148
+ if list(packages) == ["."]:
149
+ console.print(Panel("[bold cyan]devit deps add .[/bold cyan] — Upgrading all outdated packages", expand=False))
150
+
151
+ with Progress(SpinnerColumn(), TextColumn("[dim]Checking for outdated packages...[/dim]"), transient=True) as p:
152
+ p.add_task("")
153
+ r = _pip(py, ["list", "--outdated", "--format=json"], capture=True)
154
+
155
+ if r.returncode != 0:
156
+ console.print("[red]✗[/red] Could not check for outdated packages.")
157
+ raise SystemExit(1)
158
+
159
+ try:
160
+ outdated = json.loads(r.stdout)
161
+ except json.JSONDecodeError:
162
+ console.print("[red]✗[/red] Failed to parse pip output.")
163
+ raise SystemExit(1)
164
+
165
+ if not outdated:
166
+ console.print("[green]✔[/green] All packages are already up to date.")
167
+ return
168
+
169
+ table = Table(title=f"Upgrading {len(outdated)} package(s)", show_lines=False)
170
+ table.add_column("Package", style="cyan")
171
+ table.add_column("Current", style="dim")
172
+ table.add_column("→ Latest", style="bold green")
173
+ for pkg in sorted(outdated, key=lambda p: p["name"].lower()):
174
+ table.add_row(pkg["name"], pkg["version"], pkg["latest_version"])
175
+ console.print(table)
176
+
177
+ names = [pkg["name"] for pkg in outdated]
178
+ result = _pip(py, ["install", "--upgrade"] + names)
179
+ if result.returncode != 0:
180
+ console.print("[red]✗[/red] Upgrade failed.")
181
+ raise SystemExit(result.returncode)
182
+
183
+ if not no_save:
184
+ req_file = _detect_req_file(root)
185
+ if req_file:
186
+ saved = []
187
+ for pkg in outdated:
188
+ new_ver = _installed_version(py, pkg["name"])
189
+ if new_ver:
190
+ try:
191
+ _req_add(req_file, pkg["name"], new_ver)
192
+ saved.append(f"{pkg['name']}=={new_ver}")
193
+ except click.ClickException as e:
194
+ console.print(f"[yellow]⚠[/yellow] {e.format_message()}")
195
+ if saved:
196
+ console.print(
197
+ f"[green]✔[/green] Updated [cyan]{req_file.name}[/cyan] with "
198
+ f"[bold]{len(saved)}[/bold] new version(s)."
199
+ )
200
+
201
+ console.print(f"[green]✔[/green] Upgraded [bold]{len(names)}[/bold] package(s) successfully.")
202
+ return
203
+ # ─────────────────────────────────────────────────────────────────────
204
+
205
+ console.print(Panel("[bold cyan]devit deps add[/bold cyan]", expand=False))
206
+
207
+ # Install via pip (stream output live so user sees progress)
208
+ result = _pip(py, ["install"] + list(packages))
209
+ if result.returncode != 0:
210
+ console.print("[red]✗[/red] Installation failed.")
211
+ raise SystemExit(result.returncode)
212
+
213
+ if no_save:
214
+ return
215
+
216
+ req_file = _detect_req_file(root)
217
+ if not req_file:
218
+ console.print(
219
+ "[dim]No requirements.txt found in this directory — "
220
+ "packages installed but not saved.[/dim]"
221
+ )
222
+ return
223
+
224
+ saved = []
225
+ for pkg in packages:
226
+ name = _bare_name(pkg)
227
+ version = _installed_version(py, name)
228
+ if not version:
229
+ console.print(f"[yellow]⚠[/yellow] Could not determine version for [bold]{name}[/bold] — skipping save.")
230
+ continue
231
+ try:
232
+ _req_add(req_file, name, version)
233
+ saved.append(f"{name}=={version}")
234
+ except click.ClickException as e:
235
+ console.print(f"[yellow]⚠[/yellow] {e.format_message()}")
236
+
237
+ if saved:
238
+ console.print(
239
+ f"[green]✔[/green] Saved to [cyan]{req_file.name}[/cyan]: "
240
+ + ", ".join(f"[bold]{s}[/bold]" for s in saved)
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # devit deps remove
246
+ # ---------------------------------------------------------------------------
247
+
248
+ @deps.command("remove")
249
+ @click.argument("packages", nargs=-1, required=True)
250
+ @click.option("--no-save", is_flag=True, default=False,
251
+ help="Uninstall without updating requirements.txt.")
252
+ @click.option("--dir", "project_dir", default=None,
253
+ help="Project directory (default: current directory).")
254
+ def deps_remove(packages, no_save, project_dir):
255
+ """
256
+ Uninstall package(s) and remove from requirements.txt.
257
+
258
+ \b
259
+ Examples:
260
+ devit deps remove requests
261
+ devit deps remove flask sqlalchemy
262
+ """
263
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
264
+ py = _venv_python(root)
265
+
266
+ console.print(Panel("[bold cyan]devit deps remove[/bold cyan]", expand=False))
267
+
268
+ result = _pip(py, ["uninstall", "-y"] + list(packages))
269
+ if result.returncode != 0:
270
+ console.print("[red]✗[/red] Uninstall failed.")
271
+ raise SystemExit(result.returncode)
272
+
273
+ if no_save:
274
+ return
275
+
276
+ req_file = _detect_req_file(root)
277
+ if not req_file:
278
+ return
279
+
280
+ removed = []
281
+ for pkg in packages:
282
+ name = _bare_name(pkg)
283
+ try:
284
+ found = _req_remove(req_file, name)
285
+ if found:
286
+ removed.append(name)
287
+ except click.ClickException as e:
288
+ console.print(f"[yellow]⚠[/yellow] {e.format_message()}")
289
+
290
+ if removed:
291
+ console.print(
292
+ f"[green]✔[/green] Removed from [cyan]{req_file.name}[/cyan]: "
293
+ + ", ".join(f"[bold]{n}[/bold]" for n in removed)
294
+ )
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # devit deps list
299
+ # ---------------------------------------------------------------------------
300
+
301
+ @deps.command("list")
302
+ @click.option("--dir", "project_dir", default=None,
303
+ help="Project directory (default: current directory).")
304
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
305
+ def deps_list(project_dir, as_json):
306
+ """
307
+ List all packages installed in the project environment.
308
+
309
+ \b
310
+ Examples:
311
+ devit deps list
312
+ devit deps list --json
313
+ """
314
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
315
+ py = _venv_python(root)
316
+
317
+ result = _pip(py, ["list", "--format=json"], capture=True)
318
+ if result.returncode != 0:
319
+ console.print("[red]✗[/red] Could not retrieve package list.")
320
+ raise SystemExit(1)
321
+
322
+ try:
323
+ packages = json.loads(result.stdout)
324
+ except json.JSONDecodeError:
325
+ console.print("[red]✗[/red] Failed to parse pip output.")
326
+ raise SystemExit(1)
327
+
328
+ if as_json:
329
+ click.echo(json.dumps(packages, indent=2))
330
+ return
331
+
332
+ table = Table(title=f"Installed Packages ({len(packages)})", show_lines=False)
333
+ table.add_column("Package", style="cyan")
334
+ table.add_column("Version", style="bold green")
335
+
336
+ for pkg in sorted(packages, key=lambda p: p["name"].lower()):
337
+ table.add_row(pkg["name"], pkg["version"])
338
+
339
+ console.print(table)
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # devit deps outdated
344
+ # ---------------------------------------------------------------------------
345
+
346
+ @deps.command("outdated")
347
+ @click.option("--dir", "project_dir", default=None,
348
+ help="Project directory (default: current directory).")
349
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
350
+ def deps_outdated(project_dir, as_json):
351
+ """
352
+ Show packages that have newer versions available.
353
+
354
+ \b
355
+ Examples:
356
+ devit deps outdated
357
+ devit deps outdated --json
358
+ """
359
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
360
+ py = _venv_python(root)
361
+
362
+ with Progress(
363
+ SpinnerColumn(),
364
+ TextColumn("[dim]Checking for updates...[/dim]"),
365
+ transient=True,
366
+ ) as p:
367
+ p.add_task("")
368
+ result = _pip(py, ["list", "--outdated", "--format=json"], capture=True)
369
+
370
+ if result.returncode != 0:
371
+ console.print("[red]✗[/red] Could not check for updates.")
372
+ raise SystemExit(1)
373
+
374
+ try:
375
+ packages = json.loads(result.stdout)
376
+ except json.JSONDecodeError:
377
+ console.print("[red]✗[/red] Failed to parse pip output.")
378
+ raise SystemExit(1)
379
+
380
+ if not packages:
381
+ console.print("[green]✔[/green] All packages are up to date.")
382
+ return
383
+
384
+ if as_json:
385
+ click.echo(json.dumps(packages, indent=2))
386
+ return
387
+
388
+ table = Table(title=f"Outdated Packages ({len(packages)})", show_lines=False)
389
+ table.add_column("Package", style="cyan")
390
+ table.add_column("Installed", style="dim")
391
+ table.add_column("Latest", style="bold green")
392
+ table.add_column("Type", style="dim")
393
+
394
+ for pkg in sorted(packages, key=lambda p: p["name"].lower()):
395
+ table.add_row(
396
+ pkg["name"],
397
+ pkg["version"],
398
+ pkg["latest_version"],
399
+ pkg.get("latest_filetype", "wheel"),
400
+ )
401
+
402
+ console.print(table)
403
+ console.print(
404
+ f"\n[dim]Run [bold]devit deps add <name>[/bold] to upgrade one "
405
+ f"or [bold]devit deps add .[/bold] to upgrade all.[/dim]"
406
+ )
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Snapshot storage helpers
411
+ # ---------------------------------------------------------------------------
412
+
413
+ _SNAPSHOTS_FILE = Path(".devit") / "dep_snapshots.json"
414
+
415
+
416
+ def _snapshots_path(root: Path) -> Path:
417
+ return root / _SNAPSHOTS_FILE
418
+
419
+
420
+ def _load_snapshots(root: Path) -> list[dict]:
421
+ path = _snapshots_path(root)
422
+ if not path.exists():
423
+ return []
424
+ try:
425
+ data = json.loads(path.read_text(encoding="utf-8"))
426
+ if not isinstance(data, list):
427
+ raise ValueError("expected a list")
428
+ return data
429
+ except (json.JSONDecodeError, ValueError):
430
+ console.print(
431
+ f"[yellow]⚠[/yellow] Snapshot file is corrupted ([dim]{path}[/dim]) — "
432
+ "existing history could not be loaded."
433
+ )
434
+ return []
435
+ except OSError as e:
436
+ console.print(f"[yellow]⚠[/yellow] Could not read snapshot file: {e}")
437
+ return []
438
+
439
+
440
+ def _save_snapshots(root: Path, snapshots: list[dict]) -> None:
441
+ path = _snapshots_path(root)
442
+ try:
443
+ path.parent.mkdir(parents=True, exist_ok=True)
444
+ path.write_text(json.dumps(snapshots, indent=2), encoding="utf-8")
445
+ except OSError as e:
446
+ raise click.ClickException(f"Cannot save snapshots: {e}")
447
+
448
+
449
+ def _get_current_packages(py: str) -> list[dict]:
450
+ result = _pip(py, ["list", "--format=json"], capture=True)
451
+ if result.returncode != 0:
452
+ console.print("[red]\u2717[/red] Could not retrieve package list.")
453
+ raise SystemExit(1)
454
+ try:
455
+ return json.loads(result.stdout)
456
+ except json.JSONDecodeError:
457
+ console.print("[red]\u2717[/red] Failed to parse pip output.")
458
+ raise SystemExit(1)
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # devit deps snapshot
463
+ # ---------------------------------------------------------------------------
464
+
465
+ @deps.command("snapshot")
466
+ @click.option("--message", "-m", default=None,
467
+ help="Label for this snapshot (e.g. 'working baseline').")
468
+ @click.option("--dir", "project_dir", default=None,
469
+ help="Project directory (default: current directory).")
470
+ def deps_snapshot(message, project_dir):
471
+ """
472
+ Save a snapshot of the current dependency state.
473
+
474
+ \b
475
+ Examples:
476
+ devit deps snapshot
477
+ devit deps snapshot -m "working with flask 3.0"
478
+ """
479
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
480
+ py = _venv_python(root)
481
+
482
+ packages = _get_current_packages(py)
483
+ snapshots = _load_snapshots(root)
484
+
485
+ snap_id = (max(s["id"] for s in snapshots) + 1) if snapshots else 1
486
+ timestamp = datetime.now().isoformat(timespec="seconds")
487
+ label = message or f"Snapshot #{snap_id}"
488
+
489
+ snapshots.append({
490
+ "id": snap_id,
491
+ "message": label,
492
+ "timestamp": timestamp,
493
+ "packages": [{"name": p["name"], "version": p["version"]} for p in packages],
494
+ })
495
+ try:
496
+ _save_snapshots(root, snapshots)
497
+ except click.ClickException as e:
498
+ console.print(f"[red]\u2717[/red] {e.format_message()}")
499
+ raise SystemExit(1)
500
+
501
+ console.print(
502
+ f"[green]\u2714[/green] Snapshot [bold]#{snap_id}[/bold] saved — "
503
+ f"[cyan]{label}[/cyan] [dim]({len(packages)} packages · {timestamp})[/dim]"
504
+ )
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # devit deps history
509
+ # ---------------------------------------------------------------------------
510
+
511
+ @deps.command("history")
512
+ @click.option("--dir", "project_dir", default=None,
513
+ help="Project directory (default: current directory).")
514
+ def deps_history(project_dir):
515
+ """
516
+ List all saved dependency snapshots.
517
+
518
+ \b
519
+ Example:
520
+ devit deps history
521
+ """
522
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
523
+ snapshots = _load_snapshots(root)
524
+
525
+ if not snapshots:
526
+ console.print(
527
+ "[yellow]No snapshots yet.[/yellow] "
528
+ "Run [bold]devit deps snapshot[/bold] to save one."
529
+ )
530
+ return
531
+
532
+ table = Table(title=f"Dependency Snapshots ({len(snapshots)})", show_lines=False)
533
+ table.add_column("ID", style="dim", width=5)
534
+ table.add_column("Message", style="cyan")
535
+ table.add_column("Packages", justify="right", style="bold green", width=10)
536
+ table.add_column("Saved At", style="dim")
537
+
538
+ for s in reversed(snapshots):
539
+ table.add_row(
540
+ f"#{s['id']}",
541
+ s["message"],
542
+ str(len(s["packages"])),
543
+ s["timestamp"],
544
+ )
545
+
546
+ console.print(table)
547
+ console.print(
548
+ f"[dim] devit deps diff <ID> — compare to current env\n"
549
+ f" devit deps rollback <ID> — restore a snapshot[/dim]"
550
+ )
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # devit deps diff
555
+ # ---------------------------------------------------------------------------
556
+
557
+ @deps.command("diff")
558
+ @click.argument("snapshot_id", type=int, required=False)
559
+ @click.option("--dir", "project_dir", default=None,
560
+ help="Project directory (default: current directory).")
561
+ def deps_diff(snapshot_id, project_dir):
562
+ """
563
+ Diff current environment against a snapshot. Shows what changed and flags issues.
564
+
565
+ \b
566
+ Examples:
567
+ devit deps diff # compare to latest snapshot
568
+ devit deps diff 2 # compare to snapshot #2
569
+ """
570
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
571
+ py = _venv_python(root)
572
+ snapshots = _load_snapshots(root)
573
+
574
+ if not snapshots:
575
+ console.print(
576
+ "[yellow]No snapshots found.[/yellow] "
577
+ "Run [bold]devit deps snapshot[/bold] first."
578
+ )
579
+ return
580
+
581
+ if snapshot_id is None:
582
+ snap = snapshots[-1]
583
+ else:
584
+ snap = next((s for s in snapshots if s["id"] == snapshot_id), None)
585
+ if not snap:
586
+ console.print(f"[red]\u2717[/red] Snapshot [bold]#{snapshot_id}[/bold] not found. "
587
+ f"Run [bold]devit deps history[/bold] to see available IDs.")
588
+ raise SystemExit(1)
589
+
590
+ current_pkgs = {p["name"].lower(): p["version"] for p in _get_current_packages(py)}
591
+ snap_pkgs = {p["name"].lower(): p["version"] for p in snap["packages"]}
592
+
593
+ all_names = sorted(set(current_pkgs) | set(snap_pkgs))
594
+
595
+ issues: list[str] = []
596
+ rows: list[tuple] = []
597
+ for name in all_names:
598
+ cur = current_pkgs.get(name)
599
+ old = snap_pkgs.get(name)
600
+ if cur == old:
601
+ continue
602
+ if old is None:
603
+ rows.append((name, "\u2014", cur, "[green]+added[/green]"))
604
+ elif cur is None:
605
+ rows.append((name, old, "\u2014", "[red]-removed[/red]"))
606
+ issues.append(f"[bold]{name}[/bold] was removed (snapshot had {old})")
607
+ else:
608
+ rows.append((name, old, cur, "[yellow]~changed[/yellow]"))
609
+ issues.append(f"[bold]{name}[/bold] version changed {old} \u2192 {cur}")
610
+
611
+ if not rows:
612
+ console.print(
613
+ f"[green]\u2714[/green] Environment matches snapshot "
614
+ f"[bold]#{snap['id']}[/bold] [dim]{snap['message']}[/dim]"
615
+ )
616
+ return
617
+
618
+ table = Table(
619
+ title=f"Diff: current vs Snapshot #{snap['id']} \u00b7 {snap['message']} [{snap['timestamp']}]",
620
+ show_lines=False,
621
+ )
622
+ table.add_column("Package", style="cyan")
623
+ table.add_column("Snapshot", style="dim")
624
+ table.add_column("Current", style="bold")
625
+ table.add_column("Status", style="bold")
626
+
627
+ for name, old, cur, status in rows:
628
+ table.add_row(name, old, cur, status)
629
+
630
+ console.print(table)
631
+
632
+ if issues:
633
+ console.print()
634
+ console.print("[red bold]Issues detected:[/red bold]")
635
+ for issue in issues:
636
+ console.print(f" [red]\u2022[/red] {issue}")
637
+ console.print()
638
+ console.print(
639
+ f" [dim]Run [bold]devit deps rollback {snap['id']}[/bold] "
640
+ f"to restore this snapshot.[/dim]"
641
+ )
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # devit deps rollback
646
+ # ---------------------------------------------------------------------------
647
+
648
+ @deps.command("rollback")
649
+ @click.argument("snapshot_id", type=int, required=False)
650
+ @click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
651
+ @click.option("--dir", "project_dir", default=None,
652
+ help="Project directory (default: current directory).")
653
+ def deps_rollback(snapshot_id, yes, project_dir):
654
+ """
655
+ Reinstall exact package versions from a saved snapshot.
656
+
657
+ \b
658
+ Examples:
659
+ devit deps rollback # rollback to latest snapshot
660
+ devit deps rollback 2 # rollback to snapshot #2
661
+ devit deps rollback 2 --yes
662
+ """
663
+ root = Path(project_dir).resolve() if project_dir else Path.cwd()
664
+ py = _venv_python(root)
665
+ snapshots = _load_snapshots(root)
666
+
667
+ if not snapshots:
668
+ console.print("[yellow]No snapshots found.[/yellow]")
669
+ return
670
+
671
+ if snapshot_id is None:
672
+ snap = snapshots[-1]
673
+ else:
674
+ snap = next((s for s in snapshots if s["id"] == snapshot_id), None)
675
+ if not snap:
676
+ console.print(f"[red]\u2717[/red] Snapshot [bold]#{snapshot_id}[/bold] not found. "
677
+ f"Run [bold]devit deps history[/bold] to see available IDs.")
678
+ raise SystemExit(1)
679
+
680
+ # Show what will change before asking
681
+ current_pkgs = {p["name"].lower(): p["version"] for p in _get_current_packages(py)}
682
+ snap_pkgs = {p["name"].lower(): p["version"] for p in snap["packages"]}
683
+
684
+ diffs = [
685
+ (name, current_pkgs.get(name, "\u2014"), ver)
686
+ for name, ver in snap_pkgs.items()
687
+ if current_pkgs.get(name) != ver
688
+ ]
689
+
690
+ console.print(
691
+ f"Rolling back to snapshot [bold]#{snap['id']}[/bold] — "
692
+ f"[cyan]{snap['message']}[/cyan] [dim]{snap['timestamp']}[/dim]"
693
+ )
694
+
695
+ if diffs:
696
+ table = Table(title=f"Changes that will be applied ({len(diffs)})", show_lines=False)
697
+ table.add_column("Package", style="cyan")
698
+ table.add_column("Current", style="dim")
699
+ table.add_column("\u2192 Restore", style="bold green")
700
+ for name, cur, restore in diffs:
701
+ table.add_row(name, cur, restore)
702
+ console.print(table)
703
+ else:
704
+ console.print("[green]\u2714[/green] Environment already matches this snapshot — nothing to do.")
705
+ return
706
+
707
+ if not yes:
708
+ ok = questionary.confirm(
709
+ "Reinstall these exact versions?", default=True
710
+ ).ask()
711
+ if not ok:
712
+ console.print("[yellow]Aborted.[/yellow]")
713
+ raise click.Abort()
714
+
715
+ pins = [f"{p['name']}=={p['version']}" for p in snap["packages"]]
716
+
717
+ if not pins:
718
+ console.print("[yellow]⚠[/yellow] Snapshot has no packages — nothing to install.")
719
+ return
720
+
721
+ console.print(f"[dim]Installing {len(pins)} pinned package(s)...[/dim]")
722
+ # Run pip OUTSIDE any Progress/spinner so its live output is not garbled
723
+ result = _pip(py, ["install"] + pins)
724
+
725
+ if result.returncode != 0:
726
+ console.print("[red]✗[/red] Rollback failed — see errors above.")
727
+ raise SystemExit(result.returncode)
728
+
729
+ console.print(
730
+ f"[green]\u2714[/green] Rolled back to snapshot [bold]#{snap['id']}[/bold] successfully.\n"
731
+ f" [dim]Restored [bold]{len(pins)}[/bold] packages to exact pinned versions.[/dim]"
732
+ )
@@ -16,6 +16,7 @@ from devkit_cli.commands.find import find
16
16
  from devkit_cli.commands.archive import zip_cmd, unzip_cmd
17
17
  from devkit_cli.commands.env import env
18
18
  from devkit_cli.commands.run import run, build, dev, test
19
+ from devkit_cli.commands.deps import deps
19
20
 
20
21
  console = Console()
21
22
 
@@ -83,6 +84,7 @@ cli.add_command(run)
83
84
  cli.add_command(build)
84
85
  cli.add_command(dev)
85
86
  cli.add_command(test)
87
+ cli.add_command(deps)
86
88
 
87
89
 
88
90
  if __name__ == "__main__":
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devit-cli"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "A full-featured CLI framework for professional Python developers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes