qtmp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qtmp/__init__.py +1 -0
- qtmp/cheatsheet_manager.py +75 -0
- qtmp/cheatsheets/fastapi.md +24 -0
- qtmp/cheatsheets/react.md +22 -0
- qtmp/cli.py +234 -0
- qtmp/context.py +94 -0
- qtmp/doctor.py +51 -0
- qtmp/executor.py +98 -0
- qtmp/planner.py +84 -0
- qtmp/recipes/__init__.py +51 -0
- qtmp/recipes/base.py +62 -0
- qtmp/recipes/database.py +112 -0
- qtmp/recipes/fastapi.py +92 -0
- qtmp/recipes/react.py +59 -0
- qtmp/recipes/router_axios.py +69 -0
- qtmp/recipes/tailwind.py +69 -0
- qtmp/utils.py +180 -0
- qtmp-0.2.0.dist-info/METADATA +10 -0
- qtmp-0.2.0.dist-info/RECORD +22 -0
- qtmp-0.2.0.dist-info/WHEEL +5 -0
- qtmp-0.2.0.dist-info/entry_points.txt +2 -0
- qtmp-0.2.0.dist-info/top_level.txt +1 -0
qtmp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.cheatsheet_manager
|
|
3
|
+
-------------------------------
|
|
4
|
+
Two sources of cheatsheets:
|
|
5
|
+
|
|
6
|
+
1. Bundled -> qtmp/cheatsheets/*.md (shipped with the package)
|
|
7
|
+
2. Personal -> ~/.qtmp/cheatsheets/*.md (your own handmade notes)
|
|
8
|
+
|
|
9
|
+
Personal cheatsheets always take priority: if you drop a `react.md` in
|
|
10
|
+
your personal folder, it overrides the bundled one — so you can keep
|
|
11
|
+
your own handwritten reference without losing it on an upgrade.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
BUNDLED_DIR = os.path.join(os.path.dirname(__file__), "cheatsheets")
|
|
18
|
+
PERSONAL_DIR = os.path.join(os.path.expanduser("~"), ".qtmp", "cheatsheets")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ensure_personal_dir():
|
|
22
|
+
os.makedirs(PERSONAL_DIR, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_cheatsheets():
|
|
26
|
+
"""Return {name: (path, source)} merged, personal overriding bundled."""
|
|
27
|
+
_ensure_personal_dir()
|
|
28
|
+
sheets = {}
|
|
29
|
+
|
|
30
|
+
if os.path.isdir(BUNDLED_DIR):
|
|
31
|
+
for fname in sorted(os.listdir(BUNDLED_DIR)):
|
|
32
|
+
if fname.endswith(".md"):
|
|
33
|
+
name = fname[:-3]
|
|
34
|
+
sheets[name] = (os.path.join(BUNDLED_DIR, fname), "bundled")
|
|
35
|
+
|
|
36
|
+
for fname in sorted(os.listdir(PERSONAL_DIR)):
|
|
37
|
+
if fname.endswith(".md"):
|
|
38
|
+
name = fname[:-3]
|
|
39
|
+
sheets[name] = (os.path.join(PERSONAL_DIR, fname), "personal")
|
|
40
|
+
|
|
41
|
+
return sheets
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def view(name: str) -> str:
|
|
45
|
+
sheets = list_cheatsheets()
|
|
46
|
+
if name not in sheets:
|
|
47
|
+
return None
|
|
48
|
+
path, _source = sheets[name]
|
|
49
|
+
with open(path, "r") as f:
|
|
50
|
+
return f.read()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add(name: str, source_path: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Copy a user's own handmade cheatsheet file into the personal folder,
|
|
56
|
+
so it survives package upgrades and overrides any bundled sheet of
|
|
57
|
+
the same name.
|
|
58
|
+
"""
|
|
59
|
+
_ensure_personal_dir()
|
|
60
|
+
source_path = os.path.expanduser(source_path)
|
|
61
|
+
if not os.path.isfile(source_path):
|
|
62
|
+
raise FileNotFoundError(source_path)
|
|
63
|
+
|
|
64
|
+
dest = os.path.join(PERSONAL_DIR, f"{name}.md")
|
|
65
|
+
shutil.copyfile(source_path, dest)
|
|
66
|
+
return dest
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove(name: str) -> bool:
|
|
70
|
+
"""Remove a personal cheatsheet override (bundled ones can't be removed)."""
|
|
71
|
+
path = os.path.join(PERSONAL_DIR, f"{name}.md")
|
|
72
|
+
if os.path.isfile(path):
|
|
73
|
+
os.remove(path)
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# FastAPI Cheatsheet
|
|
2
|
+
|
|
3
|
+
## Dev commands
|
|
4
|
+
uvicorn main:app --reload start dev server (localhost:8000)
|
|
5
|
+
|
|
6
|
+
## Built-in docs
|
|
7
|
+
/docs Swagger UI
|
|
8
|
+
/redoc ReDoc
|
|
9
|
+
|
|
10
|
+
## Minimal app
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
app = FastAPI()
|
|
13
|
+
|
|
14
|
+
@app.get("/")
|
|
15
|
+
def read_root():
|
|
16
|
+
return {"message": "hello"}
|
|
17
|
+
|
|
18
|
+
## PostgreSQL (via SQLAlchemy)
|
|
19
|
+
DATABASE_URL = "postgresql://user:pass@localhost:5432/dbname"
|
|
20
|
+
|
|
21
|
+
## Gotchas
|
|
22
|
+
- `uvicorn[standard]` pulls in the faster event loop + websockets support;
|
|
23
|
+
plain `uvicorn` is a lighter install if you don't need those.
|
|
24
|
+
- `--reload` is for development only — never use it in production.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# React + Vite Cheatsheet
|
|
2
|
+
|
|
3
|
+
## Dev commands
|
|
4
|
+
npm run dev start dev server
|
|
5
|
+
npm run build production build
|
|
6
|
+
npm run preview preview the build
|
|
7
|
+
|
|
8
|
+
## Common add-ons
|
|
9
|
+
npm install react-router-dom # routing
|
|
10
|
+
npm install axios # HTTP client
|
|
11
|
+
npx shadcn@latest add button # add a shadcn component
|
|
12
|
+
|
|
13
|
+
## File structure (default Vite scaffold)
|
|
14
|
+
src/
|
|
15
|
+
main.jsx entry point, mounts <App />
|
|
16
|
+
App.jsx root component
|
|
17
|
+
index.html Vite's HTML entry (not public/index.html like CRA)
|
|
18
|
+
|
|
19
|
+
## Gotchas
|
|
20
|
+
- Env vars must be prefixed `VITE_` to be exposed to the client
|
|
21
|
+
(e.g. `VITE_API_URL`, accessed via `import.meta.env.VITE_API_URL`).
|
|
22
|
+
- Vite dev server default port: 5173 (not 3000).
|
qtmp/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.cli
|
|
3
|
+
---------
|
|
4
|
+
Real subcommands, not a menu loop:
|
|
5
|
+
|
|
6
|
+
qtmp create react
|
|
7
|
+
qtmp create fastapi
|
|
8
|
+
qtmp add tailwind
|
|
9
|
+
qtmp add sqlalchemy
|
|
10
|
+
qtmp plan add sqlalchemy # dry run: show what would happen, change nothing
|
|
11
|
+
qtmp doctor
|
|
12
|
+
qtmp list
|
|
13
|
+
qtmp cheatsheet [name]
|
|
14
|
+
|
|
15
|
+
Every `add`/`plan add` goes through the same dependency-resolution
|
|
16
|
+
path (qtmp.planner.resolve_plan), so the dry run and the real run can
|
|
17
|
+
never drift apart from each other.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from qtmp import __version__
|
|
25
|
+
from qtmp.context import ProjectContext
|
|
26
|
+
from qtmp.recipes import REGISTRY, CREATABLE, resolve_name
|
|
27
|
+
from qtmp.planner import resolve_plan, ResolutionError
|
|
28
|
+
from qtmp.executor import apply as execute_plan, report as report_result
|
|
29
|
+
from qtmp.doctor import run_doctor
|
|
30
|
+
from qtmp.utils import banner, info, warn, ok, err, print_logo, C
|
|
31
|
+
from qtmp import cheatsheet_manager as cheats
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_create(args):
|
|
35
|
+
key = resolve_name(args.recipe)
|
|
36
|
+
recipe = CREATABLE.get(key)
|
|
37
|
+
if recipe is None:
|
|
38
|
+
available = ", ".join(sorted(CREATABLE.keys()))
|
|
39
|
+
err(f"'{args.recipe}' isn't creatable. Available: {available}")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
ctx = ProjectContext.detect(args.cwd)
|
|
43
|
+
banner(f"Create: {recipe.name}")
|
|
44
|
+
project_dir, advanced = recipe.create(ctx)
|
|
45
|
+
|
|
46
|
+
if key == "react" and advanced:
|
|
47
|
+
sub_ctx = ProjectContext.detect(project_dir)
|
|
48
|
+
info("Advanced preset: adding Tailwind, shadcn/ui, Router, Axios automatically.")
|
|
49
|
+
for capability in ("tailwind", "shadcn", "router", "axios"):
|
|
50
|
+
_add_capability(capability, sub_ctx)
|
|
51
|
+
elif key == "react":
|
|
52
|
+
sub_ctx = ProjectContext.detect(project_dir)
|
|
53
|
+
info("Beginner preset: adding Tailwind automatically.")
|
|
54
|
+
_add_capability("tailwind", sub_ctx)
|
|
55
|
+
|
|
56
|
+
ok(f"Done. cd {project_dir}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_add(args):
|
|
60
|
+
ctx = ProjectContext.detect(args.cwd)
|
|
61
|
+
key = resolve_name(args.capability)
|
|
62
|
+
_add_capability(key, ctx)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_plan(args):
|
|
66
|
+
ctx = ProjectContext.detect(args.cwd)
|
|
67
|
+
key = resolve_name(args.capability)
|
|
68
|
+
_print_plan(key, ctx)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _add_capability(key: str, ctx):
|
|
72
|
+
try:
|
|
73
|
+
plan = resolve_plan(key, ctx, REGISTRY)
|
|
74
|
+
except ResolutionError as e:
|
|
75
|
+
err(str(e))
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
banner(f"add: {key}")
|
|
79
|
+
if plan.is_noop():
|
|
80
|
+
for step in plan.steps:
|
|
81
|
+
ok(step.description)
|
|
82
|
+
info("Nothing to do.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
for step in plan.pending_steps:
|
|
86
|
+
print(f" {C.DIM}->{C.RESET} {step.description}")
|
|
87
|
+
|
|
88
|
+
result = execute_plan(plan)
|
|
89
|
+
report_result(result, key)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _print_plan(key: str, ctx):
|
|
93
|
+
try:
|
|
94
|
+
plan = resolve_plan(key, ctx, REGISTRY)
|
|
95
|
+
except ResolutionError as e:
|
|
96
|
+
err(str(e))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
banner(f"plan: {key}")
|
|
100
|
+
if plan.is_noop():
|
|
101
|
+
for step in plan.steps:
|
|
102
|
+
ok(f"{step.description} (already satisfied)")
|
|
103
|
+
info("Nothing to do — running 'qtmp add' now would be a no-op.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for step in plan.steps:
|
|
107
|
+
if step.already_satisfied:
|
|
108
|
+
print(f" {C.DIM}✓ {step.description} (already satisfied){C.RESET}")
|
|
109
|
+
else:
|
|
110
|
+
cmd_preview = " ".join(step.command) if step.command else "(custom action)"
|
|
111
|
+
print(f" {C.B_YELLOW}→{C.RESET} {step.description}")
|
|
112
|
+
print(f" {C.DIM}$ {cmd_preview}{C.RESET}")
|
|
113
|
+
|
|
114
|
+
info("Dry run only — nothing was changed. Run 'qtmp add "
|
|
115
|
+
f"{key}' to execute this plan.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_doctor(args):
|
|
119
|
+
ctx = ProjectContext.detect(args.cwd)
|
|
120
|
+
run_doctor(ctx)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_list(args):
|
|
124
|
+
banner("Available capabilities")
|
|
125
|
+
for cap, recipe in sorted(REGISTRY.items()):
|
|
126
|
+
tag = f"{C.DIM}(creatable){C.RESET}" if recipe.creatable else ""
|
|
127
|
+
print(f" {C.CYAN}{cap}{C.RESET} {tag}")
|
|
128
|
+
info("qtmp add <capability> | qtmp create <capability> | qtmp plan add <capability>")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_cheatsheet(args):
|
|
132
|
+
extra = args.extra or []
|
|
133
|
+
if not extra:
|
|
134
|
+
sheets = cheats.list_cheatsheets()
|
|
135
|
+
if not sheets:
|
|
136
|
+
info("No cheatsheets yet. Add one with: qtmp cheatsheet add <name> <path/to/file.md>")
|
|
137
|
+
return
|
|
138
|
+
banner("Cheatsheets")
|
|
139
|
+
for name, (_path, source) in sheets.items():
|
|
140
|
+
tag = f"{C.DIM}(yours){C.RESET}" if source == "personal" else f"{C.DIM}(built-in){C.RESET}"
|
|
141
|
+
print(f" {C.CYAN}{name}{C.RESET} {tag}")
|
|
142
|
+
info("View one with: qtmp cheatsheet <name>")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if extra[0] == "add":
|
|
146
|
+
if len(extra) < 3:
|
|
147
|
+
warn("Usage: qtmp cheatsheet add <name> <path/to/your-notes.md>")
|
|
148
|
+
return
|
|
149
|
+
name, path = extra[1], extra[2]
|
|
150
|
+
try:
|
|
151
|
+
dest = cheats.add(name, path)
|
|
152
|
+
ok(f"Saved your cheatsheet as '{name}' -> {dest}")
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
warn(f"File not found: {path}")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if extra[0] == "remove":
|
|
158
|
+
if len(extra) < 2:
|
|
159
|
+
warn("Usage: qtmp cheatsheet remove <name>")
|
|
160
|
+
return
|
|
161
|
+
removed = cheats.remove(extra[1])
|
|
162
|
+
if removed:
|
|
163
|
+
ok(f"Removed your custom cheatsheet '{extra[1]}'.")
|
|
164
|
+
else:
|
|
165
|
+
warn(f"No personal cheatsheet named '{extra[1]}' to remove "
|
|
166
|
+
"(built-in ones can't be removed, only overridden).")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
name = extra[0]
|
|
170
|
+
content = cheats.view(name)
|
|
171
|
+
if content is None:
|
|
172
|
+
warn(f"No cheatsheet named '{name}'. Run 'qtmp cheatsheet' to see what's available.")
|
|
173
|
+
return
|
|
174
|
+
print(f"\n{content}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_parser():
|
|
178
|
+
parser = argparse.ArgumentParser(
|
|
179
|
+
prog="qtmp",
|
|
180
|
+
description="Developer Environment Automation CLI — detects your project, "
|
|
181
|
+
"resolves dependencies, and executes package-manager commands so you don't "
|
|
182
|
+
"have to hunt through docs.",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument("--version", action="version", version=f"qtmp {__version__}")
|
|
185
|
+
parser.add_argument("--cwd", default=os.getcwd(), help="Directory to operate in.")
|
|
186
|
+
|
|
187
|
+
sub = parser.add_subparsers(dest="command")
|
|
188
|
+
|
|
189
|
+
p_create = sub.add_parser("create", help="Scaffold a brand-new project (react, fastapi).")
|
|
190
|
+
p_create.add_argument("recipe")
|
|
191
|
+
p_create.set_defaults(func=cmd_create)
|
|
192
|
+
|
|
193
|
+
p_add = sub.add_parser("add", help="Add a capability, resolving dependencies, and run it.")
|
|
194
|
+
p_add.add_argument("capability")
|
|
195
|
+
p_add.set_defaults(func=cmd_add)
|
|
196
|
+
|
|
197
|
+
p_plan = sub.add_parser("plan", help="Dry-run: show what 'add' would do without doing it.")
|
|
198
|
+
plan_sub = p_plan.add_subparsers(dest="plan_command")
|
|
199
|
+
p_plan_add = plan_sub.add_parser("add")
|
|
200
|
+
p_plan_add.add_argument("capability")
|
|
201
|
+
p_plan_add.set_defaults(func=cmd_plan)
|
|
202
|
+
|
|
203
|
+
p_doctor = sub.add_parser("doctor", help="Diagnose the environment and this project.")
|
|
204
|
+
p_doctor.set_defaults(func=cmd_doctor)
|
|
205
|
+
|
|
206
|
+
p_list = sub.add_parser("list", help="List all available capabilities.")
|
|
207
|
+
p_list.set_defaults(func=cmd_list)
|
|
208
|
+
|
|
209
|
+
p_cheat = sub.add_parser("cheatsheet", help="View or manage cheatsheets.")
|
|
210
|
+
p_cheat.add_argument("extra", nargs="*")
|
|
211
|
+
p_cheat.set_defaults(func=cmd_cheatsheet)
|
|
212
|
+
|
|
213
|
+
return parser
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
parser = build_parser()
|
|
218
|
+
args = parser.parse_args()
|
|
219
|
+
args.cwd = os.path.abspath(args.cwd)
|
|
220
|
+
|
|
221
|
+
if not getattr(args, "command", None):
|
|
222
|
+
print_logo()
|
|
223
|
+
parser.print_help()
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
if args.command == "plan" and getattr(args, "plan_command", None) != "add":
|
|
227
|
+
parser.parse_args(["plan", "--help"])
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
args.func(args)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
qtmp/context.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.context
|
|
3
|
+
-------------
|
|
4
|
+
Detects what kind of project directory we're standing in, once per run.
|
|
5
|
+
Every recipe reads from this instead of re-implementing its own file
|
|
6
|
+
sniffing — that's what makes `qtmp doctor` and dependency resolution
|
|
7
|
+
consistent with each other.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ProjectContext:
|
|
17
|
+
cwd: str
|
|
18
|
+
|
|
19
|
+
# Node / JS
|
|
20
|
+
has_package_json: bool = False
|
|
21
|
+
is_node: bool = False
|
|
22
|
+
is_vite: bool = False
|
|
23
|
+
node_deps: set = field(default_factory=set)
|
|
24
|
+
|
|
25
|
+
# Python
|
|
26
|
+
has_pyproject: bool = False
|
|
27
|
+
has_requirements: bool = False
|
|
28
|
+
is_python: bool = False
|
|
29
|
+
has_venv: bool = False
|
|
30
|
+
|
|
31
|
+
# Infra
|
|
32
|
+
has_docker_compose: bool = False
|
|
33
|
+
has_tailwind_config: bool = False
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def detect(cls, cwd: str) -> "ProjectContext":
|
|
37
|
+
ctx = cls(cwd=cwd)
|
|
38
|
+
|
|
39
|
+
pkg_path = os.path.join(cwd, "package.json")
|
|
40
|
+
if os.path.isfile(pkg_path):
|
|
41
|
+
ctx.has_package_json = True
|
|
42
|
+
ctx.is_node = True
|
|
43
|
+
try:
|
|
44
|
+
with open(pkg_path) as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
47
|
+
ctx.node_deps = set(deps.keys())
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
for fname in ("vite.config.js", "vite.config.ts"):
|
|
52
|
+
if os.path.isfile(os.path.join(cwd, fname)):
|
|
53
|
+
ctx.is_vite = True
|
|
54
|
+
|
|
55
|
+
if os.path.isfile(os.path.join(cwd, "pyproject.toml")):
|
|
56
|
+
ctx.has_pyproject = True
|
|
57
|
+
ctx.is_python = True
|
|
58
|
+
if os.path.isfile(os.path.join(cwd, "requirements.txt")):
|
|
59
|
+
ctx.has_requirements = True
|
|
60
|
+
ctx.is_python = True
|
|
61
|
+
if os.path.isdir(os.path.join(cwd, "venv")) or os.path.isdir(os.path.join(cwd, ".venv")):
|
|
62
|
+
ctx.has_venv = True
|
|
63
|
+
ctx.is_python = True
|
|
64
|
+
|
|
65
|
+
for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
|
|
66
|
+
if os.path.isfile(os.path.join(cwd, fname)):
|
|
67
|
+
ctx.has_docker_compose = True
|
|
68
|
+
|
|
69
|
+
for fname in ("tailwind.config.js", "tailwind.config.ts"):
|
|
70
|
+
if os.path.isfile(os.path.join(cwd, fname)):
|
|
71
|
+
ctx.has_tailwind_config = True
|
|
72
|
+
|
|
73
|
+
return ctx
|
|
74
|
+
|
|
75
|
+
# ---- venv path helpers (kept here so recipes never hardcode OS paths) --
|
|
76
|
+
|
|
77
|
+
def _venv_dir(self):
|
|
78
|
+
for name in ("venv", ".venv"):
|
|
79
|
+
candidate = os.path.join(self.cwd, name)
|
|
80
|
+
if os.path.isdir(candidate):
|
|
81
|
+
return candidate
|
|
82
|
+
return os.path.join(self.cwd, "venv")
|
|
83
|
+
|
|
84
|
+
def venv_python(self) -> str:
|
|
85
|
+
venv_dir = self._venv_dir()
|
|
86
|
+
if os.name == "nt":
|
|
87
|
+
return os.path.join(venv_dir, "Scripts", "python.exe")
|
|
88
|
+
return os.path.join(venv_dir, "bin", "python")
|
|
89
|
+
|
|
90
|
+
def venv_pip(self) -> str:
|
|
91
|
+
venv_dir = self._venv_dir()
|
|
92
|
+
if os.name == "nt":
|
|
93
|
+
return os.path.join(venv_dir, "Scripts", "pip.exe")
|
|
94
|
+
return os.path.join(venv_dir, "bin", "pip")
|
qtmp/doctor.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.doctor
|
|
3
|
+
------------
|
|
4
|
+
`qtmp doctor` — one command that answers "what does qtmp think this
|
|
5
|
+
environment looks like, and what's missing." Read-only: it never
|
|
6
|
+
installs anything, only reports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from qtmp.utils import C, ok, warn, info, which
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TOOLS = [
|
|
13
|
+
("node", "Node.js"),
|
|
14
|
+
("npm", "npm"),
|
|
15
|
+
("python3", "Python 3"),
|
|
16
|
+
("pip3", "pip"),
|
|
17
|
+
("psql", "PostgreSQL client"),
|
|
18
|
+
("docker", "Docker"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_doctor(ctx):
|
|
23
|
+
print(f"\n{C.BOLD}Tools on PATH{C.RESET}")
|
|
24
|
+
for binary, label in TOOLS:
|
|
25
|
+
if which(binary):
|
|
26
|
+
ok(f"{label} ({binary})")
|
|
27
|
+
else:
|
|
28
|
+
warn(f"{label} ({binary}) not found")
|
|
29
|
+
|
|
30
|
+
print(f"\n{C.BOLD}Project detected at {ctx.cwd}{C.RESET}")
|
|
31
|
+
|
|
32
|
+
flags = [
|
|
33
|
+
(ctx.has_package_json, "package.json (Node project)"),
|
|
34
|
+
(ctx.is_vite, "Vite config"),
|
|
35
|
+
(ctx.has_tailwind_config, "Tailwind config"),
|
|
36
|
+
(ctx.has_pyproject, "pyproject.toml (Python project)"),
|
|
37
|
+
(ctx.has_requirements, "requirements.txt"),
|
|
38
|
+
(ctx.has_venv, "virtual environment (venv/.venv)"),
|
|
39
|
+
(ctx.has_docker_compose, "docker-compose file"),
|
|
40
|
+
]
|
|
41
|
+
any_detected = False
|
|
42
|
+
for present, label in flags:
|
|
43
|
+
if present:
|
|
44
|
+
ok(label)
|
|
45
|
+
any_detected = True
|
|
46
|
+
|
|
47
|
+
if not any_detected:
|
|
48
|
+
info("No recognizable project here yet — 'qtmp create react' or 'qtmp create fastapi' to start one.")
|
|
49
|
+
|
|
50
|
+
if ctx.node_deps:
|
|
51
|
+
print(f"\n{C.DIM}Detected npm dependencies: {', '.join(sorted(ctx.node_deps))}{C.RESET}")
|
qtmp/executor.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.executor
|
|
3
|
+
--------------
|
|
4
|
+
Turns a Plan into real side effects. Two guarantees this module exists
|
|
5
|
+
to provide:
|
|
6
|
+
|
|
7
|
+
1. Idempotency: steps already marked `already_satisfied` by the planner
|
|
8
|
+
are reported, never re-run.
|
|
9
|
+
2. Fail-fast with a clear report: if a step fails, later steps (which
|
|
10
|
+
may assume it succeeded) are not attempted, and the person gets a
|
|
11
|
+
readable summary of what did and didn't happen instead of a raw
|
|
12
|
+
traceback.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
from qtmp.planner import Plan, Step
|
|
20
|
+
from qtmp.utils import Spinner, ok, warn, err, info
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ExecutionResult:
|
|
25
|
+
plan: Plan
|
|
26
|
+
applied: List[Step] = field(default_factory=list)
|
|
27
|
+
skipped: List[Step] = field(default_factory=list)
|
|
28
|
+
failed_step: Step = None
|
|
29
|
+
error: str = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def success(self) -> bool:
|
|
33
|
+
return self.failed_step is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply(plan: Plan) -> ExecutionResult:
|
|
37
|
+
result = ExecutionResult(plan=plan)
|
|
38
|
+
|
|
39
|
+
if plan.is_noop():
|
|
40
|
+
for step in plan.steps:
|
|
41
|
+
result.skipped.append(step)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
for step in plan.steps:
|
|
45
|
+
if step.already_satisfied:
|
|
46
|
+
result.skipped.append(step)
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
_run_step(step)
|
|
51
|
+
result.applied.append(step)
|
|
52
|
+
except Exception as e: # noqa: BLE001 — any step failure halts the plan
|
|
53
|
+
result.failed_step = step
|
|
54
|
+
result.error = str(e)
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_step(step: Step):
|
|
61
|
+
from qtmp.utils import run as run_cmd
|
|
62
|
+
|
|
63
|
+
if step.apply_fn is not None:
|
|
64
|
+
with Spinner(step.description):
|
|
65
|
+
step.apply_fn()
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if step.command is not None:
|
|
69
|
+
with Spinner(step.description):
|
|
70
|
+
run_cmd(step.command, cwd=step.cwd)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# A step with neither command nor apply_fn is just an informational
|
|
74
|
+
# marker (e.g. "already installed") and should never reach here since
|
|
75
|
+
# those are always already_satisfied — but guard anyway.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def report(result: ExecutionResult, capability: str):
|
|
79
|
+
if result.plan.is_noop():
|
|
80
|
+
for step in result.skipped:
|
|
81
|
+
ok(step.description)
|
|
82
|
+
info("Nothing to do.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
for step in result.applied:
|
|
86
|
+
ok(step.description)
|
|
87
|
+
for step in result.skipped:
|
|
88
|
+
ok(f"{step.description} (skipped, already satisfied)")
|
|
89
|
+
|
|
90
|
+
if not result.success:
|
|
91
|
+
err(f"Failed: {result.failed_step.description}")
|
|
92
|
+
if result.error:
|
|
93
|
+
print(f" {result.error}")
|
|
94
|
+
warn(f"Stopped before finishing '{capability}'. Nothing after the "
|
|
95
|
+
"failed step was run — safe to fix the issue and re-run 'qtmp add "
|
|
96
|
+
f"{capability}'; already-applied steps will be skipped next time.")
|
|
97
|
+
else:
|
|
98
|
+
ok(f"'{capability}' is ready.")
|
qtmp/planner.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.planner
|
|
3
|
+
-------------
|
|
4
|
+
A Step is one concrete unit of work. A Plan is an ordered list of Steps
|
|
5
|
+
produced by walking a capability's dependency chain against the current
|
|
6
|
+
ProjectContext. Nothing here touches the filesystem or a subprocess —
|
|
7
|
+
that's the executor's job. This separation is what makes `qtmp plan add X`
|
|
8
|
+
(dry run) and `qtmp add X` (real run) the exact same code path, just
|
|
9
|
+
stopping one step earlier.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Callable, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Step:
|
|
18
|
+
description: str
|
|
19
|
+
command: Optional[list] = None
|
|
20
|
+
cwd: Optional[str] = None
|
|
21
|
+
apply_fn: Optional[Callable[[], None]] = None
|
|
22
|
+
already_satisfied: bool = False
|
|
23
|
+
# Which capability this step belongs to, for readable plan output.
|
|
24
|
+
capability: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Plan:
|
|
29
|
+
capability: str
|
|
30
|
+
steps: List[Step] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def pending_steps(self) -> List[Step]:
|
|
34
|
+
return [s for s in self.steps if not s.already_satisfied]
|
|
35
|
+
|
|
36
|
+
def is_noop(self) -> bool:
|
|
37
|
+
return len(self.pending_steps) == 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ResolutionError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_plan(capability: str, ctx, registry, _seen=None) -> Plan:
|
|
45
|
+
"""
|
|
46
|
+
Depth-first walk of `requires(ctx)`, dependencies before the target,
|
|
47
|
+
each capability visited at most once even if requested by multiple
|
|
48
|
+
dependents (a diamond dependency doesn't get installed twice).
|
|
49
|
+
|
|
50
|
+
A capability whose recipe.detect(ctx) is already True still shows up
|
|
51
|
+
in the plan, marked already_satisfied, so `qtmp plan` output is
|
|
52
|
+
honest about the whole chain rather than silently omitting parts
|
|
53
|
+
that are already done.
|
|
54
|
+
"""
|
|
55
|
+
if _seen is None:
|
|
56
|
+
_seen = set()
|
|
57
|
+
if capability in _seen:
|
|
58
|
+
return Plan(capability=capability, steps=[])
|
|
59
|
+
_seen.add(capability)
|
|
60
|
+
|
|
61
|
+
recipe = registry.get(capability)
|
|
62
|
+
if recipe is None:
|
|
63
|
+
raise ResolutionError(
|
|
64
|
+
f"No recipe provides '{capability}'. Run 'qtmp list' to see what's available."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
steps: List[Step] = []
|
|
68
|
+
|
|
69
|
+
for dep_capability in recipe.requires(ctx):
|
|
70
|
+
dep_plan = resolve_plan(dep_capability, ctx, registry, _seen)
|
|
71
|
+
steps.extend(dep_plan.steps)
|
|
72
|
+
|
|
73
|
+
if recipe.detect(ctx):
|
|
74
|
+
steps.append(Step(
|
|
75
|
+
description=f"{recipe.name} already installed",
|
|
76
|
+
already_satisfied=True,
|
|
77
|
+
capability=capability,
|
|
78
|
+
))
|
|
79
|
+
else:
|
|
80
|
+
for step in recipe.plan(ctx):
|
|
81
|
+
step.capability = step.capability or capability
|
|
82
|
+
steps.append(step)
|
|
83
|
+
|
|
84
|
+
return Plan(capability=capability, steps=steps)
|