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.
- {qtmp-0.2.0 → qtmp-0.3.0}/PKG-INFO +1 -1
- {qtmp-0.2.0 → qtmp-0.3.0}/README.md +23 -3
- qtmp-0.3.0/qtmp/__init__.py +1 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cli.py +94 -4
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/__init__.py +6 -0
- qtmp-0.3.0/qtmp/recipes/base.py +105 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/fastapi.py +5 -0
- qtmp-0.3.0/qtmp/recipes/frontend_basic.py +123 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/react.py +6 -1
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/PKG-INFO +1 -1
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/SOURCES.txt +3 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/setup.py +1 -1
- qtmp-0.3.0/tests/test_cli_helpers.py +78 -0
- qtmp-0.3.0/tests/test_existing_target_and_frontend_basic.py +91 -0
- qtmp-0.2.0/qtmp/__init__.py +0 -1
- qtmp-0.2.0/qtmp/recipes/base.py +0 -62
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheet_manager.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheets/fastapi.md +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/cheatsheets/react.md +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/context.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/doctor.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/executor.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/planner.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/database.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/router_axios.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/recipes/tailwind.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp/utils.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/dependency_links.txt +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/entry_points.txt +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/requires.txt +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/qtmp.egg-info/top_level.txt +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/setup.cfg +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_cheatsheet_manager.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_context.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_executor.py +0 -0
- {qtmp-0.2.0 → qtmp-0.3.0}/tests/test_planner.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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",
|
|
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.
|
|
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
|
|
@@ -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
|
qtmp-0.2.0/qtmp/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
qtmp-0.2.0/qtmp/recipes/base.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|