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
sum/commands/run.py ADDED
@@ -0,0 +1,96 @@
1
+ """Run command for starting SUM projects with virtualenv awareness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import subprocess
8
+
9
+ import click
10
+ from sum.setup.venv import VenvManager
11
+ from sum.utils.environment import (
12
+ ExecutionMode,
13
+ detect_mode,
14
+ find_monorepo_root,
15
+ resolve_project_path,
16
+ )
17
+
18
+
19
+ def find_available_port(start_port: int = 8000, max_attempts: int = 10) -> int:
20
+ """Find an available port starting from start_port.
21
+
22
+ Example:
23
+ port = find_available_port(8000, max_attempts=5)
24
+ # Returns the first available port between 8000 and 8004
25
+ """
26
+ for port in range(start_port, start_port + max_attempts):
27
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
28
+ try:
29
+ sock.bind(("127.0.0.1", port))
30
+ except OSError:
31
+ continue
32
+ return port
33
+ raise RuntimeError(
34
+ f"No available ports found in range {start_port}-{start_port + max_attempts - 1}"
35
+ )
36
+
37
+
38
+ @click.command()
39
+ @click.argument("project", required=False)
40
+ @click.option(
41
+ "--port",
42
+ default=8000,
43
+ type=int,
44
+ help="Development server port (default: 8000)",
45
+ )
46
+ def run(project: str | None, port: int) -> None:
47
+ """Start development server for a project."""
48
+ try:
49
+ project_path = resolve_project_path(project)
50
+ except FileNotFoundError as exc:
51
+ raise click.ClickException(str(exc)) from exc
52
+ project_name = project_path.name
53
+ mode = detect_mode(project_path)
54
+
55
+ venv_manager = VenvManager()
56
+ if not venv_manager.exists(project_path):
57
+ raise click.ClickException(
58
+ f"Virtualenv not found at {project_path / '.venv'}. Run 'sum init --full' or create manually."
59
+ )
60
+
61
+ python_exe = venv_manager.get_python_executable(project_path)
62
+
63
+ try:
64
+ actual_port = find_available_port(port)
65
+ except RuntimeError as exc:
66
+ raise click.ClickException(str(exc)) from exc
67
+
68
+ if actual_port != port:
69
+ click.echo(f"⚠️ Port {port} in use, using {actual_port} instead")
70
+
71
+ click.echo(f"🚀 Starting {project_name}...")
72
+ click.echo()
73
+ click.echo(f"Using virtualenv: {project_path / '.venv'}")
74
+ click.echo(f"Mode: {mode.value}")
75
+ click.echo(f"Python: {python_exe}")
76
+ click.echo()
77
+
78
+ env = os.environ.copy()
79
+ if mode is ExecutionMode.MONOREPO:
80
+ repo_root = find_monorepo_root(project_path)
81
+ if repo_root is not None:
82
+ core_path = repo_root / "core"
83
+ existing = env.get("PYTHONPATH", "")
84
+ if existing:
85
+ env["PYTHONPATH"] = f"{existing}{os.pathsep}{core_path}"
86
+ else:
87
+ env["PYTHONPATH"] = str(core_path)
88
+
89
+ try:
90
+ subprocess.run(
91
+ [str(python_exe), "manage.py", "runserver", f"127.0.0.1:{actual_port}"],
92
+ cwd=project_path,
93
+ env=env,
94
+ )
95
+ except KeyboardInterrupt:
96
+ click.echo("\n👋 Server stopped")
sum/commands/themes.py ADDED
@@ -0,0 +1,56 @@
1
+ """Themes command for listing available themes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from sum.themes_registry import list_themes
8
+
9
+
10
+ def run_themes_list() -> int:
11
+ """
12
+ List all available themes.
13
+
14
+ Returns:
15
+ 0 on success, 1 on failure
16
+ """
17
+ try:
18
+ themes = list_themes()
19
+
20
+ if not themes:
21
+ print("No themes available.")
22
+ return 0
23
+
24
+ print("Available themes:")
25
+ print()
26
+ for theme in themes:
27
+ print(f" {theme.slug}")
28
+ print(f" Name: {theme.name}")
29
+ print(f" Description: {theme.description}")
30
+ print(f" Version: {theme.version}")
31
+ print()
32
+
33
+ return 0
34
+ except Exception as e:
35
+ print(f"[FAIL] Failed to list themes: {e}", file=sys.stderr)
36
+ return 1
37
+
38
+
39
+ # Click command wrapper
40
+ _missing_click: bool = False
41
+ try:
42
+ import click
43
+
44
+ @click.command(name="themes")
45
+ def themes() -> None:
46
+ """List available themes."""
47
+ result = run_themes_list()
48
+ if result != 0:
49
+ raise SystemExit(result)
50
+
51
+ except ImportError:
52
+ _missing_click = True
53
+
54
+ def themes() -> None: # type: ignore[misc]
55
+ """Fallback when click is not installed."""
56
+ raise RuntimeError("Click is required for CLI commands")
sum/commands/update.py ADDED
@@ -0,0 +1,301 @@
1
+ # pyright: reportImplicitStringConcatenation=false, reportUnusedCallResult=false
2
+
3
+ """Update command implementation.
4
+
5
+ Updates an existing site with latest code changes:
6
+ - Git pull
7
+ - Pip install requirements
8
+ - Run migrations
9
+ - Collect static files
10
+ - Restart service
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shlex
16
+ import subprocess
17
+ from pathlib import Path
18
+ from types import ModuleType
19
+
20
+ from sum.exceptions import SetupError
21
+ from sum.setup.database import DatabaseManager
22
+ from sum.setup.deps import DependencyManager
23
+ from sum.setup.venv import VenvManager
24
+ from sum.system_config import ConfigurationError, SystemConfig, get_system_config
25
+ from sum.utils.django import DjangoCommandExecutor
26
+ from sum.utils.environment import ExecutionMode
27
+ from sum.utils.output import OutputFormatter
28
+
29
+ click_module: ModuleType | None
30
+ try:
31
+ import click as click_module
32
+ except ImportError: # pragma: no cover
33
+ click_module = None
34
+
35
+ click: ModuleType | None = click_module
36
+
37
+
38
+ def _run_git_pull(app_dir: Path, config: SystemConfig) -> None:
39
+ """Pull latest changes from git remote.
40
+
41
+ Runs git as the deploy user to handle safe.directory permissions.
42
+ """
43
+ deploy_user = config.defaults.deploy_user
44
+ try:
45
+ result = subprocess.run(
46
+ ["sudo", "-u", deploy_user, "git", "pull", "--ff-only"],
47
+ cwd=app_dir,
48
+ check=True,
49
+ capture_output=True,
50
+ text=True,
51
+ )
52
+ if result.stdout:
53
+ OutputFormatter.info(result.stdout.strip())
54
+ except subprocess.CalledProcessError as exc:
55
+ raise SetupError(f"Git pull failed: {exc.stderr}") from exc
56
+
57
+
58
+ def _restart_service(site_slug: str, config: SystemConfig) -> None:
59
+ """Restart the systemd service for the site."""
60
+ service_name = config.get_systemd_service_name(site_slug)
61
+ try:
62
+ subprocess.run(
63
+ ["systemctl", "restart", service_name],
64
+ check=True,
65
+ capture_output=True,
66
+ )
67
+ except subprocess.CalledProcessError as exc:
68
+ raise SetupError(f"Failed to restart service: {exc.stderr}") from exc
69
+
70
+
71
+ def _run_remote_update(
72
+ site_slug: str,
73
+ config: SystemConfig,
74
+ skip_migrations: bool = False,
75
+ ) -> None:
76
+ """Run update on a remote (production) server via SSH."""
77
+ ssh_host = config.production.ssh_host
78
+ site_dir = config.get_site_dir(site_slug, target="prod")
79
+ app_dir = site_dir / "app"
80
+ venv_python = site_dir / "venv" / "bin" / "python"
81
+ service_name = config.get_systemd_service_name(site_slug)
82
+
83
+ # Quote paths for safe shell interpolation
84
+ q_app_dir = shlex.quote(str(app_dir))
85
+ q_venv_python = shlex.quote(str(venv_python))
86
+
87
+ # Build remote commands
88
+ commands = [
89
+ f"cd {q_app_dir} && git pull --ff-only",
90
+ f"{q_venv_python} -m pip install -r {q_app_dir}/requirements.txt -q",
91
+ ]
92
+
93
+ if not skip_migrations:
94
+ commands.append(
95
+ f"cd {q_app_dir} && {q_venv_python} manage.py migrate --noinput"
96
+ )
97
+
98
+ commands.extend(
99
+ [
100
+ f"cd {q_app_dir} && {q_venv_python} manage.py collectstatic --noinput",
101
+ f"sudo systemctl restart {service_name}",
102
+ ]
103
+ )
104
+
105
+ # Execute each command via SSH with timeout
106
+ for cmd in commands:
107
+ OutputFormatter.info(f"Running: {cmd[:60]}...")
108
+ try:
109
+ subprocess.run(
110
+ ["ssh", ssh_host, cmd],
111
+ check=True,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=300, # 5 minute timeout for long operations
115
+ )
116
+ except subprocess.TimeoutExpired as exc:
117
+ raise SetupError(
118
+ f"Remote command timed out after 5 minutes: {cmd[:60]}..."
119
+ ) from exc
120
+ except subprocess.CalledProcessError as exc:
121
+ raise SetupError(f"Remote command failed: {exc.stderr}") from exc
122
+
123
+
124
+ def run_update(
125
+ site_name: str,
126
+ *,
127
+ target: str = "staging",
128
+ skip_migrations: bool = False,
129
+ ) -> int:
130
+ """Update an existing site with latest code.
131
+
132
+ Args:
133
+ site_name: Name/slug of the site to update.
134
+ target: 'staging' or 'prod'.
135
+ skip_migrations: Skip running database migrations.
136
+
137
+ Returns:
138
+ Exit code (0 for success, non-zero for failure).
139
+ """
140
+ try:
141
+ config = get_system_config()
142
+ except ConfigurationError as exc:
143
+ OutputFormatter.error(str(exc))
144
+ return 1
145
+
146
+ site_dir = config.get_site_dir(site_name, target=target)
147
+
148
+ # For production, delegate to remote execution
149
+ if target == "prod":
150
+ OutputFormatter.header(f"Updating {site_name} on production")
151
+ print()
152
+ try:
153
+ _run_remote_update(site_name, config, skip_migrations)
154
+ except SetupError as exc:
155
+ OutputFormatter.error(str(exc))
156
+ return 1
157
+ OutputFormatter.success(f"Site {site_name} updated on production")
158
+ return 0
159
+
160
+ # For staging, run locally
161
+ app_dir = site_dir / "app"
162
+ venv_path = site_dir / "venv"
163
+
164
+ # Validate site exists
165
+ if not site_dir.exists():
166
+ OutputFormatter.error(f"Site not found: {site_dir}")
167
+ return 1
168
+
169
+ if not app_dir.exists():
170
+ OutputFormatter.error(f"App directory not found: {app_dir}")
171
+ return 1
172
+
173
+ OutputFormatter.header(f"Updating {site_name} on staging")
174
+ print()
175
+
176
+ total_steps = 5 if not skip_migrations else 4
177
+ current_step = 0
178
+
179
+ try:
180
+ # Step 1: Git pull
181
+ current_step += 1
182
+ OutputFormatter.progress(current_step, total_steps, "Pulling latest code", "⏳")
183
+ _run_git_pull(app_dir, config)
184
+ OutputFormatter.progress(current_step, total_steps, "Code updated", "✅")
185
+
186
+ # Step 2: Install dependencies
187
+ current_step += 1
188
+ OutputFormatter.progress(
189
+ current_step, total_steps, "Installing dependencies", "⏳"
190
+ )
191
+ venv_manager = VenvManager(venv_path=venv_path)
192
+ deps_manager = DependencyManager(venv_manager=venv_manager)
193
+ deps_manager.install(app_dir)
194
+ OutputFormatter.progress(
195
+ current_step, total_steps, "Dependencies installed", "✅"
196
+ )
197
+
198
+ # Create Django executor
199
+ django_executor = DjangoCommandExecutor(
200
+ app_dir,
201
+ ExecutionMode.STANDALONE,
202
+ python_path=venv_manager.get_python_executable(app_dir),
203
+ )
204
+
205
+ # Step 3: Run migrations (optional)
206
+ if not skip_migrations:
207
+ current_step += 1
208
+ OutputFormatter.progress(
209
+ current_step, total_steps, "Running migrations", "⏳"
210
+ )
211
+ db_manager = DatabaseManager(django_executor)
212
+ db_manager.migrate()
213
+ OutputFormatter.progress(
214
+ current_step, total_steps, "Migrations complete", "✅"
215
+ )
216
+
217
+ # Step 4: Collect static
218
+ current_step += 1
219
+ OutputFormatter.progress(
220
+ current_step, total_steps, "Collecting static files", "⏳"
221
+ )
222
+ django_executor.run_command(["collectstatic", "--noinput"])
223
+ OutputFormatter.progress(
224
+ current_step, total_steps, "Static files collected", "✅"
225
+ )
226
+
227
+ # Step 5: Restart service
228
+ current_step += 1
229
+ OutputFormatter.progress(current_step, total_steps, "Restarting service", "⏳")
230
+ _restart_service(site_name, config)
231
+ OutputFormatter.progress(current_step, total_steps, "Service restarted", "✅")
232
+
233
+ except SetupError as exc:
234
+ OutputFormatter.error(str(exc))
235
+ return 1
236
+
237
+ print()
238
+ OutputFormatter.success(f"Site {site_name} updated successfully")
239
+ return 0
240
+
241
+
242
+ def _update_command(
243
+ site_name: str,
244
+ target: str,
245
+ skip_migrations: bool,
246
+ ) -> None:
247
+ """Update an existing site."""
248
+ result = run_update(
249
+ site_name,
250
+ target=target,
251
+ skip_migrations=skip_migrations,
252
+ )
253
+ if result != 0:
254
+ raise SystemExit(result)
255
+
256
+
257
+ def _missing_click(*_args: object, **_kwargs: object) -> None:
258
+ raise RuntimeError("click is required to use the update command")
259
+
260
+
261
+ if click is None:
262
+ update = _missing_click
263
+ else:
264
+
265
+ @click.command(name="update")
266
+ @click.argument("site_name")
267
+ @click.option(
268
+ "--target",
269
+ type=click.Choice(["staging", "prod"], case_sensitive=False),
270
+ default="staging",
271
+ show_default=True,
272
+ help="Target environment to update.",
273
+ )
274
+ @click.option(
275
+ "--skip-migrations",
276
+ is_flag=True,
277
+ help="Skip running database migrations.",
278
+ )
279
+ def _click_update(
280
+ site_name: str,
281
+ target: str,
282
+ skip_migrations: bool,
283
+ ) -> None:
284
+ """Update an existing site with latest code.
285
+
286
+ Pulls latest code from git, installs dependencies, runs migrations,
287
+ collects static files, and restarts the service.
288
+
289
+ \b
290
+ Examples:
291
+ sum-platform update acme
292
+ sum-platform update acme --target prod
293
+ sum-platform update acme --skip-migrations
294
+ """
295
+ _update_command(
296
+ site_name,
297
+ target=target,
298
+ skip_migrations=skip_migrations,
299
+ )
300
+
301
+ update = _click_update
sum/config.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TypedDict, Unpack
5
+
6
+
7
+ class SetupConfigArgs(TypedDict, total=False):
8
+ full: bool
9
+ quick: bool
10
+ ci: bool
11
+ no_prompt: bool
12
+ skip_venv: bool
13
+ skip_migrations: bool
14
+ skip_seed: bool
15
+ skip_superuser: bool
16
+ run_server: bool
17
+ port: int
18
+ superuser_username: str
19
+ superuser_email: str
20
+ superuser_password: str
21
+ seed_preset: str | None
22
+ seed_site: str | None
23
+ theme_slug: str
24
+
25
+
26
+ @dataclass
27
+ class SetupConfig:
28
+ """Configuration for setup orchestration."""
29
+
30
+ full: bool = False
31
+ quick: bool = False
32
+ ci: bool = False
33
+ no_prompt: bool = False
34
+
35
+ skip_venv: bool = False
36
+ skip_migrations: bool = False
37
+ skip_seed: bool = False
38
+ skip_superuser: bool = False
39
+
40
+ run_server: bool = False
41
+ port: int = 8000
42
+
43
+ superuser_username: str = "admin"
44
+ superuser_email: str = "admin@example.com"
45
+ superuser_password: str = "admin"
46
+
47
+ seed_preset: str | None = None
48
+ seed_site: str | None = None
49
+ theme_slug: str = "theme_a"
50
+
51
+ def __post_init__(self) -> None:
52
+ """Validate configuration."""
53
+ if self.full and self.quick:
54
+ raise ValueError("--full and --quick are mutually exclusive")
55
+ if self.ci:
56
+ self.no_prompt = True
57
+
58
+ @classmethod
59
+ def from_cli_args(cls, **kwargs: Unpack[SetupConfigArgs]) -> SetupConfig:
60
+ """Create a SetupConfig from CLI arguments."""
61
+ return cls(**kwargs)