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
sum/setup/auth.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Superuser management for CLI setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sum.exceptions import SuperuserError
|
|
12
|
+
from sum.utils.django import DjangoCommandExecutor
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SuperuserResult:
|
|
19
|
+
"""Result of a superuser creation operation.
|
|
20
|
+
|
|
21
|
+
Note:
|
|
22
|
+
credentials_path points to .env.local regardless of the created flag.
|
|
23
|
+
When created=False (user already existed), the file may not exist or
|
|
24
|
+
may contain different credentials. Only trust credentials_path when
|
|
25
|
+
created=True.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
success: bool
|
|
29
|
+
username: str
|
|
30
|
+
credentials_path: Path
|
|
31
|
+
created: bool # True if newly created, False if already existed
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _escape_env_value(value: str) -> str:
|
|
35
|
+
"""Escape value for .env file (wrap in quotes if needed).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
value: The raw value to escape.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The value wrapped in quotes with proper escaping.
|
|
42
|
+
"""
|
|
43
|
+
# Always quote to handle spaces, #, etc.
|
|
44
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
45
|
+
return f'"{escaped}"'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SuperuserManager:
|
|
49
|
+
"""Manages Django superuser creation."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, django_executor: DjangoCommandExecutor, project_path: Path
|
|
53
|
+
) -> None:
|
|
54
|
+
self.django = django_executor
|
|
55
|
+
self.project_path = project_path
|
|
56
|
+
|
|
57
|
+
def user_exists(self, username: str) -> bool:
|
|
58
|
+
"""Check if a specific username already exists.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
username: The username to check.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if the user exists, False otherwise.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
SuperuserError: If the Django shell command fails or returns unexpected output.
|
|
68
|
+
"""
|
|
69
|
+
# Use JSON serialization to safely pass username and prevent injection
|
|
70
|
+
username_json = json.dumps(username)
|
|
71
|
+
result = self.django.run_command(
|
|
72
|
+
[
|
|
73
|
+
"shell",
|
|
74
|
+
"-v",
|
|
75
|
+
"0",
|
|
76
|
+
"-c",
|
|
77
|
+
f"import json; "
|
|
78
|
+
f"from django.contrib.auth import get_user_model; "
|
|
79
|
+
f"username = json.loads({username_json!r}); "
|
|
80
|
+
f"print(get_user_model().objects.filter(username=username).exists())",
|
|
81
|
+
],
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Check return code before parsing output
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise SuperuserError(
|
|
88
|
+
f"Failed to check if user '{username}' exists: {result.stderr}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Validate output before returning
|
|
92
|
+
output = result.stdout.strip().lower()
|
|
93
|
+
if output not in {"true", "false"}:
|
|
94
|
+
raise SuperuserError(
|
|
95
|
+
f"Unexpected output when checking if user '{username}' exists: "
|
|
96
|
+
f"{result.stdout!r}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return output == "true"
|
|
100
|
+
|
|
101
|
+
def create(
|
|
102
|
+
self,
|
|
103
|
+
username: str = "admin",
|
|
104
|
+
email: str = "admin@example.com",
|
|
105
|
+
password: str = "admin",
|
|
106
|
+
) -> SuperuserResult:
|
|
107
|
+
"""Create superuser and store credentials.
|
|
108
|
+
|
|
109
|
+
Idempotency: If user already exists, skips both creation and .env.local
|
|
110
|
+
writing to avoid creating a credentials file that doesn't match reality.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
username: The username for the superuser (default: "admin").
|
|
114
|
+
email: The email for the superuser (default: "admin@example.com").
|
|
115
|
+
password: The password for the superuser (default: "admin").
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
SuperuserError: If superuser creation fails.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
SuperuserResult with success=True, username, credentials path,
|
|
122
|
+
and created flag indicating if the user was newly created.
|
|
123
|
+
"""
|
|
124
|
+
# Check FIRST - don't attempt creation if user exists
|
|
125
|
+
if self.user_exists(username):
|
|
126
|
+
logger.info(f"User '{username}' already exists, skipping creation")
|
|
127
|
+
return SuperuserResult(
|
|
128
|
+
success=True,
|
|
129
|
+
username=username,
|
|
130
|
+
credentials_path=self.project_path / ".env.local",
|
|
131
|
+
created=False,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# User doesn't exist - create it
|
|
135
|
+
result = self.django.run_command(
|
|
136
|
+
[
|
|
137
|
+
"createsuperuser",
|
|
138
|
+
"--noinput",
|
|
139
|
+
"--username",
|
|
140
|
+
username,
|
|
141
|
+
"--email",
|
|
142
|
+
email,
|
|
143
|
+
],
|
|
144
|
+
env={"DJANGO_SUPERUSER_PASSWORD": password},
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if result.returncode != 0:
|
|
149
|
+
details = result.stderr or result.stdout or "Unknown error"
|
|
150
|
+
raise SuperuserError(f"Creation failed: {details}")
|
|
151
|
+
|
|
152
|
+
# Only write .env.local if we actually created the user
|
|
153
|
+
self._save_credentials(username, email, password)
|
|
154
|
+
|
|
155
|
+
return SuperuserResult(
|
|
156
|
+
success=True,
|
|
157
|
+
username=username,
|
|
158
|
+
credentials_path=self.project_path / ".env.local",
|
|
159
|
+
created=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _save_credentials(self, username: str, email: str, password: str) -> None:
|
|
163
|
+
"""Save credentials to .env.local with proper escaping.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
username: The superuser username.
|
|
167
|
+
email: The superuser email.
|
|
168
|
+
password: The superuser password.
|
|
169
|
+
"""
|
|
170
|
+
env_local = self.project_path / ".env.local"
|
|
171
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
172
|
+
|
|
173
|
+
content = f"""# .env.local
|
|
174
|
+
# Auto-generated by sum init on {date_str}
|
|
175
|
+
# DO NOT COMMIT THIS FILE
|
|
176
|
+
DJANGO_SUPERUSER_USERNAME={_escape_env_value(username)}
|
|
177
|
+
DJANGO_SUPERUSER_EMAIL={_escape_env_value(email)}
|
|
178
|
+
DJANGO_SUPERUSER_PASSWORD={_escape_env_value(password)}
|
|
179
|
+
"""
|
|
180
|
+
# Write the file
|
|
181
|
+
env_local.write_text(content)
|
|
182
|
+
|
|
183
|
+
# Set restrictive permissions (owner read/write only) for security
|
|
184
|
+
env_local.chmod(0o600)
|
sum/setup/database.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Database management for CLI setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sum.exceptions import MigrationError
|
|
8
|
+
from sum.utils.django import DjangoCommandExecutor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class MigrationResult:
|
|
13
|
+
"""Result of a migration operation."""
|
|
14
|
+
|
|
15
|
+
success: bool
|
|
16
|
+
output: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DatabaseManager:
|
|
20
|
+
"""Manages database operations."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, django_executor: DjangoCommandExecutor) -> None:
|
|
23
|
+
self.django = django_executor
|
|
24
|
+
|
|
25
|
+
def migrate(self) -> MigrationResult:
|
|
26
|
+
"""Run database migrations.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
MigrationError: If migration fails.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
MigrationResult with success=True and output.
|
|
33
|
+
"""
|
|
34
|
+
result = self.django.run_command(["migrate", "--noinput"], check=False)
|
|
35
|
+
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
message = result.stderr or result.stdout or "Unknown error"
|
|
38
|
+
raise MigrationError(f"Migration failed: {message}")
|
|
39
|
+
|
|
40
|
+
return MigrationResult(success=True, output=result.stdout)
|
|
41
|
+
|
|
42
|
+
def check_migrations(self) -> bool:
|
|
43
|
+
"""Check if migrations are up to date.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if migrations are up to date, False otherwise.
|
|
47
|
+
"""
|
|
48
|
+
result = self.django.run_command(["migrate", "--check"], check=False)
|
|
49
|
+
return result.returncode == 0
|
|
50
|
+
|
|
51
|
+
def get_migration_status(self) -> str:
|
|
52
|
+
"""Get detailed migration status.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Output from showmigrations --plan command.
|
|
56
|
+
"""
|
|
57
|
+
result = self.django.run_command(["showmigrations", "--plan"], check=False)
|
|
58
|
+
return result.stdout
|
sum/setup/deps.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sum.exceptions import DependencyError
|
|
7
|
+
from sum.setup.venv import VenvManager
|
|
8
|
+
from sum.utils.output import OutputFormatter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DependencyManager:
|
|
12
|
+
"""Manage dependency installation for CLI projects."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, venv_manager: VenvManager | None = None) -> None:
|
|
15
|
+
self.venv_manager = venv_manager or VenvManager()
|
|
16
|
+
|
|
17
|
+
def install(self, project_path: Path) -> None:
|
|
18
|
+
"""Install dependencies from requirements.txt using the venv pip."""
|
|
19
|
+
requirements = project_path / "requirements.txt"
|
|
20
|
+
OutputFormatter.progress(1, 1, f"Installing dependencies for {project_path}")
|
|
21
|
+
|
|
22
|
+
if not requirements.exists():
|
|
23
|
+
OutputFormatter.error(f"requirements.txt not found at {requirements}")
|
|
24
|
+
raise DependencyError(f"requirements.txt not found at {requirements}")
|
|
25
|
+
|
|
26
|
+
if not self.venv_manager.exists(project_path):
|
|
27
|
+
OutputFormatter.error("Virtualenv not found for dependency install")
|
|
28
|
+
raise DependencyError("Virtualenv not found for dependency installation")
|
|
29
|
+
|
|
30
|
+
python = self.venv_manager.get_python_executable(project_path)
|
|
31
|
+
try:
|
|
32
|
+
subprocess.run(
|
|
33
|
+
[str(python), "-m", "pip", "install", "-r", str(requirements)],
|
|
34
|
+
check=True,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
)
|
|
38
|
+
except FileNotFoundError as exc:
|
|
39
|
+
OutputFormatter.error("Virtualenv python not found for pip install")
|
|
40
|
+
raise DependencyError(f"Virtualenv python not found at {python}") from exc
|
|
41
|
+
except subprocess.CalledProcessError as exc:
|
|
42
|
+
OutputFormatter.error("pip install failed")
|
|
43
|
+
details = exc.stderr or exc.stdout or "Unknown error"
|
|
44
|
+
raise DependencyError(f"pip install failed: {details}") from exc
|
|
45
|
+
|
|
46
|
+
OutputFormatter.success("Dependencies installed")
|
|
47
|
+
|
|
48
|
+
def verify(self, project_path: Path) -> bool:
|
|
49
|
+
"""Verify key dependencies are installed in the virtualenv."""
|
|
50
|
+
OutputFormatter.progress(1, 1, f"Verifying dependencies for {project_path}")
|
|
51
|
+
|
|
52
|
+
if not self.venv_manager.exists(project_path):
|
|
53
|
+
OutputFormatter.error("Virtualenv not found for dependency verification")
|
|
54
|
+
raise DependencyError("Virtualenv not found for dependency verification")
|
|
55
|
+
|
|
56
|
+
python = self.venv_manager.get_python_executable(project_path)
|
|
57
|
+
required = ["django", "wagtail"]
|
|
58
|
+
missing: list[str] = []
|
|
59
|
+
for package in required:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[str(python), "-c", f"import {package}"],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
missing.append(package)
|
|
67
|
+
|
|
68
|
+
if missing:
|
|
69
|
+
OutputFormatter.error(f"Missing packages: {', '.join(sorted(missing))}")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
OutputFormatter.success("Dependencies verified")
|
|
73
|
+
return True
|