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 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)