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,51 @@
1
+ """
2
+ Integration tests for the /health/ endpoint.
3
+
4
+ This test verifies that sum_core endpoints are correctly wired into this client project.
5
+ It validates the actual health check contract without mocking core internals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from django.urls import reverse
12
+
13
+
14
+ @pytest.mark.django_db
15
+ def test_health_endpoint_returns_200_with_json(client) -> None:
16
+ """
17
+ Integration test: /health/ returns HTTP 200 with JSON in the healthy baseline.
18
+
19
+ This test proves that the client project correctly wires the sum_core ops endpoint
20
+ and validates the real health check contract (ok/degraded=200, unhealthy=503).
21
+ """
22
+ response = client.get(reverse("health_check"))
23
+
24
+ # In the baseline/healthy state, expect HTTP 200
25
+ assert response.status_code == 200
26
+
27
+ # Response should be JSON
28
+ data = response.json()
29
+ assert isinstance(data, dict), "Health endpoint must return JSON object"
30
+
31
+
32
+ @pytest.mark.django_db
33
+ def test_health_endpoint_has_required_keys(client) -> None:
34
+ """
35
+ Integration test: /health/ JSON response has required structure.
36
+
37
+ Verifies the endpoint returns 'status' and 'checks' keys without asserting
38
+ exact check ordering or exhaustive payload contents.
39
+ """
40
+ response = client.get(reverse("health_check"))
41
+ data = response.json()
42
+
43
+ # Verify required keys are present
44
+ assert "status" in data, "Response JSON must have 'status' key"
45
+ assert "checks" in data, "Response JSON must have 'checks' key"
46
+
47
+ # Verify status is a valid string (don't hardcode exact value)
48
+ assert isinstance(data["status"], str), "'status' must be a string"
49
+
50
+ # Verify checks is a dict (don't assert specific checks or ordering)
51
+ assert isinstance(data["checks"], dict), "'checks' must be a dictionary"
sum/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ """Command-line entrypoint wiring for the SUM platform CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ import click
8
+ from sum.commands.backup import backup
9
+ from sum.commands.check import check
10
+ from sum.commands.init import init
11
+ from sum.commands.promote import promote
12
+ from sum.commands.run import run
13
+ from sum.commands.themes import themes
14
+ from sum.commands.update import update
15
+
16
+
17
+ def _get_version() -> str:
18
+ try:
19
+ return importlib.metadata.version("sum-cli")
20
+ except importlib.metadata.PackageNotFoundError:
21
+ return "0.0.0"
22
+
23
+
24
+ @click.group()
25
+ @click.version_option(
26
+ version=_get_version(), prog_name="sum-platform", message="%(prog)s %(version)s"
27
+ )
28
+ def cli() -> None:
29
+ """SUM Platform CLI (v2)."""
30
+
31
+
32
+ cli.add_command(backup)
33
+ cli.add_command(check)
34
+ cli.add_command(init)
35
+ cli.add_command(promote)
36
+ cli.add_command(run)
37
+ cli.add_command(themes)
38
+ cli.add_command(update)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ cli()
@@ -0,0 +1,10 @@
1
+ """CLI command implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sum.commands.check import check
6
+ from sum.commands.init import init
7
+ from sum.commands.run import run
8
+ from sum.commands.themes import themes
9
+
10
+ __all__ = ["check", "init", "run", "themes"]
sum/commands/backup.py ADDED
@@ -0,0 +1,308 @@
1
+ # pyright: reportImplicitStringConcatenation=false, reportUnusedCallResult=false
2
+
3
+ """Backup command implementation.
4
+
5
+ Creates backups of site database and optionally media files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ from sum.exceptions import SetupError
17
+ from sum.system_config import ConfigurationError, SystemConfig, get_system_config
18
+ from sum.utils.output import OutputFormatter
19
+
20
+ click_module: ModuleType | None
21
+ try:
22
+ import click as click_module
23
+ except ImportError: # pragma: no cover
24
+ click_module = None
25
+
26
+ click: ModuleType | None = click_module
27
+
28
+
29
+ def _generate_backup_filename(site_slug: str, suffix: str = "sql") -> str:
30
+ """Generate a timestamped backup filename."""
31
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
32
+ return f"{site_slug}_{timestamp}.{suffix}.gz"
33
+
34
+
35
+ def _backup_database(
36
+ site_slug: str,
37
+ backup_dir: Path,
38
+ config: SystemConfig,
39
+ ) -> Path:
40
+ """Create a compressed database backup using pg_dump."""
41
+ # Fail fast if backup directory is not writable
42
+ if not os.access(backup_dir, os.W_OK):
43
+ raise SetupError(f"Backup directory is not writable: {backup_dir}")
44
+
45
+ db_name = config.get_db_name(site_slug)
46
+ backup_filename = _generate_backup_filename(site_slug, "sql")
47
+ backup_path = backup_dir / backup_filename
48
+
49
+ try:
50
+ # pg_dump | gzip > backup_file
51
+ with open(backup_path, "wb") as f:
52
+ pg_dump = subprocess.Popen(
53
+ ["sudo", "-u", "postgres", "pg_dump", db_name],
54
+ stdout=subprocess.PIPE,
55
+ stderr=subprocess.PIPE,
56
+ )
57
+ gzip = subprocess.Popen(
58
+ ["gzip"],
59
+ stdin=pg_dump.stdout,
60
+ stdout=f,
61
+ stderr=subprocess.PIPE,
62
+ )
63
+ if pg_dump.stdout:
64
+ pg_dump.stdout.close()
65
+
66
+ gzip.communicate()
67
+ pg_dump.wait()
68
+
69
+ if pg_dump.returncode != 0:
70
+ stderr = pg_dump.stderr.read() if pg_dump.stderr else b""
71
+ raise SetupError(f"pg_dump failed: {stderr.decode()}")
72
+
73
+ if gzip.returncode != 0:
74
+ raise SetupError("gzip compression failed")
75
+
76
+ except OSError as exc:
77
+ raise SetupError(f"Failed to create backup: {exc}") from exc
78
+
79
+ return backup_path
80
+
81
+
82
+ def _backup_media(
83
+ site_slug: str,
84
+ site_dir: Path,
85
+ backup_dir: Path,
86
+ ) -> Path:
87
+ """Create a compressed tarball of the media directory."""
88
+ media_dir = site_dir / "media"
89
+ backup_filename = _generate_backup_filename(site_slug, "media.tar")
90
+ backup_path = backup_dir / backup_filename
91
+
92
+ if not media_dir.exists():
93
+ raise SetupError(f"Media directory not found: {media_dir}")
94
+
95
+ try:
96
+ # tar czf backup_file -C media_dir .
97
+ with open(backup_path, "wb") as f:
98
+ tar = subprocess.Popen(
99
+ ["tar", "czf", "-", "-C", str(media_dir), "."],
100
+ stdout=f,
101
+ stderr=subprocess.PIPE,
102
+ )
103
+ _, stderr = tar.communicate()
104
+
105
+ if tar.returncode != 0:
106
+ raise SetupError(f"tar failed: {stderr.decode()}")
107
+
108
+ except OSError as exc:
109
+ raise SetupError(f"Failed to create media backup: {exc}") from exc
110
+
111
+ return backup_path
112
+
113
+
114
+ def _run_remote_backup(
115
+ site_slug: str,
116
+ config: SystemConfig,
117
+ include_media: bool = False,
118
+ ) -> list[str]:
119
+ """Run backup on a remote (production) server via SSH."""
120
+ ssh_host = config.production.ssh_host
121
+ site_dir = config.get_site_dir(site_slug, target="prod")
122
+ backup_dir = site_dir / "backups"
123
+ db_name = config.get_db_name(site_slug)
124
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
125
+
126
+ backup_files: list[str] = []
127
+
128
+ # Database backup command
129
+ db_backup = f"{site_slug}_{timestamp}.sql.gz"
130
+ db_cmd = f"sudo -u postgres pg_dump {db_name} | gzip > {backup_dir}/{db_backup}"
131
+
132
+ OutputFormatter.info("Creating database backup...")
133
+ try:
134
+ subprocess.run(
135
+ ["ssh", ssh_host, db_cmd],
136
+ check=True,
137
+ capture_output=True,
138
+ text=True,
139
+ )
140
+ backup_files.append(f"{backup_dir}/{db_backup}")
141
+ except subprocess.CalledProcessError as exc:
142
+ raise SetupError(f"Remote database backup failed: {exc.stderr}") from exc
143
+
144
+ # Media backup (optional)
145
+ if include_media:
146
+ media_backup = f"{site_slug}_{timestamp}.media.tar.gz"
147
+ media_cmd = f"tar czf {backup_dir}/{media_backup} -C {site_dir}/media ."
148
+
149
+ OutputFormatter.info("Creating media backup...")
150
+ try:
151
+ subprocess.run(
152
+ ["ssh", ssh_host, media_cmd],
153
+ check=True,
154
+ capture_output=True,
155
+ text=True,
156
+ )
157
+ backup_files.append(f"{backup_dir}/{media_backup}")
158
+ except subprocess.CalledProcessError as exc:
159
+ raise SetupError(f"Remote media backup failed: {exc.stderr}") from exc
160
+
161
+ return backup_files
162
+
163
+
164
+ def run_backup(
165
+ site_name: str,
166
+ *,
167
+ target: str = "staging",
168
+ include_media: bool = False,
169
+ ) -> int:
170
+ """Create a backup of site database and optionally media.
171
+
172
+ Args:
173
+ site_name: Name/slug of the site to backup.
174
+ target: 'staging' or 'prod'.
175
+ include_media: Also backup the media directory.
176
+
177
+ Returns:
178
+ Exit code (0 for success, non-zero for failure).
179
+ """
180
+ try:
181
+ config = get_system_config()
182
+ except ConfigurationError as exc:
183
+ OutputFormatter.error(str(exc))
184
+ return 1
185
+
186
+ site_dir = config.get_site_dir(site_name, target=target)
187
+
188
+ # For production, delegate to remote execution
189
+ if target == "prod":
190
+ OutputFormatter.header(f"Backing up {site_name} on production")
191
+ print()
192
+ try:
193
+ backup_files = _run_remote_backup(site_name, config, include_media)
194
+ except SetupError as exc:
195
+ OutputFormatter.error(str(exc))
196
+ return 1
197
+
198
+ print()
199
+ OutputFormatter.success("Backup completed:")
200
+ for path in backup_files:
201
+ print(f" - {path}")
202
+ return 0
203
+
204
+ # For staging, run locally
205
+ backup_dir = site_dir / "backups"
206
+
207
+ # Validate site exists
208
+ if not site_dir.exists():
209
+ OutputFormatter.error(f"Site not found: {site_dir}")
210
+ return 1
211
+
212
+ # Ensure backup directory exists
213
+ backup_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ OutputFormatter.header(f"Backing up {site_name} on staging")
216
+ print()
217
+
218
+ backup_files: list[Path] = []
219
+
220
+ try:
221
+ # Database backup
222
+ OutputFormatter.info("Creating database backup...")
223
+ db_backup = _backup_database(site_name, backup_dir, config)
224
+ backup_files.append(db_backup)
225
+ OutputFormatter.success(f"Database backup: {db_backup.name}")
226
+
227
+ # Media backup (optional)
228
+ if include_media:
229
+ OutputFormatter.info("Creating media backup...")
230
+ media_backup = _backup_media(site_name, site_dir, backup_dir)
231
+ backup_files.append(media_backup)
232
+ OutputFormatter.success(f"Media backup: {media_backup.name}")
233
+
234
+ except SetupError as exc:
235
+ OutputFormatter.error(str(exc))
236
+ return 1
237
+
238
+ print()
239
+ OutputFormatter.success("Backup completed:")
240
+ for path in backup_files:
241
+ size_mb = path.stat().st_size / (1024 * 1024)
242
+ print(f" - {path} ({size_mb:.1f} MB)")
243
+
244
+ return 0
245
+
246
+
247
+ def _backup_command(
248
+ site_name: str,
249
+ target: str,
250
+ include_media: bool,
251
+ ) -> None:
252
+ """Create a backup of an existing site."""
253
+ result = run_backup(
254
+ site_name,
255
+ target=target,
256
+ include_media=include_media,
257
+ )
258
+ if result != 0:
259
+ raise SystemExit(result)
260
+
261
+
262
+ def _missing_click(*_args: object, **_kwargs: object) -> None:
263
+ raise RuntimeError("click is required to use the backup command")
264
+
265
+
266
+ if click is None:
267
+ backup = _missing_click
268
+ else:
269
+
270
+ @click.command(name="backup")
271
+ @click.argument("site_name")
272
+ @click.option(
273
+ "--target",
274
+ type=click.Choice(["staging", "prod"], case_sensitive=False),
275
+ default="staging",
276
+ show_default=True,
277
+ help="Target environment to backup.",
278
+ )
279
+ @click.option(
280
+ "--include-media",
281
+ is_flag=True,
282
+ help="Also backup the media directory.",
283
+ )
284
+ def _click_backup(
285
+ site_name: str,
286
+ target: str,
287
+ include_media: bool,
288
+ ) -> None:
289
+ """Create a backup of site database and media.
290
+
291
+ Creates a compressed backup of the PostgreSQL database.
292
+ Optionally includes the media directory.
293
+
294
+ Backups are stored in /srv/sum/<site>/backups/
295
+
296
+ \b
297
+ Examples:
298
+ sum-platform backup acme
299
+ sum-platform backup acme --include-media
300
+ sum-platform backup acme --target prod
301
+ """
302
+ _backup_command(
303
+ site_name,
304
+ target=target,
305
+ include_media=include_media,
306
+ )
307
+
308
+ backup = _click_backup
sum/commands/check.py ADDED
@@ -0,0 +1,128 @@
1
+ """Check command for validating SUM project readiness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from sum.utils.environment import ExecutionMode, detect_mode, resolve_project_path
11
+ from sum.utils.validation import ProjectValidator, ValidationResult, ValidationStatus
12
+
13
+
14
+ def _resolve_project_path(project: str | None) -> Path:
15
+ return resolve_project_path(project)
16
+
17
+
18
+ def _format_status(status: ValidationStatus) -> str:
19
+ if status is ValidationStatus.OK:
20
+ return "[OK]"
21
+ if status is ValidationStatus.SKIP:
22
+ return "[SKIP]"
23
+ return "[FAIL]"
24
+
25
+
26
+ def _virtualenv_check(validator: ProjectValidator) -> ValidationResult:
27
+ venv_result = validator.check_venv_exists()
28
+ if venv_result.failed:
29
+ return venv_result
30
+
31
+ package_result = validator.check_packages_installed()
32
+ if package_result.failed or package_result.skipped:
33
+ return package_result
34
+
35
+ return ValidationResult.ok(".venv exists with required packages")
36
+
37
+
38
+ def run_enhanced_checks(project_path: Path, mode: ExecutionMode) -> None:
39
+ """Run enhanced validation checks.
40
+
41
+ Example:
42
+ run_enhanced_checks(Path('clients/demo'), ExecutionMode.STANDALONE)
43
+ """
44
+ validator = ProjectValidator(project_path, mode)
45
+
46
+ checks: list[tuple[str, Callable[[], ValidationResult]]] = [
47
+ ("Virtualenv", lambda: _virtualenv_check(validator)),
48
+ ("Credentials", validator.check_env_local),
49
+ ("Database", validator.check_migrations_applied),
50
+ ("Homepage", validator.check_homepage_exists),
51
+ ("Theme compiled CSS", validator.check_theme_compiled_css),
52
+ ("Theme slug match", validator.check_theme_slug_match),
53
+ ("Required env vars", validator.check_required_env_vars),
54
+ ("sum_core import", validator.check_sum_core_import),
55
+ ]
56
+
57
+ has_failures = False
58
+
59
+ for name, check_func in checks:
60
+ result = check_func()
61
+ status = _format_status(result.status)
62
+ click.echo(f"{status} {name}: {result.message}")
63
+ if result.failed and result.remediation:
64
+ click.echo(f" → {result.remediation}")
65
+ has_failures = True
66
+ elif result.failed:
67
+ has_failures = True
68
+
69
+ if has_failures:
70
+ click.echo("\n❌ Some checks failed")
71
+ sys.exit(1)
72
+
73
+ click.echo("\n✅ All checks passed")
74
+
75
+
76
+ def run_check(project_path: str | Path | None = None) -> int:
77
+ """V1-compatible check that returns exit code instead of calling sys.exit.
78
+
79
+ Note: This runs a reduced check set (6 checks) for backward compatibility.
80
+ Omits Virtualenv and Database checks which require Django environment setup.
81
+ For full validation (8 checks), use run_enhanced_checks() instead.
82
+ """
83
+ try:
84
+ path = _resolve_project_path(str(project_path) if project_path else None)
85
+ except FileNotFoundError:
86
+ return 1
87
+ mode = detect_mode(path)
88
+ validator = ProjectValidator(path, mode)
89
+
90
+ checks: list[tuple[str, Callable[[], ValidationResult]]] = [
91
+ ("Credentials", validator.check_env_local),
92
+ ("Homepage", validator.check_homepage_exists),
93
+ ("Theme compiled CSS", validator.check_theme_compiled_css),
94
+ ("Theme slug match", validator.check_theme_slug_match),
95
+ ("Required env vars", validator.check_required_env_vars),
96
+ ("sum_core import", validator.check_sum_core_import),
97
+ ]
98
+
99
+ has_failures = False
100
+ for name, check_func in checks:
101
+ result = check_func()
102
+ status = _format_status(result.status)
103
+ click.echo(f"{status} {name}: {result.message}")
104
+ if result.failed and result.remediation:
105
+ click.echo(f" → {result.remediation}")
106
+ has_failures = True
107
+ elif result.failed:
108
+ has_failures = True
109
+
110
+ if has_failures:
111
+ click.echo("\n❌ Some checks failed")
112
+ return 1
113
+
114
+ click.echo("\n✅ All checks passed")
115
+ return 0
116
+
117
+
118
+ @click.command()
119
+ @click.argument("project", required=False)
120
+ def check(project: str | None) -> None:
121
+ """Validate project setup."""
122
+ try:
123
+ path = _resolve_project_path(project)
124
+ except FileNotFoundError as exc:
125
+ click.echo(f"Error: {exc}", err=True)
126
+ raise SystemExit(1)
127
+ mode = detect_mode(path)
128
+ run_enhanced_checks(path, mode)