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.
Files changed (102) hide show
  1. pysnap/__init__.py +1 -0
  2. pysnap/_shared/Dockerfile.j2 +34 -0
  3. pysnap/_shared/ci.yml.j2 +53 -0
  4. pysnap/_shared/docker-compose.yml.j2 +46 -0
  5. pysnap/_shared/dockerignore.j2 +13 -0
  6. pysnap/_shared/env_example.j2 +24 -0
  7. pysnap/_shared/gitignore.j2 +15 -0
  8. pysnap/commands/__init__.py +1 -0
  9. pysnap/commands/add.py +171 -0
  10. pysnap/commands/create.py +136 -0
  11. pysnap/commands/templates_cmd.py +134 -0
  12. pysnap/commands/update.py +133 -0
  13. pysnap/community.py +113 -0
  14. pysnap/config.py +76 -0
  15. pysnap/generator.py +262 -0
  16. pysnap/main.py +65 -0
  17. pysnap/manifest.py +101 -0
  18. pysnap/plugins.py +123 -0
  19. pysnap/preview.py +131 -0
  20. pysnap/prompts.py +217 -0
  21. pysnap/registry.py +123 -0
  22. pysnap/templates/django/.dockerignore.j2 +15 -0
  23. pysnap/templates/django/.github/workflows/ci.yml.j2 +34 -0
  24. pysnap/templates/django/.gitignore.j2 +14 -0
  25. pysnap/templates/django/Dockerfile.j2 +14 -0
  26. pysnap/templates/django/README.md.j2 +36 -0
  27. pysnap/templates/django/apps/__init__.py.j2 +0 -0
  28. pysnap/templates/django/apps/core/__init__.py.j2 +0 -0
  29. pysnap/templates/django/apps/core/apps.py.j2 +6 -0
  30. pysnap/templates/django/apps/core/urls.py.j2 +7 -0
  31. pysnap/templates/django/apps/core/views.py.j2 +6 -0
  32. pysnap/templates/django/apps/users/__init__.py.j2 +0 -0
  33. pysnap/templates/django/apps/users/apps.py.j2 +6 -0
  34. pysnap/templates/django/apps/users/models.py.j2 +14 -0
  35. pysnap/templates/django/apps/users/serializers.py.j2 +13 -0
  36. pysnap/templates/django/apps/users/urls.py.j2 +10 -0
  37. pysnap/templates/django/apps/users/views.py.j2 +22 -0
  38. pysnap/templates/django/config/__init__.py.j2 +0 -0
  39. pysnap/templates/django/config/asgi.py.j2 +9 -0
  40. pysnap/templates/django/config/settings.py.j2 +110 -0
  41. pysnap/templates/django/config/urls.py.j2 +12 -0
  42. pysnap/templates/django/config/wsgi.py.j2 +9 -0
  43. pysnap/templates/django/docker-compose.yml.j2 +29 -0
  44. pysnap/templates/django/manage.py.j2 +22 -0
  45. pysnap/templates/django/pyproject.toml.j2 +40 -0
  46. pysnap/templates/django/template.json +50 -0
  47. pysnap/templates/django/tests/__init__.py.j2 +1 -0
  48. pysnap/templates/django/tests/conftest.py.j2 +6 -0
  49. pysnap/templates/django/tests/test_health.py.j2 +9 -0
  50. pysnap/templates/fastapi/.dockerignore.j2 +8 -0
  51. pysnap/templates/fastapi/.github/workflows/ci.yml.j2 +46 -0
  52. pysnap/templates/fastapi/.gitignore.j2 +13 -0
  53. pysnap/templates/fastapi/Dockerfile.j2 +14 -0
  54. pysnap/templates/fastapi/README.md.j2 +57 -0
  55. pysnap/templates/fastapi/api/__init__.py.j2 +0 -0
  56. pysnap/templates/fastapi/api/routes/__init__.py.j2 +0 -0
  57. pysnap/templates/fastapi/api/routes/auth.py.j2 +18 -0
  58. pysnap/templates/fastapi/api/routes/health.py.j2 +8 -0
  59. pysnap/templates/fastapi/app/__init__.py.j2 +1 -0
  60. pysnap/templates/fastapi/core/__init__.py.j2 +0 -0
  61. pysnap/templates/fastapi/core/config.py.j2 +26 -0
  62. pysnap/templates/fastapi/core/security.py.j2 +22 -0
  63. pysnap/templates/fastapi/db/__init__.py.j2 +0 -0
  64. pysnap/templates/fastapi/db/base.py.j2 +5 -0
  65. pysnap/templates/fastapi/db/session.py.j2 +15 -0
  66. pysnap/templates/fastapi/docker-compose.yml.j2 +30 -0
  67. pysnap/templates/fastapi/main.py.j2 +27 -0
  68. pysnap/templates/fastapi/models/__init__.py.j2 +0 -0
  69. pysnap/templates/fastapi/models/user.py.j2 +13 -0
  70. pysnap/templates/fastapi/pyproject.toml.j2 +48 -0
  71. pysnap/templates/fastapi/schemas/__init__.py.j2 +0 -0
  72. pysnap/templates/fastapi/schemas/user.py.j2 +18 -0
  73. pysnap/templates/fastapi/template.json +53 -0
  74. pysnap/templates/fastapi/tests/__init__.py.j2 +0 -0
  75. pysnap/templates/fastapi/tests/conftest.py.j2 +9 -0
  76. pysnap/templates/fastapi/tests/test_health.py.j2 +9 -0
  77. pysnap/templates/flask/.dockerignore.j2 +14 -0
  78. pysnap/templates/flask/.github/workflows/ci.yml.j2 +34 -0
  79. pysnap/templates/flask/.gitignore.j2 +13 -0
  80. pysnap/templates/flask/Dockerfile.j2 +14 -0
  81. pysnap/templates/flask/README.md.j2 +34 -0
  82. pysnap/templates/flask/app/__init__.py.j2 +30 -0
  83. pysnap/templates/flask/app/config.py.j2 +23 -0
  84. pysnap/templates/flask/app/extensions.py.j2 +9 -0
  85. pysnap/templates/flask/app/models/__init__.py.j2 +1 -0
  86. pysnap/templates/flask/app/models/user.py.j2 +16 -0
  87. pysnap/templates/flask/app/routes/__init__.py.j2 +1 -0
  88. pysnap/templates/flask/app/routes/auth.py.j2 +31 -0
  89. pysnap/templates/flask/app/routes/health.py.j2 +11 -0
  90. pysnap/templates/flask/docker-compose.yml.j2 +29 -0
  91. pysnap/templates/flask/pyproject.toml.j2 +39 -0
  92. pysnap/templates/flask/template.json +44 -0
  93. pysnap/templates/flask/tests/__init__.py.j2 +1 -0
  94. pysnap/templates/flask/tests/conftest.py.j2 +16 -0
  95. pysnap/templates/flask/tests/test_health.py.j2 +8 -0
  96. pysnap/templates/flask/wsgi.py.j2 +8 -0
  97. pysnap/validator.py +89 -0
  98. snapstack-1.0.0.dist-info/METADATA +267 -0
  99. snapstack-1.0.0.dist-info/RECORD +102 -0
  100. snapstack-1.0.0.dist-info/WHEEL +4 -0
  101. snapstack-1.0.0.dist-info/entry_points.txt +2 -0
  102. 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