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.
Files changed (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. 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)