qtmp 0.2.0__tar.gz → 0.3.0__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 (37) hide show
  1. {qtmp-0.2.0 → qtmp-0.3.0}/PKG-INFO +1 -1
  2. {qtmp-0.2.0 → qtmp-0.3.0}/README.md +23 -3
  3. qtmp-0.3.0/qtmp/__init__.py +1 -0
  4. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cli.py +94 -4
  5. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/__init__.py +6 -0
  6. qtmp-0.3.0/qtmp/recipes/base.py +105 -0
  7. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/fastapi.py +5 -0
  8. qtmp-0.3.0/qtmp/recipes/frontend_basic.py +123 -0
  9. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/react.py +6 -1
  10. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/PKG-INFO +1 -1
  11. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/SOURCES.txt +3 -0
  12. {qtmp-0.2.0 → qtmp-0.3.0}/setup.py +1 -1
  13. qtmp-0.3.0/tests/test_cli_helpers.py +78 -0
  14. qtmp-0.3.0/tests/test_existing_target_and_frontend_basic.py +91 -0
  15. qtmp-0.2.0/qtmp/__init__.py +0 -1
  16. qtmp-0.2.0/qtmp/recipes/base.py +0 -62
  17. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheet_manager.py +0 -0
  18. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheets/fastapi.md +0 -0
  19. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheets/react.md +0 -0
  20. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/context.py +0 -0
  21. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/doctor.py +0 -0
  22. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/executor.py +0 -0
  23. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/planner.py +0 -0
  24. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/database.py +0 -0
  25. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/router_axios.py +0 -0
  26. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/tailwind.py +0 -0
  27. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/utils.py +0 -0
  28. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/dependency_links.txt +0 -0
  29. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/entry_points.txt +0 -0
  30. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/requires.txt +0 -0
  31. {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/top_level.txt +0 -0
  32. {qtmp-0.2.0 → qtmp-0.3.0}/setup.cfg +0 -0
  33. {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_cheatsheet_manager.py +0 -0
  34. {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_context.py +0 -0
  35. {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_executor.py +0 -0
  36. {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_planner.py +0 -0
  37. {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_recipes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qtmp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Qtmp -- Developer Environment Automation CLI. Detects project environments, resolves dependencies, generates execution plans, and runs package-manager commands, with idempotent operations and dry-run planning built in.
5
5
  Requires-Python: >=3.8
6
6
  Provides-Extra: dev
@@ -44,7 +44,8 @@ qtmp doctor
44
44
 
45
45
  | Command | What it does |
46
46
  |---|---|
47
- | `qtmp create react` / `qtmp create fastapi` | Scaffold a brand-new project (asks name, and for React, beginner/advanced) |
47
+ | `qtmp create react` / `qtmp create fastapi` / `qtmp create frontend-basic` | Scaffold a brand-new project |
48
+ | `qtmp <capability>` | **Shorthand** — `qtmp fastapi` == `qtmp create fastapi`; `qtmp tailwind` == `qtmp add tailwind` (creatable capabilities go to `create`, everything else to `add`) |
48
49
  | `qtmp add <capability>` | Resolve dependencies and install/configure a capability |
49
50
  | `qtmp plan add <capability>` | Same resolution, dry-run only — prints the commands, changes nothing |
50
51
  | `qtmp doctor` | Diagnose which tools are on PATH and what this directory looks like |
@@ -52,11 +53,25 @@ qtmp doctor
52
53
  | `qtmp cheatsheet [name]` | View built-in or your own personal cheatsheets |
53
54
  | `qtmp --version` | Print the installed version |
54
55
 
56
+ `--cwd` works in **either position** — `qtmp --cwd ./my-app create react` and `qtmp create react --cwd ./my-app` both work identically.
57
+
55
58
  Beginner React (`b`) gets Vite + Tailwind automatically. Advanced (`a`)
56
59
  gets TypeScript, Tailwind, shadcn/ui, React Router, Axios, and a
57
60
  `src/components|pages|hooks|lib` layout — all resolved and applied
58
61
  through the same planner/executor path as a manual `qtmp add`.
59
62
 
63
+ `frontend-basic` (aliases: `frontend`, `html-css-js`, `vanilla`) scaffolds
64
+ a plain `index.html` + `style.css` + `script.js` — no framework, no npm,
65
+ no network required. Good for confirming files land where you expect
66
+ without waiting on any package manager.
67
+
68
+ ### Re-running `create` is safe
69
+
70
+ Every creatable recipe checks the target folder before scaffolding:
71
+ - **Doesn't exist / empty** → scaffolds normally.
72
+ - **Already a valid project of that type** → skipped, reports "already exists," doesn't touch it.
73
+ - **Exists, non-empty, and isn't that project type** → clean error, doesn't blindly run a scaffolder into someone else's files.
74
+
60
75
  ## Architecture
61
76
 
62
77
  ```
@@ -72,10 +87,13 @@ qtmp/
72
87
  │ ├── router_axios.py provides "router" / "axios" / "shadcn" (shadcn requires "tailwind")
73
88
  │ ├── fastapi.py provides "fastapi", creatable
74
89
  │ ├── database.py provides "postgres" / "db-driver-postgres" / "sqlalchemy"
90
+ │ ├── frontend_basic.py provides "frontend-basic", creatable -- plain HTML/CSS/JS
75
91
  │ └── __init__.py REGISTRY = {capability_name: recipe_instance} -- the only file
76
92
  │ you edit to add a new recipe
77
93
  ├── cheatsheet_manager.py bundled vs. personal (~/.qtmp/cheatsheets/) cheatsheets
78
- └── cli.py argparse subcommands, all going through resolve_plan()/apply()
94
+ └── cli.py argparse subcommands, all going through resolve_plan()/apply();
95
+ also handles --cwd extraction (works before/after the
96
+ subcommand) and shorthand expansion ('qtmp fastapi' -> 'qtmp create fastapi')
79
97
  ```
80
98
 
81
99
  ### The dependency-resolution example, end to end
@@ -126,7 +144,9 @@ pip install -e ".[dev]"
126
144
  pytest -v
127
145
  ```
128
146
 
129
- 40 tests covering:
147
+ 60 tests covering:
148
+ - **CLI helpers** — shorthand expansion (`qtmp fastapi` → `create fastapi`), and `--cwd` extraction working in either position, including the `--cwd=value` form and repeated occurrences.
149
+ - **Existing-target safety** — `create` skips cleanly on an already-valid project of the same type, and raises a clear error (not a crash) on a genuine name collision with unrelated content.
130
150
  - **Project detection** — Node/Vite/Python/venv/Docker/Tailwind sniffing, and that a malformed `package.json` doesn't crash detection.
131
151
  - **Dependency resolution** — ordering, diamond dependencies installed once, unknown capabilities raising a clear error, and the exact FastAPI→driver→SQLAlchemy shape from the design doc.
132
152
  - **Idempotency & safe execution** — a fully-satisfied chain is a no-op; a failure mid-plan halts everything after it and reports which step failed; already-applied steps are never re-run.
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -19,6 +19,7 @@ never drift apart from each other.
19
19
 
20
20
  import argparse
21
21
  import os
22
+ import subprocess
22
23
  import sys
23
24
 
24
25
  from qtmp import __version__
@@ -30,6 +31,69 @@ from qtmp.doctor import run_doctor
30
31
  from qtmp.utils import banner, info, warn, ok, err, print_logo, C
31
32
  from qtmp import cheatsheet_manager as cheats
32
33
 
34
+ KNOWN_COMMANDS = {"create", "add", "plan", "doctor", "list", "cheatsheet"}
35
+
36
+
37
+ def extract_cwd(argv):
38
+ """
39
+ Pull --cwd (or --cwd=VALUE) out of argv wherever it appears and
40
+ return (remaining_argv, value_or_None).
41
+
42
+ This exists because argparse has a sharp edge with subparsers:
43
+ when the same --cwd argument is defined on both the top-level
44
+ parser and a subparser (so it can appear either before or after
45
+ the subcommand), the subparser's *default* silently overwrites
46
+ whatever the top-level parser already captured -- even if the
47
+ person never typed --cwd a second time. Handling it here, once,
48
+ before argparse ever sees it, sidesteps that entirely and lets
49
+ --cwd work in either position reliably.
50
+ """
51
+ remaining = []
52
+ value = None
53
+ i = 0
54
+ while i < len(argv):
55
+ tok = argv[i]
56
+ if tok == "--cwd" and i + 1 < len(argv):
57
+ value = argv[i + 1]
58
+ i += 2
59
+ continue
60
+ if tok.startswith("--cwd="):
61
+ value = tok.split("=", 1)[1]
62
+ i += 1
63
+ continue
64
+ remaining.append(tok)
65
+ i += 1
66
+ return remaining, value
67
+
68
+
69
+ def expand_shorthand(argv):
70
+ """
71
+ Lets 'qtmp fastapi' work directly instead of requiring
72
+ 'qtmp create fastapi' / 'qtmp add fastapi'. The first bare word
73
+ that isn't already a known subcommand is checked against the
74
+ recipe registry: creatable recipes become 'create <name>',
75
+ everything else becomes 'add <name>'. Anything that doesn't match
76
+ a known command or a registered capability is left untouched, so
77
+ argparse still reports a normal error for genuine typos.
78
+ """
79
+ for i, tok in enumerate(argv):
80
+ if tok == "--cwd":
81
+ continue
82
+ if i > 0 and argv[i - 1] == "--cwd":
83
+ continue
84
+ if tok.startswith("--cwd="):
85
+ continue
86
+ if tok.startswith("-"):
87
+ continue
88
+ if tok in KNOWN_COMMANDS:
89
+ return argv
90
+ key = resolve_name(tok)
91
+ if key in REGISTRY:
92
+ verb = "create" if key in CREATABLE else "add"
93
+ return argv[:i] + [verb, tok] + argv[i + 1:]
94
+ return argv
95
+ return argv
96
+
33
97
 
34
98
  def cmd_create(args):
35
99
  key = resolve_name(args.recipe)
@@ -41,7 +105,23 @@ def cmd_create(args):
41
105
 
42
106
  ctx = ProjectContext.detect(args.cwd)
43
107
  banner(f"Create: {recipe.name}")
44
- project_dir, advanced = recipe.create(ctx)
108
+
109
+ try:
110
+ project_dir, advanced = recipe.create(ctx)
111
+ except RuntimeError as e:
112
+ err(str(e))
113
+ return
114
+ except subprocess.CalledProcessError as e:
115
+ err(f"A command failed while scaffolding: {e}")
116
+ warn("Nothing after the failed step ran. Fix the issue and re-run — "
117
+ "if a partial folder was created, remove it first to avoid a "
118
+ "'directory not empty' error on retry.")
119
+ return
120
+
121
+ if project_dir is None:
122
+ # recipe.create() already reported why it stopped (e.g. skipped
123
+ # because the project already exists) -- nothing more to do.
124
+ return
45
125
 
46
126
  if key == "react" and advanced:
47
127
  sub_ctx = ProjectContext.detect(project_dir)
@@ -182,11 +262,15 @@ def build_parser():
182
262
  "have to hunt through docs.",
183
263
  )
184
264
  parser.add_argument("--version", action="version", version=f"qtmp {__version__}")
185
- parser.add_argument("--cwd", default=os.getcwd(), help="Directory to operate in.")
265
+ parser.add_argument("--cwd", default=os.getcwd(),
266
+ help="Directory to operate in. Works before or after "
267
+ "the subcommand, e.g. both 'qtmp --cwd X create react' "
268
+ "and 'qtmp create react --cwd X'.")
186
269
 
187
270
  sub = parser.add_subparsers(dest="command")
188
271
 
189
- p_create = sub.add_parser("create", help="Scaffold a brand-new project (react, fastapi).")
272
+ p_create = sub.add_parser("create",
273
+ help="Scaffold a brand-new project (react, fastapi, frontend-basic).")
190
274
  p_create.add_argument("recipe")
191
275
  p_create.set_defaults(func=cmd_create)
192
276
 
@@ -214,8 +298,14 @@ def build_parser():
214
298
 
215
299
 
216
300
  def main():
301
+ argv, cwd_override = extract_cwd(sys.argv[1:])
302
+ argv = expand_shorthand(argv)
303
+
217
304
  parser = build_parser()
218
- args = parser.parse_args()
305
+ args = parser.parse_args(argv)
306
+
307
+ if cwd_override is not None:
308
+ args.cwd = cwd_override
219
309
  args.cwd = os.path.abspath(args.cwd)
220
310
 
221
311
  if not getattr(args, "command", None):
@@ -12,6 +12,7 @@ from qtmp.recipes.tailwind import TailwindRecipe
12
12
  from qtmp.recipes.router_axios import RouterRecipe, AxiosRecipe, ShadcnRecipe
13
13
  from qtmp.recipes.fastapi import FastAPIRecipe
14
14
  from qtmp.recipes.database import PostgresRecipe, DbDriverPostgresRecipe, SQLAlchemyRecipe
15
+ from qtmp.recipes.frontend_basic import FrontendBasicRecipe
15
16
 
16
17
  REGISTRY = {
17
18
  r.provides: r
@@ -25,6 +26,7 @@ REGISTRY = {
25
26
  PostgresRecipe(),
26
27
  DbDriverPostgresRecipe(),
27
28
  SQLAlchemyRecipe(),
29
+ FrontendBasicRecipe(),
28
30
  ]
29
31
  }
30
32
 
@@ -43,6 +45,10 @@ ALIASES = {
43
45
  "sqlalchemy": "sqlalchemy",
44
46
  "shadcn-ui": "shadcn",
45
47
  "shadcn/ui": "shadcn",
48
+ "frontend": "frontend-basic",
49
+ "html-css-js": "frontend-basic",
50
+ "vanilla": "frontend-basic",
51
+ "basic": "frontend-basic",
46
52
  }
47
53
 
48
54
 
@@ -0,0 +1,105 @@
1
+ """
2
+ qtmp.recipes.base
3
+ -------------------
4
+ A Recipe is the only thing a developer needs to write to teach qtmp
5
+ about a new piece of the stack. It answers three questions:
6
+
7
+ - requires(ctx) -> what other capabilities must exist first?
8
+ - detect(ctx) -> is this capability already satisfied? (idempotency)
9
+ - plan(ctx) -> what Steps would satisfy it, if not?
10
+
11
+ Nothing in qtmp/planner.py, qtmp/executor.py, or qtmp/cli.py needs to
12
+ change when a new Recipe is added — only qtmp/recipes/__init__.py's
13
+ registry needs one new line.
14
+ """
15
+
16
+ import os
17
+ from abc import ABC, abstractmethod
18
+ from typing import List
19
+
20
+ from qtmp.planner import Step
21
+
22
+
23
+ class Recipe(ABC):
24
+ #: human-readable name, e.g. "Tailwind CSS"
25
+ name: str = ""
26
+ #: capability id this recipe satisfies, e.g. "tailwind" — this is
27
+ #: what `qtmp add <id>` and `requires()` refer to.
28
+ provides: str = ""
29
+ #: True if this recipe supports `qtmp create <provides>` (scaffolding
30
+ #: a brand-new project) in addition to `qtmp add`.
31
+ creatable: bool = False
32
+
33
+ def requires(self, ctx) -> List[str]:
34
+ """
35
+ Capability ids that must be satisfied before this one can run.
36
+ Can depend on `ctx` — e.g. SQLAlchemy's driver requirement
37
+ depends on which database is actually in play.
38
+ """
39
+ return []
40
+
41
+ @abstractmethod
42
+ def detect(self, ctx) -> bool:
43
+ """Is this capability already present in ctx.cwd?"""
44
+ raise NotImplementedError
45
+
46
+ @abstractmethod
47
+ def plan(self, ctx) -> List[Step]:
48
+ """
49
+ Steps needed to satisfy this capability. Only ever called when
50
+ detect(ctx) is False — recipes don't need their own "already
51
+ installed" guard, the planner handles that centrally.
52
+ """
53
+ raise NotImplementedError
54
+
55
+ def create(self, ctx, **kwargs):
56
+ """
57
+ Only for creatable recipes: scaffold a brand-new project.
58
+ Returns (project_dir, advanced_flag), or (None, False) if
59
+ creation was skipped (e.g. the target already exists as a
60
+ valid project of this type — see check_existing_target below).
61
+ """
62
+ raise NotImplementedError(f"{self.name} does not support 'create'")
63
+
64
+ def check_existing_target(self, project_dir: str):
65
+ """
66
+ Shared guard every 'create' implementation should call before
67
+ scaffolding: prevents blindly re-running a scaffolder (e.g.
68
+ 'npm create vite@latest') into a folder that already has
69
+ something in it.
70
+
71
+ Returns:
72
+ "skip" -> folder already looks like a valid project of this
73
+ type; caller should report that and return early
74
+ instead of re-scaffolding.
75
+ "clear" -> folder doesn't exist yet, or exists and is empty;
76
+ safe to scaffold into.
77
+ Raises:
78
+ RuntimeError -> folder exists, is non-empty, and isn't a
79
+ recognizable project of this type. Scaffolding
80
+ into it would likely fail anyway (most
81
+ scaffolders refuse a non-empty directory) or
82
+ silently mix files from two different projects.
83
+ """
84
+ from qtmp.context import ProjectContext
85
+ from qtmp.utils import ok, info
86
+
87
+ if not os.path.isdir(project_dir):
88
+ return "clear"
89
+
90
+ if not os.listdir(project_dir):
91
+ return "clear"
92
+
93
+ existing_ctx = ProjectContext.detect(project_dir)
94
+ if self.detect(existing_ctx):
95
+ ok(f"'{os.path.basename(project_dir)}' already exists and is "
96
+ f"already a {self.name} project — skipping scaffold.")
97
+ info(f"Run 'qtmp add <capability> --cwd {project_dir}' to add "
98
+ "more to it, it won't be recreated.")
99
+ return "skip"
100
+
101
+ raise RuntimeError(
102
+ f"'{project_dir}' already exists and isn't empty, and doesn't "
103
+ f"look like a {self.name} project. Choose a different name or "
104
+ "remove that folder first."
105
+ )
@@ -72,6 +72,11 @@ class FastAPIRecipe(Recipe):
72
72
 
73
73
  name = prompt("Project name", default="my-fastapi-app")
74
74
  project_dir = os.path.join(ctx.cwd, name)
75
+
76
+ status = self.check_existing_target(project_dir)
77
+ if status == "skip":
78
+ return None, False
79
+
75
80
  os.makedirs(project_dir, exist_ok=True)
76
81
 
77
82
  from qtmp.utils import Spinner, run, ok
@@ -0,0 +1,123 @@
1
+ """
2
+ qtmp.recipes.frontend_basic
3
+ ------------------------------
4
+ Provides "frontend-basic": a plain HTML + CSS + JS starter, no
5
+ framework, no npm, no network required. This is the recipe to reach
6
+ for when someone doesn't want React/Vite at all — just a page that
7
+ opens directly in a browser.
8
+ """
9
+
10
+ import os
11
+ from typing import List
12
+
13
+ from qtmp.planner import Step
14
+ from qtmp.recipes.base import Recipe
15
+ from qtmp.utils import prompt
16
+
17
+
18
+ INDEX_HTML = """<!DOCTYPE html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23
+ <title>{title}</title>
24
+ <link rel="stylesheet" href="style.css">
25
+ </head>
26
+ <body>
27
+ <main>
28
+ <h1>{title}</h1>
29
+ <p>Edit index.html, style.css, and script.js to get started.</p>
30
+ <button id="demo-button">Click me</button>
31
+ </main>
32
+ <script src="script.js"></script>
33
+ </body>
34
+ </html>
35
+ """
36
+
37
+ STYLE_CSS = """* {
38
+ box-sizing: border-box;
39
+ }
40
+
41
+ body {
42
+ font-family: system-ui, -apple-system, sans-serif;
43
+ margin: 0;
44
+ min-height: 100vh;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ background: #0f172a;
49
+ color: #e2e8f0;
50
+ }
51
+
52
+ main {
53
+ text-align: center;
54
+ padding: 2rem;
55
+ }
56
+
57
+ button {
58
+ padding: 0.6rem 1.2rem;
59
+ font-size: 1rem;
60
+ border: none;
61
+ border-radius: 6px;
62
+ background: #3b82f6;
63
+ color: white;
64
+ cursor: pointer;
65
+ }
66
+
67
+ button:hover {
68
+ background: #2563eb;
69
+ }
70
+ """
71
+
72
+ SCRIPT_JS = """document.getElementById('demo-button').addEventListener('click', () => {
73
+ alert('script.js is wired up.');
74
+ });
75
+ """
76
+
77
+
78
+ def _write_files(project_dir, title):
79
+ with open(os.path.join(project_dir, "index.html"), "w") as f:
80
+ f.write(INDEX_HTML.format(title=title))
81
+ with open(os.path.join(project_dir, "style.css"), "w") as f:
82
+ f.write(STYLE_CSS)
83
+ with open(os.path.join(project_dir, "script.js"), "w") as f:
84
+ f.write(SCRIPT_JS)
85
+
86
+
87
+ class FrontendBasicRecipe(Recipe):
88
+ name = "Frontend Basics (HTML/CSS/JS)"
89
+ provides = "frontend-basic"
90
+ creatable = True
91
+
92
+ def detect(self, ctx) -> bool:
93
+ index = os.path.join(ctx.cwd, "index.html")
94
+ css = os.path.join(ctx.cwd, "style.css")
95
+ js = os.path.join(ctx.cwd, "script.js")
96
+ return os.path.isfile(index) and os.path.isfile(css) and os.path.isfile(js)
97
+
98
+ def plan(self, ctx) -> List[Step]:
99
+ title = os.path.basename(ctx.cwd.rstrip("/")) or "My Site"
100
+ return [
101
+ Step(
102
+ "Write index.html, style.css, script.js",
103
+ apply_fn=lambda: _write_files(ctx.cwd, title),
104
+ )
105
+ ]
106
+
107
+ def create(self, ctx, **kwargs):
108
+ name = prompt("Project name", default="my-site")
109
+ project_dir = os.path.join(ctx.cwd, name)
110
+
111
+ status = self.check_existing_target(project_dir)
112
+ if status == "skip":
113
+ return None, False
114
+
115
+ os.makedirs(project_dir, exist_ok=True)
116
+ _write_files(project_dir, name)
117
+
118
+ from qtmp.utils import ok
119
+ ok(f"Frontend basics ready at {project_dir}")
120
+ info_paths = ", ".join(["index.html", "style.css", "script.js"])
121
+ from qtmp.utils import info
122
+ info(f"Files: {info_paths}")
123
+ return project_dir, False
@@ -42,6 +42,12 @@ class ReactRecipe(Recipe):
42
42
  raise RuntimeError("npm not found on PATH. Install Node.js first: https://nodejs.org")
43
43
 
44
44
  name = prompt("Project name", default="my-react-app")
45
+ project_dir = os.path.join(ctx.cwd, name)
46
+
47
+ status = self.check_existing_target(project_dir)
48
+ if status == "skip":
49
+ return None, False
50
+
45
51
  level = prompt("Beginner or advanced setup? [b/a]", default="b").strip().lower()
46
52
  advanced = level.startswith("a")
47
53
  template = "react-ts" if advanced else "react"
@@ -51,7 +57,6 @@ class ReactRecipe(Recipe):
51
57
  with Spinner(f"Scaffolding {name} with Vite"):
52
58
  run(["npm", "create", "vite@latest", name, "--", "--template", template], cwd=ctx.cwd)
53
59
 
54
- project_dir = os.path.join(ctx.cwd, name)
55
60
  with Spinner("Installing dependencies"):
56
61
  run(["npm", "install"], cwd=project_dir)
57
62
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qtmp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Qtmp -- Developer Environment Automation CLI. Detects project environments, resolves dependencies, generates execution plans, and runs package-manager commands, with idempotent operations and dry-run planning built in.
5
5
  Requires-Python: >=3.8
6
6
  Provides-Extra: dev
@@ -20,11 +20,14 @@ qtmp/recipes/__init__.py
20
20
  qtmp/recipes/base.py
21
21
  qtmp/recipes/database.py
22
22
  qtmp/recipes/fastapi.py
23
+ qtmp/recipes/frontend_basic.py
23
24
  qtmp/recipes/react.py
24
25
  qtmp/recipes/router_axios.py
25
26
  qtmp/recipes/tailwind.py
26
27
  tests/test_cheatsheet_manager.py
28
+ tests/test_cli_helpers.py
27
29
  tests/test_context.py
28
30
  tests/test_executor.py
31
+ tests/test_existing_target_and_frontend_basic.py
29
32
  tests/test_planner.py
30
33
  tests/test_recipes.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="qtmp",
5
- version="0.2.0",
5
+ version="0.3.0",
6
6
  description=(
7
7
  "Qtmp -- Developer Environment Automation CLI. Detects project "
8
8
  "environments, resolves dependencies, generates execution plans, "
@@ -0,0 +1,78 @@
1
+ """
2
+ Tests for the small pure-function helpers in qtmp.cli that don't need
3
+ a subprocess or a real filesystem: shorthand command expansion and
4
+ --cwd extraction (both/either position).
5
+ """
6
+
7
+ from qtmp.cli import expand_shorthand, extract_cwd
8
+
9
+
10
+ # ---- expand_shorthand ---------------------------------------------------
11
+
12
+ def test_known_command_is_left_untouched():
13
+ assert expand_shorthand(["create", "react"]) == ["create", "react"]
14
+ assert expand_shorthand(["doctor"]) == ["doctor"]
15
+
16
+
17
+ def test_bare_creatable_capability_becomes_create():
18
+ assert expand_shorthand(["fastapi"]) == ["create", "fastapi"]
19
+ assert expand_shorthand(["react"]) == ["create", "react"]
20
+
21
+
22
+ def test_bare_non_creatable_capability_becomes_add():
23
+ assert expand_shorthand(["tailwind"]) == ["add", "tailwind"]
24
+ assert expand_shorthand(["sqlalchemy"]) == ["add", "sqlalchemy"]
25
+
26
+
27
+ def test_alias_resolves_before_expanding():
28
+ # "pg" is an alias for "postgres", which is not creatable -> add.
29
+ # The original token is preserved (not replaced by the canonical
30
+ # name) since cmd_add() resolves aliases again downstream anyway.
31
+ assert expand_shorthand(["pg"]) == ["add", "pg"]
32
+
33
+
34
+ def test_unknown_token_is_left_untouched_for_argparse_to_reject():
35
+ assert expand_shorthand(["not-a-real-thing"]) == ["not-a-real-thing"]
36
+
37
+
38
+ def test_trailing_args_are_preserved_after_expansion():
39
+ assert expand_shorthand(["fastapi", "--cwd", "/tmp/x"]) == \
40
+ ["create", "fastapi", "--cwd", "/tmp/x"]
41
+
42
+
43
+ def test_leading_flags_are_skipped_when_finding_the_command():
44
+ # A leading flag shouldn't be mistaken for the command itself.
45
+ assert expand_shorthand(["--cwd", "/tmp/x", "tailwind"]) == \
46
+ ["--cwd", "/tmp/x", "add", "tailwind"]
47
+
48
+
49
+ # ---- extract_cwd ---------------------------------------------------------
50
+
51
+ def test_extract_cwd_before_subcommand():
52
+ remaining, value = extract_cwd(["--cwd", "/tmp/x", "create", "react"])
53
+ assert value == "/tmp/x"
54
+ assert remaining == ["create", "react"]
55
+
56
+
57
+ def test_extract_cwd_after_subcommand():
58
+ remaining, value = extract_cwd(["create", "react", "--cwd", "/tmp/x"])
59
+ assert value == "/tmp/x"
60
+ assert remaining == ["create", "react"]
61
+
62
+
63
+ def test_extract_cwd_equals_form():
64
+ remaining, value = extract_cwd(["create", "react", "--cwd=/tmp/x"])
65
+ assert value == "/tmp/x"
66
+ assert remaining == ["create", "react"]
67
+
68
+
69
+ def test_extract_cwd_absent_returns_none():
70
+ remaining, value = extract_cwd(["create", "react"])
71
+ assert value is None
72
+ assert remaining == ["create", "react"]
73
+
74
+
75
+ def test_extract_cwd_last_occurrence_wins():
76
+ remaining, value = extract_cwd(["--cwd", "/first", "create", "react", "--cwd", "/second"])
77
+ assert value == "/second"
78
+ assert remaining == ["create", "react"]
@@ -0,0 +1,91 @@
1
+ """
2
+ Tests for:
3
+ - Recipe.check_existing_target(): the shared guard that stops 'create'
4
+ from blindly re-scaffolding into a folder that already has something
5
+ in it.
6
+ - FrontendBasicRecipe: detect()/plan()/create() for the plain
7
+ HTML/CSS/JS recipe, including that files actually land in the
8
+ directory passed in (no real subprocess needed for this one).
9
+ """
10
+
11
+ import os
12
+
13
+ from qtmp.context import ProjectContext
14
+ from qtmp.recipes.frontend_basic import FrontendBasicRecipe
15
+
16
+
17
+ def test_check_existing_target_clear_when_directory_absent(tmp_path):
18
+ recipe = FrontendBasicRecipe()
19
+ target = os.path.join(str(tmp_path), "does-not-exist-yet")
20
+ assert recipe.check_existing_target(target) == "clear"
21
+
22
+
23
+ def test_check_existing_target_clear_when_directory_empty(tmp_path):
24
+ recipe = FrontendBasicRecipe()
25
+ empty_dir = tmp_path / "empty"
26
+ empty_dir.mkdir()
27
+ assert recipe.check_existing_target(str(empty_dir)) == "clear"
28
+
29
+
30
+ def test_check_existing_target_skip_when_already_valid_project(tmp_path):
31
+ recipe = FrontendBasicRecipe()
32
+ project_dir = tmp_path / "existing"
33
+ project_dir.mkdir()
34
+ (project_dir / "index.html").write_text("<html></html>")
35
+ (project_dir / "style.css").write_text("body{}")
36
+ (project_dir / "script.js").write_text("// js")
37
+
38
+ assert recipe.check_existing_target(str(project_dir)) == "skip"
39
+
40
+
41
+ def test_check_existing_target_raises_on_unrelated_nonempty_folder(tmp_path):
42
+ recipe = FrontendBasicRecipe()
43
+ unrelated_dir = tmp_path / "unrelated"
44
+ unrelated_dir.mkdir()
45
+ (unrelated_dir / "random.txt").write_text("not a frontend project")
46
+
47
+ raised = False
48
+ try:
49
+ recipe.check_existing_target(str(unrelated_dir))
50
+ except RuntimeError:
51
+ raised = True
52
+ assert raised, "expected RuntimeError for a non-empty, unrelated folder"
53
+
54
+
55
+ def test_frontend_basic_not_detected_in_empty_dir(tmp_path):
56
+ ctx = ProjectContext.detect(str(tmp_path))
57
+ assert FrontendBasicRecipe().detect(ctx) is False
58
+
59
+
60
+ def test_frontend_basic_detected_after_files_written(tmp_path):
61
+ (tmp_path / "index.html").write_text("<html></html>")
62
+ (tmp_path / "style.css").write_text("body{}")
63
+ (tmp_path / "script.js").write_text("// js")
64
+
65
+ ctx = ProjectContext.detect(str(tmp_path))
66
+ assert FrontendBasicRecipe().detect(ctx) is True
67
+
68
+
69
+ def test_frontend_basic_not_detected_if_any_one_file_missing(tmp_path):
70
+ (tmp_path / "index.html").write_text("<html></html>")
71
+ (tmp_path / "style.css").write_text("body{}")
72
+ # script.js deliberately missing
73
+
74
+ ctx = ProjectContext.detect(str(tmp_path))
75
+ assert FrontendBasicRecipe().detect(ctx) is False
76
+
77
+
78
+ def test_frontend_basic_plan_writes_files_into_the_given_cwd(tmp_path):
79
+ ctx = ProjectContext.detect(str(tmp_path))
80
+ steps = FrontendBasicRecipe().plan(ctx)
81
+
82
+ assert len(steps) == 1
83
+ steps[0].apply_fn() # simulate the executor running it
84
+
85
+ assert (tmp_path / "index.html").is_file()
86
+ assert (tmp_path / "style.css").is_file()
87
+ assert (tmp_path / "script.js").is_file()
88
+
89
+ html_content = (tmp_path / "index.html").read_text()
90
+ assert 'href="style.css"' in html_content
91
+ assert 'src="script.js"' in html_content
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,62 +0,0 @@
1
- """
2
- qtmp.recipes.base
3
- -------------------
4
- A Recipe is the only thing a developer needs to write to teach qtmp
5
- about a new piece of the stack. It answers three questions:
6
-
7
- - requires(ctx) -> what other capabilities must exist first?
8
- - detect(ctx) -> is this capability already satisfied? (idempotency)
9
- - plan(ctx) -> what Steps would satisfy it, if not?
10
-
11
- Nothing in qtmp/planner.py, qtmp/executor.py, or qtmp/cli.py needs to
12
- change when a new Recipe is added — only qtmp/recipes/__init__.py's
13
- registry needs one new line.
14
- """
15
-
16
- from abc import ABC, abstractmethod
17
- from typing import List
18
-
19
- from qtmp.planner import Step
20
-
21
-
22
- class Recipe(ABC):
23
- #: human-readable name, e.g. "Tailwind CSS"
24
- name: str = ""
25
- #: capability id this recipe satisfies, e.g. "tailwind" — this is
26
- #: what `qtmp add <id>` and `requires()` refer to.
27
- provides: str = ""
28
- #: True if this recipe supports `qtmp create <provides>` (scaffolding
29
- #: a brand-new project) in addition to `qtmp add`.
30
- creatable: bool = False
31
-
32
- def requires(self, ctx) -> List[str]:
33
- """
34
- Capability ids that must be satisfied before this one can run.
35
- Can depend on `ctx` — e.g. SQLAlchemy's driver requirement
36
- depends on which database is actually in play.
37
- """
38
- return []
39
-
40
- @abstractmethod
41
- def detect(self, ctx) -> bool:
42
- """Is this capability already present in ctx.cwd?"""
43
- raise NotImplementedError
44
-
45
- @abstractmethod
46
- def plan(self, ctx) -> List[Step]:
47
- """
48
- Steps needed to satisfy this capability. Only ever called when
49
- detect(ctx) is False — recipes don't need their own "already
50
- installed" guard, the planner handles that centrally.
51
- """
52
- raise NotImplementedError
53
-
54
- def create(self, ctx, **kwargs):
55
- """
56
- Only for creatable recipes: scaffold a brand-new project.
57
- Returns the new project's directory. Not part of the
58
- detect/plan/requires idempotency contract — creating "my-app"
59
- twice is expected to be a user decision (a new folder), not
60
- something the planner should intervene in.
61
- """
62
- raise NotImplementedError(f"{self.name} does not support 'create'")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes