sum-cli 3.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.
- sum/__init__.py +1 -0
- sum/boilerplate/.env.example +124 -0
- sum/boilerplate/.gitea/workflows/ci.yml +33 -0
- sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
- sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
- sum/boilerplate/.github/workflows/ci.yml +36 -0
- sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
- sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
- sum/boilerplate/.gitignore +45 -0
- sum/boilerplate/README.md +259 -0
- sum/boilerplate/manage.py +34 -0
- sum/boilerplate/project_name/__init__.py +5 -0
- sum/boilerplate/project_name/home/__init__.py +5 -0
- sum/boilerplate/project_name/home/apps.py +20 -0
- sum/boilerplate/project_name/home/management/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
- sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
- sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
- sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
- sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
- sum/boilerplate/project_name/home/models.py +13 -0
- sum/boilerplate/project_name/settings/__init__.py +5 -0
- sum/boilerplate/project_name/settings/base.py +348 -0
- sum/boilerplate/project_name/settings/local.py +78 -0
- sum/boilerplate/project_name/settings/production.py +106 -0
- sum/boilerplate/project_name/urls.py +33 -0
- sum/boilerplate/project_name/wsgi.py +16 -0
- sum/boilerplate/pytest.ini +5 -0
- sum/boilerplate/requirements.txt +25 -0
- sum/boilerplate/static/client/.gitkeep +3 -0
- sum/boilerplate/templates/overrides/.gitkeep +3 -0
- sum/boilerplate/tests/__init__.py +3 -0
- sum/boilerplate/tests/test_health.py +51 -0
- sum/cli.py +42 -0
- sum/commands/__init__.py +10 -0
- sum/commands/backup.py +308 -0
- sum/commands/check.py +128 -0
- sum/commands/init.py +265 -0
- sum/commands/promote.py +758 -0
- sum/commands/run.py +96 -0
- sum/commands/themes.py +56 -0
- sum/commands/update.py +301 -0
- sum/config.py +61 -0
- sum/docs/USER_GUIDE.md +663 -0
- sum/exceptions.py +45 -0
- sum/setup/__init__.py +17 -0
- sum/setup/auth.py +184 -0
- sum/setup/database.py +58 -0
- sum/setup/deps.py +73 -0
- sum/setup/git_ops.py +463 -0
- sum/setup/infrastructure.py +576 -0
- sum/setup/orchestrator.py +354 -0
- sum/setup/remote_themes.py +371 -0
- sum/setup/scaffold.py +500 -0
- sum/setup/seed.py +110 -0
- sum/setup/site_orchestrator.py +441 -0
- sum/setup/venv.py +89 -0
- sum/system_config.py +330 -0
- sum/themes_registry.py +180 -0
- sum/utils/__init__.py +25 -0
- sum/utils/django.py +97 -0
- sum/utils/environment.py +76 -0
- sum/utils/output.py +78 -0
- sum/utils/project.py +110 -0
- sum/utils/prompts.py +36 -0
- sum/utils/validation.py +313 -0
- sum_cli-3.0.0.dist-info/METADATA +127 -0
- sum_cli-3.0.0.dist-info/RECORD +72 -0
- sum_cli-3.0.0.dist-info/WHEEL +5 -0
- sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
- sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
- sum_cli-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# pyright: reportUnusedCallResult=false
|
|
2
|
+
|
|
3
|
+
"""Remote theme fetching from sum-themes distribution repository."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from sum.exceptions import SetupError, ThemeNotFoundError
|
|
16
|
+
|
|
17
|
+
# Default repository URL, can be overridden via environment variable
|
|
18
|
+
DEFAULT_SUM_THEMES_REPO = "https://github.com/markashton480/sum-themes.git"
|
|
19
|
+
CACHE_DIR_NAME = "sum-platform"
|
|
20
|
+
THEMES_CACHE_SUBDIR = "themes"
|
|
21
|
+
|
|
22
|
+
# Timeout for git network operations (in seconds)
|
|
23
|
+
GIT_NETWORK_TIMEOUT = 120
|
|
24
|
+
|
|
25
|
+
# Valid theme slug pattern: lowercase letters, numbers, underscores, hyphens
|
|
26
|
+
# Must start with a letter
|
|
27
|
+
THEME_SLUG_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_themes_repo_url() -> str:
|
|
31
|
+
"""Get the themes repository URL from environment or default."""
|
|
32
|
+
return os.getenv("SUM_THEMES_REPO", DEFAULT_SUM_THEMES_REPO)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_theme_slug(slug: str) -> None:
|
|
36
|
+
"""Validate that a theme slug contains only safe characters.
|
|
37
|
+
|
|
38
|
+
Prevents directory traversal attacks and command injection.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
slug: Theme slug to validate.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ThemeNotFoundError: If the slug contains invalid characters.
|
|
45
|
+
"""
|
|
46
|
+
if not THEME_SLUG_PATTERN.match(slug):
|
|
47
|
+
raise ThemeNotFoundError(
|
|
48
|
+
f"Invalid theme slug: {slug!r}. "
|
|
49
|
+
f"Must match pattern: [a-z][a-z0-9_-]* (lowercase, start with letter)"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ThemeSpec:
|
|
55
|
+
"""Parsed theme specification with slug and optional version."""
|
|
56
|
+
|
|
57
|
+
slug: str
|
|
58
|
+
version: str | None
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
if self.version:
|
|
62
|
+
return f"{self.slug}@{self.version}"
|
|
63
|
+
return self.slug
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_theme_spec(theme_input: str) -> ThemeSpec:
|
|
67
|
+
"""Parse theme specification like 'theme_a' or 'theme_a@1.0.0'.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
theme_input: Theme slug, optionally with version suffix.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
ThemeSpec with parsed slug and version.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ThemeNotFoundError: If the input is empty or malformed.
|
|
77
|
+
"""
|
|
78
|
+
theme_input = theme_input.strip()
|
|
79
|
+
if not theme_input:
|
|
80
|
+
raise ThemeNotFoundError("Theme slug cannot be empty")
|
|
81
|
+
|
|
82
|
+
if "@" in theme_input:
|
|
83
|
+
parts = theme_input.split("@", 1)
|
|
84
|
+
slug = parts[0].strip()
|
|
85
|
+
version = parts[1].strip()
|
|
86
|
+
if not slug:
|
|
87
|
+
raise ThemeNotFoundError("Theme slug cannot be empty")
|
|
88
|
+
if not version:
|
|
89
|
+
raise ThemeNotFoundError("Version cannot be empty when @ is used")
|
|
90
|
+
_validate_theme_slug(slug)
|
|
91
|
+
# Strip leading 'v' if present for consistency
|
|
92
|
+
if version.startswith("v"):
|
|
93
|
+
version = version[1:]
|
|
94
|
+
return ThemeSpec(slug=slug, version=version)
|
|
95
|
+
|
|
96
|
+
_validate_theme_slug(theme_input)
|
|
97
|
+
return ThemeSpec(slug=theme_input, version=None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_cache_dir() -> Path:
|
|
101
|
+
"""Get the theme cache directory, creating it if needed.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Path to ~/.cache/sum-platform/themes/
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
SetupError: If the cache directory cannot be created.
|
|
108
|
+
"""
|
|
109
|
+
cache_root = Path.home() / ".cache" / CACHE_DIR_NAME / THEMES_CACHE_SUBDIR
|
|
110
|
+
try:
|
|
111
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise SetupError(
|
|
114
|
+
f"Failed to create theme cache directory '{cache_root}': {exc}. "
|
|
115
|
+
f"Check filesystem permissions and available disk space."
|
|
116
|
+
) from exc
|
|
117
|
+
return cache_root
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_cached_theme(slug: str, version: str) -> Path | None:
|
|
121
|
+
"""Check if a theme version is already cached.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
slug: Theme slug (e.g., 'theme_a').
|
|
125
|
+
version: Theme version (e.g., '1.0.0').
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Path to cached theme if it exists and is valid, None otherwise.
|
|
129
|
+
"""
|
|
130
|
+
cache_dir = get_cache_dir()
|
|
131
|
+
theme_path = cache_dir / slug / version
|
|
132
|
+
if theme_path.is_dir() and (theme_path / "theme.json").is_file():
|
|
133
|
+
return theme_path
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_git_command(
|
|
138
|
+
args: list[str],
|
|
139
|
+
cwd: Path | None = None,
|
|
140
|
+
check: bool = True,
|
|
141
|
+
timeout: int | None = None,
|
|
142
|
+
) -> subprocess.CompletedProcess[str]:
|
|
143
|
+
"""Run a git command and return the result.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
args: Git command arguments (without 'git' prefix).
|
|
147
|
+
cwd: Working directory for the command.
|
|
148
|
+
check: Whether to raise on non-zero exit code.
|
|
149
|
+
timeout: Command timeout in seconds (None for no timeout).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Completed process result.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
subprocess.CalledProcessError: If check=True and command fails.
|
|
156
|
+
subprocess.TimeoutExpired: If command exceeds timeout.
|
|
157
|
+
"""
|
|
158
|
+
cmd = ["git"] + args
|
|
159
|
+
return subprocess.run(
|
|
160
|
+
cmd,
|
|
161
|
+
cwd=cwd,
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
check=check,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_latest_theme_tag(slug: str) -> str | None:
|
|
170
|
+
"""Find the latest version tag for a theme in sum-themes repo.
|
|
171
|
+
|
|
172
|
+
Tags are expected in format: {slug}/v{version} (e.g., theme_a/v1.0.0)
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
slug: Theme slug to find tags for.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Latest version string (without 'v' prefix) or None if no tags found.
|
|
179
|
+
"""
|
|
180
|
+
repo_url = _get_themes_repo_url()
|
|
181
|
+
try:
|
|
182
|
+
result = _run_git_command(
|
|
183
|
+
["ls-remote", "--tags", repo_url, f"refs/tags/{slug}/*"],
|
|
184
|
+
check=True,
|
|
185
|
+
timeout=GIT_NETWORK_TIMEOUT,
|
|
186
|
+
)
|
|
187
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if not result.stdout.strip():
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# Parse tags and find the latest version
|
|
194
|
+
# Format: <hash>\trefs/tags/{slug}/v{version}
|
|
195
|
+
tag_pattern = re.compile(rf"refs/tags/{re.escape(slug)}/v([\d.]+)$")
|
|
196
|
+
versions: list[tuple[int, ...]] = []
|
|
197
|
+
version_strings: dict[tuple[int, ...], str] = {}
|
|
198
|
+
|
|
199
|
+
for line in result.stdout.strip().split("\n"):
|
|
200
|
+
if not line:
|
|
201
|
+
continue
|
|
202
|
+
parts = line.split("\t")
|
|
203
|
+
if len(parts) < 2:
|
|
204
|
+
continue
|
|
205
|
+
ref = parts[1]
|
|
206
|
+
match = tag_pattern.search(ref)
|
|
207
|
+
if match:
|
|
208
|
+
version_str = match.group(1)
|
|
209
|
+
try:
|
|
210
|
+
version_tuple = tuple(int(p) for p in version_str.split("."))
|
|
211
|
+
versions.append(version_tuple)
|
|
212
|
+
version_strings[version_tuple] = version_str
|
|
213
|
+
except ValueError:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if not versions:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# Sort versions and return the latest
|
|
220
|
+
versions.sort(reverse=True)
|
|
221
|
+
return version_strings[versions[0]]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def fetch_theme_from_repo(slug: str, version: str | None = None) -> Path:
|
|
225
|
+
"""Fetch a theme from the sum-themes distribution repository.
|
|
226
|
+
|
|
227
|
+
Uses sparse checkout to fetch only the theme directory.
|
|
228
|
+
Caches the result in ~/.cache/sum-platform/themes/{slug}/{version}/
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
slug: Theme slug (e.g., 'theme_a').
|
|
232
|
+
version: Optional version string. If None, fetches latest.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Path to the fetched (and cached) theme directory.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ThemeNotFoundError: If theme or version cannot be found/fetched.
|
|
239
|
+
"""
|
|
240
|
+
repo_url = _get_themes_repo_url()
|
|
241
|
+
|
|
242
|
+
# Resolve version if not provided
|
|
243
|
+
if version is None:
|
|
244
|
+
version = get_latest_theme_tag(slug)
|
|
245
|
+
if version is None:
|
|
246
|
+
raise ThemeNotFoundError(
|
|
247
|
+
f"No version tags found for theme '{slug}' in {repo_url}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Check cache first
|
|
251
|
+
cached = get_cached_theme(slug, version)
|
|
252
|
+
if cached is not None:
|
|
253
|
+
return cached
|
|
254
|
+
|
|
255
|
+
# Prepare cache directory
|
|
256
|
+
cache_dir = get_cache_dir()
|
|
257
|
+
theme_cache_root = cache_dir / slug
|
|
258
|
+
try:
|
|
259
|
+
theme_cache_root.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
except OSError as exc:
|
|
261
|
+
raise ThemeNotFoundError(
|
|
262
|
+
f"Failed to create theme cache directory '{theme_cache_root}': {exc}"
|
|
263
|
+
) from exc
|
|
264
|
+
version_dir = theme_cache_root / version
|
|
265
|
+
|
|
266
|
+
# Determine git ref (tag format: {slug}/v{version})
|
|
267
|
+
git_ref = f"{slug}/v{version}"
|
|
268
|
+
|
|
269
|
+
# Use temp directory for clone
|
|
270
|
+
with tempfile.TemporaryDirectory(prefix=f"sum-theme-{slug}-") as tmp_dir:
|
|
271
|
+
tmp_path = Path(tmp_dir)
|
|
272
|
+
clone_path = tmp_path / "repo"
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
# Initialize sparse checkout with timeout for network operations
|
|
276
|
+
_run_git_command(
|
|
277
|
+
[
|
|
278
|
+
"clone",
|
|
279
|
+
"--filter=blob:none",
|
|
280
|
+
"--sparse",
|
|
281
|
+
"--depth=1",
|
|
282
|
+
"--branch",
|
|
283
|
+
git_ref,
|
|
284
|
+
repo_url,
|
|
285
|
+
str(clone_path),
|
|
286
|
+
],
|
|
287
|
+
check=True,
|
|
288
|
+
timeout=GIT_NETWORK_TIMEOUT,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Configure sparse checkout for just this theme (local, no timeout needed)
|
|
292
|
+
_run_git_command(
|
|
293
|
+
["sparse-checkout", "set", slug],
|
|
294
|
+
cwd=clone_path,
|
|
295
|
+
check=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
except subprocess.TimeoutExpired as exc:
|
|
299
|
+
raise ThemeNotFoundError(
|
|
300
|
+
f"Timeout while fetching theme '{slug}' from {repo_url}. "
|
|
301
|
+
f"Check your network connection."
|
|
302
|
+
) from exc
|
|
303
|
+
except subprocess.CalledProcessError as exc:
|
|
304
|
+
# Check if it's a tag not found error
|
|
305
|
+
stderr = exc.stderr or ""
|
|
306
|
+
if (
|
|
307
|
+
"Could not find remote branch" in stderr
|
|
308
|
+
or "not found" in stderr.lower()
|
|
309
|
+
):
|
|
310
|
+
raise ThemeNotFoundError(
|
|
311
|
+
f"Theme '{slug}' version '{version}' not found in {repo_url}"
|
|
312
|
+
) from exc
|
|
313
|
+
raise ThemeNotFoundError(
|
|
314
|
+
f"Failed to fetch theme '{slug}': {stderr}"
|
|
315
|
+
) from exc
|
|
316
|
+
|
|
317
|
+
# Verify theme directory exists in checkout
|
|
318
|
+
theme_source = clone_path / slug
|
|
319
|
+
if not theme_source.is_dir():
|
|
320
|
+
raise ThemeNotFoundError(
|
|
321
|
+
f"Theme directory '{slug}' not found in repository at ref '{git_ref}'"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Verify theme.json exists
|
|
325
|
+
if not (theme_source / "theme.json").is_file():
|
|
326
|
+
raise ThemeNotFoundError(
|
|
327
|
+
f"Theme '{slug}' at version '{version}' is missing theme.json"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Move to cache with error handling
|
|
331
|
+
if version_dir.exists():
|
|
332
|
+
try:
|
|
333
|
+
shutil.rmtree(version_dir)
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
raise ThemeNotFoundError(
|
|
336
|
+
f"Failed to remove existing theme cache at '{version_dir}': {exc}"
|
|
337
|
+
) from exc
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
moved_path = Path(shutil.move(str(theme_source), str(version_dir)))
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
raise ThemeNotFoundError(
|
|
343
|
+
f"Failed to move theme to cache at '{version_dir}': {exc}"
|
|
344
|
+
) from exc
|
|
345
|
+
|
|
346
|
+
# Verify the theme ended up where expected
|
|
347
|
+
if moved_path.resolve() != version_dir.resolve():
|
|
348
|
+
raise ThemeNotFoundError(
|
|
349
|
+
f"Theme cache path mismatch for '{slug}' version '{version}': "
|
|
350
|
+
f"expected '{version_dir}', got '{moved_path}'"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return version_dir
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def resolve_remote_theme(theme_input: str) -> Path:
|
|
357
|
+
"""Resolve a theme specification to a local path, fetching if needed.
|
|
358
|
+
|
|
359
|
+
This is the main entry point for remote theme resolution.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
theme_input: Theme specification (e.g., 'theme_a' or 'theme_a@1.0.0').
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Path to the theme directory (either cached or freshly fetched).
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
ThemeNotFoundError: If theme cannot be resolved or fetched.
|
|
369
|
+
"""
|
|
370
|
+
spec = parse_theme_spec(theme_input)
|
|
371
|
+
return fetch_theme_from_repo(spec.slug, spec.version)
|