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,441 @@
|
|
|
1
|
+
"""Site orchestration for production deployment.
|
|
2
|
+
|
|
3
|
+
Handles the full workflow of creating a working site on staging:
|
|
4
|
+
- Directory structure at /srv/sum/<name>/
|
|
5
|
+
- Postgres database with credentials
|
|
6
|
+
- Code scaffolding
|
|
7
|
+
- External venv
|
|
8
|
+
- Django setup (migrate, seed, superuser, collectstatic)
|
|
9
|
+
- Systemd service
|
|
10
|
+
- Caddy configuration
|
|
11
|
+
- Git repository
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from sum.exceptions import SeedError, SetupError
|
|
21
|
+
from sum.setup.auth import SuperuserManager
|
|
22
|
+
from sum.setup.database import DatabaseManager
|
|
23
|
+
from sum.setup.deps import DependencyManager
|
|
24
|
+
from sum.setup.git_ops import setup_git_for_site
|
|
25
|
+
from sum.setup.infrastructure import (
|
|
26
|
+
SiteCredentials,
|
|
27
|
+
check_infrastructure,
|
|
28
|
+
cleanup_site,
|
|
29
|
+
configure_caddy,
|
|
30
|
+
create_postgres_database,
|
|
31
|
+
create_site_directories,
|
|
32
|
+
fix_site_ownership,
|
|
33
|
+
generate_site_credentials,
|
|
34
|
+
install_systemd_service,
|
|
35
|
+
start_site_service,
|
|
36
|
+
write_credentials_file,
|
|
37
|
+
write_env_file,
|
|
38
|
+
)
|
|
39
|
+
from sum.setup.scaffold import scaffold_project
|
|
40
|
+
from sum.setup.venv import VenvManager
|
|
41
|
+
from sum.system_config import SystemConfig, get_system_config
|
|
42
|
+
from sum.utils.django import DjangoCommandExecutor
|
|
43
|
+
from sum.utils.environment import ExecutionMode
|
|
44
|
+
from sum.utils.output import OutputFormatter
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SiteSetupResult:
|
|
49
|
+
"""Result of site setup operation."""
|
|
50
|
+
|
|
51
|
+
success: bool
|
|
52
|
+
site_slug: str
|
|
53
|
+
site_dir: Path
|
|
54
|
+
app_dir: Path
|
|
55
|
+
domain: str
|
|
56
|
+
admin_url: str
|
|
57
|
+
credentials_path: Path
|
|
58
|
+
repo_url: str | None
|
|
59
|
+
superuser_username: str
|
|
60
|
+
superuser_password: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SiteSetupConfig:
|
|
65
|
+
"""Configuration for site setup."""
|
|
66
|
+
|
|
67
|
+
site_slug: str
|
|
68
|
+
theme_slug: str = "theme_a"
|
|
69
|
+
seed_profile: str | None = "starter"
|
|
70
|
+
content_path: str | None = None
|
|
71
|
+
superuser_username: str = "admin"
|
|
72
|
+
skip_git: bool = False
|
|
73
|
+
skip_systemd: bool = False
|
|
74
|
+
skip_caddy: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SiteOrchestrator:
|
|
78
|
+
"""Orchestrates the full site setup workflow for production deployment.
|
|
79
|
+
|
|
80
|
+
Creates a working site on staging with all infrastructure configured.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, config: SystemConfig | None = None):
|
|
84
|
+
self.sys_config = config or get_system_config()
|
|
85
|
+
self._credentials: SiteCredentials | None = None
|
|
86
|
+
self._site_dir: Path | None = None
|
|
87
|
+
self._app_dir: Path | None = None
|
|
88
|
+
|
|
89
|
+
def setup_site(self, setup_config: SiteSetupConfig) -> SiteSetupResult:
|
|
90
|
+
"""Run the complete site setup workflow.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
setup_config: Configuration for the site setup.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
SiteSetupResult with all site information.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
SetupError: If any step fails.
|
|
100
|
+
"""
|
|
101
|
+
site_slug = setup_config.site_slug
|
|
102
|
+
total_steps = self._count_steps(setup_config)
|
|
103
|
+
current_step = 0
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Step 1: Check infrastructure prerequisites
|
|
107
|
+
current_step += 1
|
|
108
|
+
self._progress(current_step, total_steps, "Checking prerequisites")
|
|
109
|
+
infra = check_infrastructure()
|
|
110
|
+
infra.require_all()
|
|
111
|
+
self._done(current_step, total_steps, "Prerequisites verified")
|
|
112
|
+
|
|
113
|
+
# Step 2: Generate credentials
|
|
114
|
+
current_step += 1
|
|
115
|
+
self._progress(current_step, total_steps, "Generating credentials")
|
|
116
|
+
self._credentials = generate_site_credentials(
|
|
117
|
+
site_slug,
|
|
118
|
+
superuser_username=setup_config.superuser_username,
|
|
119
|
+
)
|
|
120
|
+
self._done(current_step, total_steps, "Credentials generated")
|
|
121
|
+
|
|
122
|
+
# Step 3: Create directory structure
|
|
123
|
+
current_step += 1
|
|
124
|
+
self._progress(current_step, total_steps, "Creating directories")
|
|
125
|
+
self._site_dir = create_site_directories(site_slug, self.sys_config)
|
|
126
|
+
self._app_dir = self._site_dir / "app"
|
|
127
|
+
self._done(current_step, total_steps, "Directories created")
|
|
128
|
+
|
|
129
|
+
# Step 4: Create Postgres database
|
|
130
|
+
current_step += 1
|
|
131
|
+
self._progress(current_step, total_steps, "Creating database")
|
|
132
|
+
create_postgres_database(self._credentials, self.sys_config)
|
|
133
|
+
self._done(current_step, total_steps, "Database created")
|
|
134
|
+
|
|
135
|
+
# Step 5: Write .env file
|
|
136
|
+
current_step += 1
|
|
137
|
+
self._progress(current_step, total_steps, "Writing configuration")
|
|
138
|
+
write_env_file(
|
|
139
|
+
self._site_dir, site_slug, self._credentials, self.sys_config
|
|
140
|
+
)
|
|
141
|
+
self._done(current_step, total_steps, "Configuration written")
|
|
142
|
+
|
|
143
|
+
# Step 6: Scaffold project code
|
|
144
|
+
current_step += 1
|
|
145
|
+
self._progress(current_step, total_steps, "Scaffolding project")
|
|
146
|
+
self._scaffold_to_app_dir(
|
|
147
|
+
site_slug,
|
|
148
|
+
setup_config.theme_slug,
|
|
149
|
+
setup_config.seed_profile,
|
|
150
|
+
)
|
|
151
|
+
# Copy .env to app directory (Django reads from app/.env, not site root)
|
|
152
|
+
self._copy_env_to_app()
|
|
153
|
+
self._done(current_step, total_steps, "Project scaffolded")
|
|
154
|
+
|
|
155
|
+
# Step 7: Create external venv
|
|
156
|
+
current_step += 1
|
|
157
|
+
self._progress(current_step, total_steps, "Creating virtualenv")
|
|
158
|
+
venv_path = self._site_dir / "venv"
|
|
159
|
+
venv_manager = VenvManager(venv_path=venv_path)
|
|
160
|
+
venv_manager.create(self._app_dir)
|
|
161
|
+
self._done(current_step, total_steps, "Virtualenv created")
|
|
162
|
+
|
|
163
|
+
# Step 8: Install dependencies
|
|
164
|
+
current_step += 1
|
|
165
|
+
self._progress(current_step, total_steps, "Installing dependencies")
|
|
166
|
+
deps_manager = DependencyManager(venv_manager=venv_manager)
|
|
167
|
+
deps_manager.install(self._app_dir)
|
|
168
|
+
self._done(current_step, total_steps, "Dependencies installed")
|
|
169
|
+
|
|
170
|
+
# Step 9: Run migrations
|
|
171
|
+
current_step += 1
|
|
172
|
+
self._progress(current_step, total_steps, "Running migrations")
|
|
173
|
+
django_executor = self._create_django_executor(venv_manager)
|
|
174
|
+
db_manager = DatabaseManager(django_executor)
|
|
175
|
+
db_manager.migrate()
|
|
176
|
+
self._done(current_step, total_steps, "Migrations complete")
|
|
177
|
+
|
|
178
|
+
# Step 10: Seed content
|
|
179
|
+
if setup_config.seed_profile:
|
|
180
|
+
current_step += 1
|
|
181
|
+
self._progress(current_step, total_steps, "Seeding content")
|
|
182
|
+
# Use the seed command with YAML content profiles
|
|
183
|
+
# Default profile is 'starter' - a production-ready base
|
|
184
|
+
self._seed_content(
|
|
185
|
+
django_executor,
|
|
186
|
+
setup_config.seed_profile,
|
|
187
|
+
setup_config.content_path,
|
|
188
|
+
)
|
|
189
|
+
self._done(current_step, total_steps, "Content seeded")
|
|
190
|
+
|
|
191
|
+
# Step 11: Create superuser
|
|
192
|
+
current_step += 1
|
|
193
|
+
self._progress(current_step, total_steps, "Creating superuser")
|
|
194
|
+
auth_manager = SuperuserManager(django_executor, self._app_dir)
|
|
195
|
+
auth_manager.create(
|
|
196
|
+
username=self._credentials.superuser_username,
|
|
197
|
+
email=self._credentials.superuser_email,
|
|
198
|
+
password=self._credentials.superuser_password,
|
|
199
|
+
)
|
|
200
|
+
self._done(current_step, total_steps, "Superuser created")
|
|
201
|
+
|
|
202
|
+
# Step 12: Collect static files
|
|
203
|
+
current_step += 1
|
|
204
|
+
self._progress(current_step, total_steps, "Collecting static files")
|
|
205
|
+
self._collect_static(django_executor)
|
|
206
|
+
self._done(current_step, total_steps, "Static files collected")
|
|
207
|
+
|
|
208
|
+
# Step 13: Git init and push (must happen before ownership change)
|
|
209
|
+
repo_url = None
|
|
210
|
+
current_step += 1
|
|
211
|
+
self._progress(current_step, total_steps, "Setting up git repository")
|
|
212
|
+
repo_url = setup_git_for_site(
|
|
213
|
+
self._app_dir,
|
|
214
|
+
site_slug,
|
|
215
|
+
skip_remote=setup_config.skip_git,
|
|
216
|
+
)
|
|
217
|
+
if repo_url:
|
|
218
|
+
self._done(current_step, total_steps, f"Repository created: {repo_url}")
|
|
219
|
+
else:
|
|
220
|
+
self._done(current_step, total_steps, "Git initialized (local only)")
|
|
221
|
+
|
|
222
|
+
# Step 14: Fix ownership for deploy user
|
|
223
|
+
current_step += 1
|
|
224
|
+
self._progress(current_step, total_steps, "Setting file ownership")
|
|
225
|
+
fix_site_ownership(site_slug, self.sys_config)
|
|
226
|
+
self._done(current_step, total_steps, "Ownership set")
|
|
227
|
+
|
|
228
|
+
# Step 15: Install systemd service (optional)
|
|
229
|
+
if not setup_config.skip_systemd:
|
|
230
|
+
current_step += 1
|
|
231
|
+
self._progress(current_step, total_steps, "Installing systemd service")
|
|
232
|
+
install_systemd_service(site_slug, self.sys_config)
|
|
233
|
+
self._done(current_step, total_steps, "Systemd service installed")
|
|
234
|
+
|
|
235
|
+
# Step 16: Configure Caddy (optional)
|
|
236
|
+
if not setup_config.skip_caddy:
|
|
237
|
+
current_step += 1
|
|
238
|
+
self._progress(current_step, total_steps, "Configuring Caddy")
|
|
239
|
+
configure_caddy(site_slug, self.sys_config)
|
|
240
|
+
self._done(current_step, total_steps, "Caddy configured")
|
|
241
|
+
|
|
242
|
+
# Step 17: Start service (if systemd was configured)
|
|
243
|
+
if not setup_config.skip_systemd:
|
|
244
|
+
current_step += 1
|
|
245
|
+
self._progress(current_step, total_steps, "Starting service")
|
|
246
|
+
start_site_service(site_slug, self.sys_config)
|
|
247
|
+
self._done(current_step, total_steps, "Service started")
|
|
248
|
+
|
|
249
|
+
# Step 18: Write credentials file
|
|
250
|
+
current_step += 1
|
|
251
|
+
self._progress(current_step, total_steps, "Saving credentials")
|
|
252
|
+
creds_path = write_credentials_file(
|
|
253
|
+
self._site_dir, site_slug, self._credentials, self.sys_config
|
|
254
|
+
)
|
|
255
|
+
self._done(current_step, total_steps, "Credentials saved")
|
|
256
|
+
|
|
257
|
+
# Build result
|
|
258
|
+
domain = self.sys_config.get_site_domain(site_slug)
|
|
259
|
+
return SiteSetupResult(
|
|
260
|
+
success=True,
|
|
261
|
+
site_slug=site_slug,
|
|
262
|
+
site_dir=self._site_dir,
|
|
263
|
+
app_dir=self._app_dir,
|
|
264
|
+
domain=domain,
|
|
265
|
+
admin_url=f"https://{domain}/admin/",
|
|
266
|
+
credentials_path=creds_path,
|
|
267
|
+
repo_url=repo_url,
|
|
268
|
+
superuser_username=self._credentials.superuser_username,
|
|
269
|
+
superuser_password=self._credentials.superuser_password,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
# Cleanup on failure
|
|
274
|
+
OutputFormatter.error(f"Setup failed: {exc}")
|
|
275
|
+
OutputFormatter.warning("Cleaning up partial installation...")
|
|
276
|
+
cleanup_site(site_slug, self.sys_config)
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
def _count_steps(self, config: SiteSetupConfig) -> int:
|
|
280
|
+
"""Count total steps based on configuration.
|
|
281
|
+
|
|
282
|
+
Builds a list of step names to ensure count matches actual execution order.
|
|
283
|
+
"""
|
|
284
|
+
steps: list[str] = [
|
|
285
|
+
"check_prerequisites",
|
|
286
|
+
"generate_credentials",
|
|
287
|
+
"create_directories",
|
|
288
|
+
"create_database",
|
|
289
|
+
"write_env_file",
|
|
290
|
+
"scaffold_project",
|
|
291
|
+
"create_venv",
|
|
292
|
+
"install_dependencies",
|
|
293
|
+
"run_migrations",
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
# Seeding happens before superuser creation
|
|
297
|
+
if config.seed_profile:
|
|
298
|
+
steps.append("seed_content")
|
|
299
|
+
|
|
300
|
+
steps.extend(
|
|
301
|
+
[
|
|
302
|
+
"create_superuser",
|
|
303
|
+
"collect_static",
|
|
304
|
+
# Git must happen before ownership change
|
|
305
|
+
"setup_git",
|
|
306
|
+
"fix_ownership",
|
|
307
|
+
]
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if not config.skip_systemd:
|
|
311
|
+
steps.append("install_systemd_service")
|
|
312
|
+
|
|
313
|
+
if not config.skip_caddy:
|
|
314
|
+
steps.append("configure_caddy")
|
|
315
|
+
|
|
316
|
+
# Start service after caddy is configured
|
|
317
|
+
if not config.skip_systemd:
|
|
318
|
+
steps.append("start_systemd_service")
|
|
319
|
+
|
|
320
|
+
steps.append("write_credentials_file")
|
|
321
|
+
|
|
322
|
+
return len(steps)
|
|
323
|
+
|
|
324
|
+
def _progress(self, step: int, total: int, message: str) -> None:
|
|
325
|
+
"""Show progress indicator."""
|
|
326
|
+
OutputFormatter.progress(step, total, message, "⏳")
|
|
327
|
+
|
|
328
|
+
def _done(self, step: int, total: int, message: str) -> None:
|
|
329
|
+
"""Show completion indicator."""
|
|
330
|
+
OutputFormatter.progress(step, total, message, "✅")
|
|
331
|
+
|
|
332
|
+
def _scaffold_to_app_dir(
|
|
333
|
+
self, site_slug: str, theme_slug: str, seed_profile: str | None = None
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Scaffold project code to the app directory.
|
|
336
|
+
|
|
337
|
+
The scaffold function expects to create the directory, so we need to
|
|
338
|
+
remove the empty app dir first, let scaffold create it, then the
|
|
339
|
+
result will be in the right place.
|
|
340
|
+
"""
|
|
341
|
+
if self._app_dir is None or self._site_dir is None:
|
|
342
|
+
raise SetupError("Site directories not initialized")
|
|
343
|
+
|
|
344
|
+
# Remove empty app dir (scaffold will create it)
|
|
345
|
+
if self._app_dir.exists():
|
|
346
|
+
try:
|
|
347
|
+
self._app_dir.rmdir()
|
|
348
|
+
except OSError as exc:
|
|
349
|
+
raise SetupError(
|
|
350
|
+
f"Cannot remove app directory (not empty?): {exc}"
|
|
351
|
+
) from exc
|
|
352
|
+
|
|
353
|
+
# Scaffold creates <parent>/<slug>, so we use site_dir as parent
|
|
354
|
+
# and site_slug as the name, but we want it in "app" subdir
|
|
355
|
+
# So we scaffold to a temp name then rename
|
|
356
|
+
scaffold_project(
|
|
357
|
+
project_name=site_slug,
|
|
358
|
+
clients_dir=self._site_dir,
|
|
359
|
+
theme_slug=theme_slug,
|
|
360
|
+
seed_profile=seed_profile or "starter",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Rename from site_slug to app
|
|
364
|
+
temp_path = self._site_dir / site_slug
|
|
365
|
+
if not temp_path.exists():
|
|
366
|
+
raise SetupError(f"Scaffold did not create expected directory: {temp_path}")
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
temp_path.rename(self._app_dir)
|
|
370
|
+
except OSError as exc:
|
|
371
|
+
raise SetupError(
|
|
372
|
+
f"Failed to rename scaffolded directory to app: {exc}"
|
|
373
|
+
) from exc
|
|
374
|
+
|
|
375
|
+
def _create_django_executor(
|
|
376
|
+
self, venv_manager: VenvManager
|
|
377
|
+
) -> DjangoCommandExecutor:
|
|
378
|
+
"""Create a Django command executor for the site."""
|
|
379
|
+
if self._app_dir is None:
|
|
380
|
+
raise SetupError("App directory not initialized")
|
|
381
|
+
|
|
382
|
+
# We need a custom executor that uses our external venv
|
|
383
|
+
return DjangoCommandExecutor(
|
|
384
|
+
self._app_dir,
|
|
385
|
+
ExecutionMode.STANDALONE,
|
|
386
|
+
python_path=venv_manager.get_python_executable(self._app_dir),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _copy_env_to_app(self) -> None:
|
|
390
|
+
"""Copy .env from site root to app directory.
|
|
391
|
+
|
|
392
|
+
Django reads .env from the project root (app/), not the site root.
|
|
393
|
+
The scaffolded project has a default .env that needs to be overwritten
|
|
394
|
+
with the production configuration.
|
|
395
|
+
"""
|
|
396
|
+
if self._site_dir is None or self._app_dir is None:
|
|
397
|
+
raise SetupError("Site directories not initialized")
|
|
398
|
+
|
|
399
|
+
source_env = self._site_dir / ".env"
|
|
400
|
+
target_env = self._app_dir / ".env"
|
|
401
|
+
|
|
402
|
+
if not source_env.exists():
|
|
403
|
+
raise SetupError(f".env file not found at {source_env}")
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
shutil.copy2(source_env, target_env)
|
|
407
|
+
except OSError as exc:
|
|
408
|
+
raise SetupError(f"Failed to copy .env to app directory: {exc}") from exc
|
|
409
|
+
|
|
410
|
+
def _collect_static(self, django_executor: DjangoCommandExecutor) -> None:
|
|
411
|
+
"""Run collectstatic Django command."""
|
|
412
|
+
django_executor.run_command(["collectstatic", "--noinput"])
|
|
413
|
+
|
|
414
|
+
def _seed_content(
|
|
415
|
+
self,
|
|
416
|
+
django_executor: DjangoCommandExecutor,
|
|
417
|
+
profile: str,
|
|
418
|
+
content_path: str | None = None,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Seed the site with content using YAML content profiles.
|
|
421
|
+
|
|
422
|
+
Uses the seed management command with the seeders package and
|
|
423
|
+
content profiles.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
django_executor: Django command executor for the site.
|
|
427
|
+
profile: Seed profile name (e.g., 'starter', 'sage-stone').
|
|
428
|
+
content_path: Optional path to custom content directory.
|
|
429
|
+
"""
|
|
430
|
+
cmd = ["seed", profile, "--clear"]
|
|
431
|
+
if content_path:
|
|
432
|
+
cmd.extend(["--content-path", content_path])
|
|
433
|
+
|
|
434
|
+
result = django_executor.run_command(
|
|
435
|
+
cmd,
|
|
436
|
+
check=False,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if result.returncode != 0:
|
|
440
|
+
details = result.stderr or result.stdout
|
|
441
|
+
raise SeedError(f"Seeding failed: {details}")
|
sum/setup/venv.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sum.exceptions import VenvError
|
|
9
|
+
from sum.utils.output import OutputFormatter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VenvManager:
|
|
13
|
+
"""Manage Python virtual environments for CLI projects.
|
|
14
|
+
|
|
15
|
+
Supports both internal venv (.venv inside project) and external venv
|
|
16
|
+
(separate directory outside project, e.g., /srv/sum/<name>/venv/).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, venv_path: Path | None = None):
|
|
20
|
+
"""Initialize VenvManager with optional explicit venv path.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
venv_path: If provided, use this path for the venv.
|
|
24
|
+
If None, uses project_path/.venv (legacy behavior).
|
|
25
|
+
"""
|
|
26
|
+
self._explicit_venv_path = venv_path
|
|
27
|
+
|
|
28
|
+
def _get_venv_path(self, project_path: Path) -> Path:
|
|
29
|
+
"""Get the venv path for a project."""
|
|
30
|
+
if self._explicit_venv_path is not None:
|
|
31
|
+
return self._explicit_venv_path
|
|
32
|
+
return project_path / ".venv"
|
|
33
|
+
|
|
34
|
+
def create(self, project_path: Path) -> Path:
|
|
35
|
+
"""Create a virtualenv if it does not exist.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
project_path: Path to the project. Used to determine venv location
|
|
39
|
+
if no explicit venv_path was provided.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to the virtualenv directory.
|
|
43
|
+
"""
|
|
44
|
+
venv_path = self._get_venv_path(project_path)
|
|
45
|
+
|
|
46
|
+
if venv_path.is_dir():
|
|
47
|
+
OutputFormatter.info(f"Virtualenv already exists at {venv_path}")
|
|
48
|
+
return venv_path
|
|
49
|
+
|
|
50
|
+
OutputFormatter.progress(1, 1, f"Creating virtualenv at {venv_path}")
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(
|
|
53
|
+
[sys.executable, "-m", "venv", str(venv_path)],
|
|
54
|
+
check=True,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
)
|
|
58
|
+
except subprocess.CalledProcessError as exc:
|
|
59
|
+
OutputFormatter.error("Failed to create virtualenv")
|
|
60
|
+
details = exc.stderr or exc.stdout or "Unknown error"
|
|
61
|
+
raise VenvError(
|
|
62
|
+
f"Failed to create virtualenv at {venv_path}: {details}"
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
OutputFormatter.success(f"Virtualenv created at {venv_path}")
|
|
66
|
+
return venv_path
|
|
67
|
+
|
|
68
|
+
def get_python_executable(self, project_path: Path) -> Path:
|
|
69
|
+
"""Return the Python executable inside the virtualenv."""
|
|
70
|
+
venv_path = self._get_venv_path(project_path)
|
|
71
|
+
python_path = venv_path / "bin" / "python"
|
|
72
|
+
return python_path
|
|
73
|
+
|
|
74
|
+
def get_pip_executable(self, project_path: Path) -> Path:
|
|
75
|
+
"""Return the pip executable inside the virtualenv."""
|
|
76
|
+
venv_path = self._get_venv_path(project_path)
|
|
77
|
+
pip_path = venv_path / "bin" / "pip"
|
|
78
|
+
return pip_path
|
|
79
|
+
|
|
80
|
+
def is_activated(self) -> bool:
|
|
81
|
+
"""Return True when running inside a virtualenv."""
|
|
82
|
+
active = bool(os.environ.get("VIRTUAL_ENV")) or sys.prefix != sys.base_prefix
|
|
83
|
+
return active
|
|
84
|
+
|
|
85
|
+
def exists(self, project_path: Path) -> bool:
|
|
86
|
+
"""Return True when the virtualenv exists."""
|
|
87
|
+
venv_path = self._get_venv_path(project_path)
|
|
88
|
+
exists = venv_path.is_dir()
|
|
89
|
+
return exists
|