snapstack 1.0.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.
- pysnap/__init__.py +1 -0
- pysnap/_shared/Dockerfile.j2 +34 -0
- pysnap/_shared/ci.yml.j2 +53 -0
- pysnap/_shared/docker-compose.yml.j2 +46 -0
- pysnap/_shared/dockerignore.j2 +13 -0
- pysnap/_shared/env_example.j2 +24 -0
- pysnap/_shared/gitignore.j2 +15 -0
- pysnap/commands/__init__.py +1 -0
- pysnap/commands/add.py +171 -0
- pysnap/commands/create.py +136 -0
- pysnap/commands/templates_cmd.py +134 -0
- pysnap/commands/update.py +133 -0
- pysnap/community.py +113 -0
- pysnap/config.py +76 -0
- pysnap/generator.py +262 -0
- pysnap/main.py +65 -0
- pysnap/manifest.py +101 -0
- pysnap/plugins.py +123 -0
- pysnap/preview.py +131 -0
- pysnap/prompts.py +217 -0
- pysnap/registry.py +123 -0
- pysnap/templates/django/.dockerignore.j2 +15 -0
- pysnap/templates/django/.github/workflows/ci.yml.j2 +34 -0
- pysnap/templates/django/.gitignore.j2 +14 -0
- pysnap/templates/django/Dockerfile.j2 +14 -0
- pysnap/templates/django/README.md.j2 +36 -0
- pysnap/templates/django/apps/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/core/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/core/apps.py.j2 +6 -0
- pysnap/templates/django/apps/core/urls.py.j2 +7 -0
- pysnap/templates/django/apps/core/views.py.j2 +6 -0
- pysnap/templates/django/apps/users/__init__.py.j2 +0 -0
- pysnap/templates/django/apps/users/apps.py.j2 +6 -0
- pysnap/templates/django/apps/users/models.py.j2 +14 -0
- pysnap/templates/django/apps/users/serializers.py.j2 +13 -0
- pysnap/templates/django/apps/users/urls.py.j2 +10 -0
- pysnap/templates/django/apps/users/views.py.j2 +22 -0
- pysnap/templates/django/config/__init__.py.j2 +0 -0
- pysnap/templates/django/config/asgi.py.j2 +9 -0
- pysnap/templates/django/config/settings.py.j2 +110 -0
- pysnap/templates/django/config/urls.py.j2 +12 -0
- pysnap/templates/django/config/wsgi.py.j2 +9 -0
- pysnap/templates/django/docker-compose.yml.j2 +29 -0
- pysnap/templates/django/manage.py.j2 +22 -0
- pysnap/templates/django/pyproject.toml.j2 +40 -0
- pysnap/templates/django/template.json +50 -0
- pysnap/templates/django/tests/__init__.py.j2 +1 -0
- pysnap/templates/django/tests/conftest.py.j2 +6 -0
- pysnap/templates/django/tests/test_health.py.j2 +9 -0
- pysnap/templates/fastapi/.dockerignore.j2 +8 -0
- pysnap/templates/fastapi/.github/workflows/ci.yml.j2 +46 -0
- pysnap/templates/fastapi/.gitignore.j2 +13 -0
- pysnap/templates/fastapi/Dockerfile.j2 +14 -0
- pysnap/templates/fastapi/README.md.j2 +57 -0
- pysnap/templates/fastapi/api/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/api/routes/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/api/routes/auth.py.j2 +18 -0
- pysnap/templates/fastapi/api/routes/health.py.j2 +8 -0
- pysnap/templates/fastapi/app/__init__.py.j2 +1 -0
- pysnap/templates/fastapi/core/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/core/config.py.j2 +26 -0
- pysnap/templates/fastapi/core/security.py.j2 +22 -0
- pysnap/templates/fastapi/db/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/db/base.py.j2 +5 -0
- pysnap/templates/fastapi/db/session.py.j2 +15 -0
- pysnap/templates/fastapi/docker-compose.yml.j2 +30 -0
- pysnap/templates/fastapi/main.py.j2 +27 -0
- pysnap/templates/fastapi/models/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/models/user.py.j2 +13 -0
- pysnap/templates/fastapi/pyproject.toml.j2 +48 -0
- pysnap/templates/fastapi/schemas/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/schemas/user.py.j2 +18 -0
- pysnap/templates/fastapi/template.json +53 -0
- pysnap/templates/fastapi/tests/__init__.py.j2 +0 -0
- pysnap/templates/fastapi/tests/conftest.py.j2 +9 -0
- pysnap/templates/fastapi/tests/test_health.py.j2 +9 -0
- pysnap/templates/flask/.dockerignore.j2 +14 -0
- pysnap/templates/flask/.github/workflows/ci.yml.j2 +34 -0
- pysnap/templates/flask/.gitignore.j2 +13 -0
- pysnap/templates/flask/Dockerfile.j2 +14 -0
- pysnap/templates/flask/README.md.j2 +34 -0
- pysnap/templates/flask/app/__init__.py.j2 +30 -0
- pysnap/templates/flask/app/config.py.j2 +23 -0
- pysnap/templates/flask/app/extensions.py.j2 +9 -0
- pysnap/templates/flask/app/models/__init__.py.j2 +1 -0
- pysnap/templates/flask/app/models/user.py.j2 +16 -0
- pysnap/templates/flask/app/routes/__init__.py.j2 +1 -0
- pysnap/templates/flask/app/routes/auth.py.j2 +31 -0
- pysnap/templates/flask/app/routes/health.py.j2 +11 -0
- pysnap/templates/flask/docker-compose.yml.j2 +29 -0
- pysnap/templates/flask/pyproject.toml.j2 +39 -0
- pysnap/templates/flask/template.json +44 -0
- pysnap/templates/flask/tests/__init__.py.j2 +1 -0
- pysnap/templates/flask/tests/conftest.py.j2 +16 -0
- pysnap/templates/flask/tests/test_health.py.j2 +8 -0
- pysnap/templates/flask/wsgi.py.j2 +8 -0
- pysnap/validator.py +89 -0
- snapstack-1.0.0.dist-info/METADATA +267 -0
- snapstack-1.0.0.dist-info/RECORD +102 -0
- snapstack-1.0.0.dist-info/WHEEL +4 -0
- snapstack-1.0.0.dist-info/entry_points.txt +2 -0
- snapstack-1.0.0.dist-info/licenses/LICENSE +21 -0
pysnap/manifest.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read and write .pysnap.json manifest files.
|
|
3
|
+
|
|
4
|
+
The manifest is stored in the root of every pysnap-generated project and
|
|
5
|
+
records the template name, version, and user choices at scaffold time.
|
|
6
|
+
It is used by ``pysnap update`` to re-render infrastructure files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pysnap.config import MANIFEST_FILENAME, MANIFEST_SCHEMA_VERSION, PYSNAP_VERSION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_manifest(
|
|
20
|
+
project_path: Path,
|
|
21
|
+
template: str,
|
|
22
|
+
template_version: str,
|
|
23
|
+
options: dict[str, Any],
|
|
24
|
+
infrastructure_files: list[str],
|
|
25
|
+
) -> Path:
|
|
26
|
+
"""Write a .pysnap.json manifest to the generated project root.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
project_path:
|
|
31
|
+
Root directory of the generated project.
|
|
32
|
+
template:
|
|
33
|
+
Template name (e.g. "fastapi").
|
|
34
|
+
template_version:
|
|
35
|
+
Version string from the template manifest (e.g. "1.0.0").
|
|
36
|
+
options:
|
|
37
|
+
The complete ProjectOptions dict used during generation.
|
|
38
|
+
infrastructure_files:
|
|
39
|
+
List of relative paths (from project root) that are eligible
|
|
40
|
+
for ``pysnap update`` -- i.e. infrastructure-only files.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
Path
|
|
45
|
+
Absolute path to the written manifest file.
|
|
46
|
+
"""
|
|
47
|
+
manifest: dict[str, Any] = {
|
|
48
|
+
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
49
|
+
"pysnap_version": PYSNAP_VERSION,
|
|
50
|
+
"template": template,
|
|
51
|
+
"template_version": template_version,
|
|
52
|
+
"options": _serializable_options(options),
|
|
53
|
+
"infrastructure_files": sorted(infrastructure_files),
|
|
54
|
+
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
55
|
+
}
|
|
56
|
+
dest = project_path / MANIFEST_FILENAME
|
|
57
|
+
dest.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
58
|
+
return dest
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_manifest(project_path: Path) -> dict[str, Any]:
|
|
62
|
+
"""Read and parse the .pysnap.json manifest from a project directory.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
project_path:
|
|
67
|
+
Root directory of the project (where .pysnap.json lives).
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
dict
|
|
72
|
+
Parsed manifest contents.
|
|
73
|
+
|
|
74
|
+
Raises
|
|
75
|
+
------
|
|
76
|
+
FileNotFoundError
|
|
77
|
+
If .pysnap.json does not exist in *project_path*.
|
|
78
|
+
ValueError
|
|
79
|
+
If the file exists but contains invalid JSON.
|
|
80
|
+
"""
|
|
81
|
+
manifest_path = project_path / MANIFEST_FILENAME
|
|
82
|
+
if not manifest_path.exists():
|
|
83
|
+
raise FileNotFoundError(
|
|
84
|
+
f"No {MANIFEST_FILENAME} found in {project_path}. "
|
|
85
|
+
"This does not appear to be a pysnap-generated project."
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
89
|
+
except json.JSONDecodeError as exc:
|
|
90
|
+
raise ValueError(f"Corrupt {MANIFEST_FILENAME}: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _serializable_options(options: dict[str, Any]) -> dict[str, Any]:
|
|
99
|
+
"""Return a JSON-serializable copy of *options* (drop derived fields)."""
|
|
100
|
+
skip = {"project_name_slug"}
|
|
101
|
+
return {k: v for k, v in options.items() if k not in skip}
|
pysnap/plugins.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin discovery for pysnap.
|
|
3
|
+
|
|
4
|
+
Plugins register themselves via Python entry points. On install, their
|
|
5
|
+
templates and options automatically appear in the create/add flows.
|
|
6
|
+
|
|
7
|
+
Plugin authors publish a package that declares an entry point in the
|
|
8
|
+
``pysnap.plugins`` group. The entry point must be a callable that
|
|
9
|
+
takes no arguments and returns a list of PluginRegistration dicts.
|
|
10
|
+
|
|
11
|
+
Example pyproject.toml for a plugin:
|
|
12
|
+
|
|
13
|
+
[project.entry-points."pysnap.plugins"]
|
|
14
|
+
my-template = "my_package.plugin:register"
|
|
15
|
+
|
|
16
|
+
Example register() function:
|
|
17
|
+
|
|
18
|
+
def register():
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
"type": "framework",
|
|
22
|
+
"name": "my-framework",
|
|
23
|
+
"display_name": "My Framework",
|
|
24
|
+
"template_path": str(Path(__file__).parent / "templates" / "my-framework"),
|
|
25
|
+
"description": "A custom framework template",
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import importlib.metadata
|
|
33
|
+
import warnings
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pysnap.config import SUPPORTED_DATABASES, SUPPORTED_FRAMEWORKS
|
|
37
|
+
|
|
38
|
+
_REQUIRED_KEYS = {"type", "name", "display_name", "template_path"}
|
|
39
|
+
_VALID_TYPES = {"framework", "database", "addon"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def discover_plugins() -> list[dict[str, Any]]:
|
|
43
|
+
"""Discover and validate all installed pysnap plugins.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
list of plugin registration dicts
|
|
48
|
+
Each dict has at minimum: type, name, display_name, template_path.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
eps = importlib.metadata.entry_points(group="pysnap.plugins")
|
|
52
|
+
except Exception: # noqa: BLE001
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
plugins: list[dict[str, Any]] = []
|
|
56
|
+
for ep in eps:
|
|
57
|
+
try:
|
|
58
|
+
register_fn = ep.load()
|
|
59
|
+
registrations = register_fn()
|
|
60
|
+
if not isinstance(registrations, list):
|
|
61
|
+
registrations = [registrations]
|
|
62
|
+
for reg in registrations:
|
|
63
|
+
validated = _validate(reg, ep.name)
|
|
64
|
+
if validated:
|
|
65
|
+
plugins.append(validated)
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
warnings.warn(
|
|
68
|
+
f"pysnap plugin '{ep.name}' failed to load: {exc}",
|
|
69
|
+
stacklevel=2,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return plugins
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Internal
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate(reg: dict[str, Any], ep_name: str) -> dict[str, Any] | None:
|
|
81
|
+
"""Validate a plugin registration dict. Returns None if invalid."""
|
|
82
|
+
if not isinstance(reg, dict):
|
|
83
|
+
warnings.warn(
|
|
84
|
+
f"Plugin '{ep_name}' returned a non-dict registration.",
|
|
85
|
+
stacklevel=3,
|
|
86
|
+
)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
missing = _REQUIRED_KEYS - reg.keys()
|
|
90
|
+
if missing:
|
|
91
|
+
warnings.warn(
|
|
92
|
+
f"Plugin '{ep_name}' registration missing required keys: {missing}",
|
|
93
|
+
stacklevel=3,
|
|
94
|
+
)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
if reg["type"] not in _VALID_TYPES:
|
|
98
|
+
warnings.warn(
|
|
99
|
+
f"Plugin '{ep_name}' has invalid type {reg['type']!r}. "
|
|
100
|
+
f"Must be one of: {_VALID_TYPES}",
|
|
101
|
+
stacklevel=3,
|
|
102
|
+
)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Check for name conflicts with built-in options
|
|
106
|
+
if reg["type"] == "framework" and reg["name"] in SUPPORTED_FRAMEWORKS:
|
|
107
|
+
warnings.warn(
|
|
108
|
+
f"Plugin '{ep_name}' tried to register framework {reg['name']!r} "
|
|
109
|
+
"which conflicts with a built-in framework. Ignoring.",
|
|
110
|
+
stacklevel=3,
|
|
111
|
+
)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
if reg["type"] == "database" and reg["name"] in SUPPORTED_DATABASES:
|
|
115
|
+
warnings.warn(
|
|
116
|
+
f"Plugin '{ep_name}' tried to register database {reg['name']!r} "
|
|
117
|
+
"which conflicts with a built-in database. Ignoring.",
|
|
118
|
+
stacklevel=3,
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
reg["_plugin_ep"] = ep_name
|
|
123
|
+
return reg
|
pysnap/preview.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-generation file tree preview using Rich.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_preview_tree(template_map: dict[str, str], options: dict[str, Any]) -> Tree:
|
|
15
|
+
"""Build a Rich Tree from the template map with optional file annotations.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
template_map:
|
|
20
|
+
Mapping of template filename -> output path (relative to project root).
|
|
21
|
+
options:
|
|
22
|
+
The current ProjectOptions dict (used to annotate optional files).
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
rich.tree.Tree
|
|
27
|
+
A Rich Tree ready to render.
|
|
28
|
+
"""
|
|
29
|
+
project_name = options.get("project_name", "project")
|
|
30
|
+
tree = Tree(f"[bold cyan]{project_name}/[/bold cyan]")
|
|
31
|
+
|
|
32
|
+
# Group output paths by top-level directory
|
|
33
|
+
dirs: dict[str, list[tuple[str, str | None]]] = {}
|
|
34
|
+
root_files: list[tuple[str, str | None]] = []
|
|
35
|
+
|
|
36
|
+
for tmpl_name, out_rel in template_map.items():
|
|
37
|
+
annotation = _annotation(out_rel, options)
|
|
38
|
+
parts = out_rel.split("/")
|
|
39
|
+
if len(parts) == 1:
|
|
40
|
+
root_files.append((out_rel, annotation))
|
|
41
|
+
else:
|
|
42
|
+
top = parts[0]
|
|
43
|
+
dirs.setdefault(top, []).append((out_rel, annotation))
|
|
44
|
+
|
|
45
|
+
# Root files first
|
|
46
|
+
for path, ann in sorted(root_files):
|
|
47
|
+
_add_leaf(tree, path, ann)
|
|
48
|
+
|
|
49
|
+
# Subdirectories
|
|
50
|
+
for dir_name, entries in sorted(dirs.items()):
|
|
51
|
+
subtree = tree.add(f"[bold]{dir_name}/[/bold]")
|
|
52
|
+
for full_path, ann in sorted(entries):
|
|
53
|
+
relative = "/".join(full_path.split("/")[1:])
|
|
54
|
+
_add_leaf(subtree, relative, ann)
|
|
55
|
+
|
|
56
|
+
return tree
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def show_preview(
|
|
60
|
+
template_map: dict[str, str],
|
|
61
|
+
options: dict[str, Any],
|
|
62
|
+
console: Console,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Render the preview tree and prompt the developer.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
str
|
|
69
|
+
One of: "confirm", "back", "cancel".
|
|
70
|
+
"""
|
|
71
|
+
tree = build_preview_tree(template_map, options)
|
|
72
|
+
console.print()
|
|
73
|
+
console.print("[bold]Project preview:[/bold]")
|
|
74
|
+
console.print(tree)
|
|
75
|
+
console.print(
|
|
76
|
+
f"\n[dim]{len(template_map)} file(s) will be generated[/dim]\n"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
answer = questionary.select(
|
|
80
|
+
"Proceed?",
|
|
81
|
+
choices=[
|
|
82
|
+
questionary.Choice("Looks good, generate!", value="confirm"),
|
|
83
|
+
questionary.Choice("Go back and change options", value="back"),
|
|
84
|
+
questionary.Choice("Cancel", value="cancel"),
|
|
85
|
+
],
|
|
86
|
+
).ask()
|
|
87
|
+
|
|
88
|
+
return answer or "cancel"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Helpers
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_OPTIONAL_ANNOTATIONS = {
|
|
97
|
+
"Dockerfile": "Docker: Yes",
|
|
98
|
+
"docker-compose.yml": "Docker: Yes",
|
|
99
|
+
".dockerignore": "Docker: Yes",
|
|
100
|
+
".github/workflows/ci.yml": "CI: Yes",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_CONDITION_ANNOTATIONS = {
|
|
104
|
+
"when_docker": "Docker: Yes",
|
|
105
|
+
"when_ci": "CI: Yes",
|
|
106
|
+
"when_auth": "Auth: Yes",
|
|
107
|
+
"when_tests": "Tests: Yes",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _annotation(out_path: str, options: dict[str, Any]) -> str | None:
|
|
112
|
+
# Known fixed-name infrastructure files
|
|
113
|
+
if out_path in _OPTIONAL_ANNOTATIONS:
|
|
114
|
+
return _OPTIONAL_ANNOTATIONS[out_path]
|
|
115
|
+
# Test files
|
|
116
|
+
if out_path.startswith("tests/"):
|
|
117
|
+
return "Tests: Yes"
|
|
118
|
+
# Auth files
|
|
119
|
+
if "auth" in out_path.lower() or "security" in out_path.lower():
|
|
120
|
+
return "Auth: Yes" if options.get("include_auth") else None
|
|
121
|
+
# db files
|
|
122
|
+
if "db" in out_path.lower() or "session" in out_path.lower():
|
|
123
|
+
return f"DB: {options.get('database', 'none')}"
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _add_leaf(parent: Tree, name: str, annotation: str | None) -> None:
|
|
128
|
+
if annotation:
|
|
129
|
+
parent.add(f"[green]{name}[/green] [dim]({annotation})[/dim]")
|
|
130
|
+
else:
|
|
131
|
+
parent.add(name)
|
pysnap/prompts.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive prompt logic for pysnap.
|
|
3
|
+
|
|
4
|
+
Accepts a partial options dict (pre-filled from CLI flags) and only prompts
|
|
5
|
+
for keys whose value is None. Plugin-provided choices are merged into the
|
|
6
|
+
framework and database selection lists.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import questionary
|
|
14
|
+
|
|
15
|
+
from pysnap.config import (
|
|
16
|
+
DEFAULT_OPTIONS,
|
|
17
|
+
SUPPORTED_DATABASES,
|
|
18
|
+
SUPPORTED_FRAMEWORKS,
|
|
19
|
+
SUPPORTED_PACKAGE_MANAGERS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Custom questionary theme matching pysnap branding
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
pysnap_style = questionary.Style(
|
|
27
|
+
[
|
|
28
|
+
("qmark", "fg:#ff6b6b bold"),
|
|
29
|
+
("question", "bold"),
|
|
30
|
+
("answer", "fg:#6bffd4 bold"),
|
|
31
|
+
("pointer", "fg:#ff6b6b bold"),
|
|
32
|
+
("highlighted", "fg:#6bffd4 bold"),
|
|
33
|
+
("selected", "fg:#6bffd4"),
|
|
34
|
+
("separator", "fg:#6c5ce7"),
|
|
35
|
+
("instruction", "fg:#aaaaaa"),
|
|
36
|
+
("text", ""),
|
|
37
|
+
("disabled", "fg:#858585 italic"),
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Public API
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ask_project_options(partial: dict[str, Any | None]) -> dict[str, Any] | None:
|
|
48
|
+
"""Prompt for any options that are None in *partial*.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
partial:
|
|
53
|
+
A dict with the same keys as DEFAULT_OPTIONS. Keys with a non-None
|
|
54
|
+
value will skip their corresponding prompt.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
dict
|
|
59
|
+
Completed options dict, or None if the user cancelled.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return _collect(partial)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Internal helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _collect(partial: dict[str, Any | None]) -> dict[str, Any] | None:
|
|
73
|
+
"""Run only the prompts for options that are still None."""
|
|
74
|
+
options: dict[str, Any] = dict(partial)
|
|
75
|
+
|
|
76
|
+
# Merge plugin-provided choices (no-op if no plugins installed)
|
|
77
|
+
framework_choices = _framework_choices()
|
|
78
|
+
database_choices = _database_choices()
|
|
79
|
+
|
|
80
|
+
# --- Framework ---
|
|
81
|
+
if options.get("framework") is None:
|
|
82
|
+
answer = questionary.select(
|
|
83
|
+
"Which framework?",
|
|
84
|
+
choices=framework_choices,
|
|
85
|
+
default=_choice_by_value(framework_choices, DEFAULT_OPTIONS["framework"]),
|
|
86
|
+
style=pysnap_style,
|
|
87
|
+
).ask()
|
|
88
|
+
if answer is None:
|
|
89
|
+
return None
|
|
90
|
+
options["framework"] = answer
|
|
91
|
+
|
|
92
|
+
# --- Database ---
|
|
93
|
+
if options.get("database") is None:
|
|
94
|
+
answer = questionary.select(
|
|
95
|
+
"Which database?",
|
|
96
|
+
choices=database_choices,
|
|
97
|
+
default=_choice_by_value(database_choices, DEFAULT_OPTIONS["database"]),
|
|
98
|
+
style=pysnap_style,
|
|
99
|
+
).ask()
|
|
100
|
+
if answer is None:
|
|
101
|
+
return None
|
|
102
|
+
options["database"] = answer
|
|
103
|
+
|
|
104
|
+
# --- Auth ---
|
|
105
|
+
if options.get("include_auth") is None:
|
|
106
|
+
answer = questionary.confirm(
|
|
107
|
+
"Include JWT authentication?",
|
|
108
|
+
default=DEFAULT_OPTIONS["include_auth"],
|
|
109
|
+
style=pysnap_style,
|
|
110
|
+
).ask()
|
|
111
|
+
if answer is None:
|
|
112
|
+
return None
|
|
113
|
+
options["include_auth"] = answer
|
|
114
|
+
|
|
115
|
+
# --- Docker ---
|
|
116
|
+
if options.get("include_docker") is None:
|
|
117
|
+
answer = questionary.confirm(
|
|
118
|
+
"Include Docker (Dockerfile + docker-compose)?",
|
|
119
|
+
default=DEFAULT_OPTIONS["include_docker"],
|
|
120
|
+
style=pysnap_style,
|
|
121
|
+
).ask()
|
|
122
|
+
if answer is None:
|
|
123
|
+
return None
|
|
124
|
+
options["include_docker"] = answer
|
|
125
|
+
|
|
126
|
+
# --- CI ---
|
|
127
|
+
if options.get("include_ci") is None:
|
|
128
|
+
answer = questionary.confirm(
|
|
129
|
+
"Include GitHub Actions CI?",
|
|
130
|
+
default=DEFAULT_OPTIONS["include_ci"],
|
|
131
|
+
style=pysnap_style,
|
|
132
|
+
).ask()
|
|
133
|
+
if answer is None:
|
|
134
|
+
return None
|
|
135
|
+
options["include_ci"] = answer
|
|
136
|
+
|
|
137
|
+
# --- Package manager ---
|
|
138
|
+
if options.get("package_manager") is None:
|
|
139
|
+
pm_choices = [
|
|
140
|
+
questionary.Choice("uv (recommended)", value="uv"),
|
|
141
|
+
questionary.Choice("pip", value="pip"),
|
|
142
|
+
questionary.Choice("poetry", value="poetry"),
|
|
143
|
+
]
|
|
144
|
+
answer = questionary.select(
|
|
145
|
+
"Which package manager?",
|
|
146
|
+
choices=pm_choices,
|
|
147
|
+
default=pm_choices[0],
|
|
148
|
+
style=pysnap_style,
|
|
149
|
+
).ask()
|
|
150
|
+
if answer is None:
|
|
151
|
+
return None
|
|
152
|
+
options["package_manager"] = answer
|
|
153
|
+
|
|
154
|
+
# --- Tests ---
|
|
155
|
+
if options.get("include_tests") is None:
|
|
156
|
+
answer = questionary.confirm(
|
|
157
|
+
"Include pytest test suite?",
|
|
158
|
+
default=DEFAULT_OPTIONS["include_tests"],
|
|
159
|
+
style=pysnap_style,
|
|
160
|
+
).ask()
|
|
161
|
+
if answer is None:
|
|
162
|
+
return None
|
|
163
|
+
options["include_tests"] = answer
|
|
164
|
+
|
|
165
|
+
return options
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _framework_choices() -> list[questionary.Choice]:
|
|
169
|
+
choices = [
|
|
170
|
+
questionary.Choice(f"{display} (built-in)", value=key)
|
|
171
|
+
for key, display in SUPPORTED_FRAMEWORKS.items()
|
|
172
|
+
]
|
|
173
|
+
# Merge plugin-provided framework choices
|
|
174
|
+
try:
|
|
175
|
+
from pysnap.plugins import discover_plugins
|
|
176
|
+
|
|
177
|
+
for plugin in discover_plugins():
|
|
178
|
+
if plugin.get("type") == "framework":
|
|
179
|
+
choices.append(
|
|
180
|
+
questionary.Choice(
|
|
181
|
+
f"{plugin['display_name']} (plugin)",
|
|
182
|
+
value=plugin["name"],
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
except Exception: # noqa: BLE001
|
|
186
|
+
pass
|
|
187
|
+
return choices
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _database_choices() -> list[questionary.Choice]:
|
|
191
|
+
choices = [
|
|
192
|
+
questionary.Choice(display, value=key)
|
|
193
|
+
for key, display in SUPPORTED_DATABASES.items()
|
|
194
|
+
]
|
|
195
|
+
# Merge plugin-provided database choices
|
|
196
|
+
try:
|
|
197
|
+
from pysnap.plugins import discover_plugins
|
|
198
|
+
|
|
199
|
+
for plugin in discover_plugins():
|
|
200
|
+
if plugin.get("type") == "database":
|
|
201
|
+
choices.append(
|
|
202
|
+
questionary.Choice(
|
|
203
|
+
f"{plugin['display_name']} (plugin)",
|
|
204
|
+
value=plugin["name"],
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
except Exception: # noqa: BLE001
|
|
208
|
+
pass
|
|
209
|
+
return choices
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _choice_by_value(choices: list[questionary.Choice], value: str) -> questionary.Choice:
|
|
213
|
+
"""Return the Choice whose value matches *value*, or the first choice."""
|
|
214
|
+
for c in choices:
|
|
215
|
+
if c.value == value:
|
|
216
|
+
return c
|
|
217
|
+
return choices[0]
|
pysnap/registry.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Community template registry client.
|
|
3
|
+
|
|
4
|
+
Fetches the curated template list from the pysnap GitHub repository and
|
|
5
|
+
supports filtering by keyword and framework. Results are cached locally
|
|
6
|
+
for REGISTRY_CACHE_TTL_SECONDS seconds.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pysnap.config import REGISTRY_CACHE_PATH, REGISTRY_CACHE_TTL_SECONDS, REGISTRY_URL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_registry(url: str = REGISTRY_URL) -> list[dict[str, Any]]:
|
|
22
|
+
"""Fetch the community registry, using a local cache when fresh.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
url:
|
|
27
|
+
Override the registry URL (used in tests to inject mock data).
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
list of RegistryEntry dicts.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
OSError / urllib.error.URLError
|
|
36
|
+
When the registry cannot be reached and there is no cached copy.
|
|
37
|
+
"""
|
|
38
|
+
# Try cache first
|
|
39
|
+
cached = _load_cache()
|
|
40
|
+
if cached is not None:
|
|
41
|
+
return cached
|
|
42
|
+
|
|
43
|
+
# Fetch from network
|
|
44
|
+
entries = _download(url)
|
|
45
|
+
_save_cache(entries)
|
|
46
|
+
return entries
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def search_registry(
|
|
50
|
+
entries: list[dict[str, Any]],
|
|
51
|
+
keyword: str,
|
|
52
|
+
*,
|
|
53
|
+
framework: str | None = None,
|
|
54
|
+
) -> list[dict[str, Any]]:
|
|
55
|
+
"""Filter registry entries by *keyword* and optional *framework*.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
entries:
|
|
60
|
+
Full list of RegistryEntry dicts.
|
|
61
|
+
keyword:
|
|
62
|
+
Case-insensitive search term matched against name, description, tags.
|
|
63
|
+
framework:
|
|
64
|
+
Optional framework filter (exact match on the ``framework`` field).
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
Filtered list, sorted by stars descending.
|
|
69
|
+
"""
|
|
70
|
+
kw = keyword.lower()
|
|
71
|
+
results: list[dict[str, Any]] = []
|
|
72
|
+
|
|
73
|
+
for entry in entries:
|
|
74
|
+
# Framework filter
|
|
75
|
+
if framework and entry.get("framework", "").lower() != framework.lower():
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Keyword match
|
|
79
|
+
searchable = " ".join([
|
|
80
|
+
entry.get("name", ""),
|
|
81
|
+
entry.get("description", ""),
|
|
82
|
+
" ".join(entry.get("tags", [])),
|
|
83
|
+
]).lower()
|
|
84
|
+
|
|
85
|
+
if kw in searchable:
|
|
86
|
+
results.append(entry)
|
|
87
|
+
|
|
88
|
+
# Sort by stars descending
|
|
89
|
+
results.sort(key=lambda e: e.get("stars", 0), reverse=True)
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Cache helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_cache() -> list[dict] | None:
|
|
99
|
+
if not REGISTRY_CACHE_PATH.exists():
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(REGISTRY_CACHE_PATH.read_text(encoding="utf-8"))
|
|
103
|
+
fetched_at = data.get("_fetched_at", 0)
|
|
104
|
+
if time.time() - fetched_at < REGISTRY_CACHE_TTL_SECONDS:
|
|
105
|
+
return data.get("entries", [])
|
|
106
|
+
except Exception: # noqa: BLE001
|
|
107
|
+
pass
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _save_cache(entries: list[dict]) -> None:
|
|
112
|
+
REGISTRY_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
payload = {"_fetched_at": time.time(), "entries": entries}
|
|
114
|
+
REGISTRY_CACHE_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _download(url: str) -> list[dict]:
|
|
118
|
+
req = urllib.request.Request(url, headers={"User-Agent": "pysnap-cli"})
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
121
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
122
|
+
except urllib.error.URLError as exc:
|
|
123
|
+
raise OSError(f"Cannot reach registry at {url}: {exc}") from exc
|