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,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()
|
sum/commands/__init__.py
ADDED
|
@@ -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)
|