-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathcopier_manager.py
More file actions
560 lines (493 loc) · 22.5 KB
/
copier_manager.py
File metadata and controls
560 lines (493 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
"""
Copier template engine integration.
This module provides Copier template generation functionality alongside
the existing Cookiecutter engine. It's designed to maintain feature parity
during the migration period.
"""
from pathlib import Path
from typing import Any, Literal
import typer
import yaml
from copier import run_copy, run_update
from packaging.version import Version
from aegis import __version__
from aegis.i18n import t
from ..config.defaults import (
DEFAULT_PYTHON_VERSION,
GITHUB_TEMPLATE_URL,
version_to_git_tag,
)
from ..constants import AnswerKeys, PaymentProviders, StorageBackends
from .migration_generator import (
generate_migrations_for_services,
get_services_needing_migrations,
)
from .post_gen_tasks import cleanup_components, run_post_generation_tasks
from .template_generator import TemplateGenerator
from .verbosity import is_verbose, verbose_print
def is_git_repo(path: Path) -> bool:
"""
Check if path is inside a git repository.
Args:
path: Path to check
Returns:
True if path has a .git directory (is a git repo root)
"""
return (path / ".git").exists()
def generate_with_copier(
template_gen: TemplateGenerator,
output_dir: Path,
vcs_ref: str | None = None,
skip_llm_sync: bool = False,
dev_mode: bool = False,
) -> Path:
"""
Generate project using Copier template engine.
Args:
template_gen: Template generator with project configuration
output_dir: Directory to create the project in
vcs_ref: Git reference (tag, branch, or commit) to generate from
skip_llm_sync: Whether to skip LLM catalog sync after generation
Returns:
Path to the generated project
Note:
This function uses the Copier template which is currently incomplete
(missing conditional _exclude patterns). Projects will include all
components regardless of selection until template is fixed.
"""
import subprocess
# Get template context from template generator
template_context = template_gen.get_template_context()
# Determine Python version early - may need to override for RAG compatibility
# When RAG is enabled, chromadb requires onnxruntime which lacks Python 3.14 wheels
python_version = template_context.get("python_version", DEFAULT_PYTHON_VERSION)
ai_rag = template_context.get(AnswerKeys.AI_RAG, "no") == "yes"
if ai_rag and python_version and Version(python_version) >= Version("3.14"):
python_version = "3.13"
# Convert template context to Copier data format
# Copier uses boolean values instead of "yes"/"no" strings
copier_data = {
"project_name": template_context["project_name"],
"project_slug": template_context["project_slug"],
"project_description": template_context.get(
"project_description",
"A production-ready async Python application built with Aegis Stack",
),
"author_name": template_context.get("author_name", "Your Name"),
"author_email": template_context.get("author_email", "your.email@example.com"),
"github_username": template_context.get("github_username", "your-username"),
"version": template_context.get("version", "0.1.0"),
"python_version": python_version, # Uses override for RAG + Python 3.14
"aegis_version": template_context.get("aegis_version", "0.0.0"),
# Convert yes/no strings to booleans
AnswerKeys.SCHEDULER: template_context[AnswerKeys.SCHEDULER] == "yes",
AnswerKeys.SCHEDULER_BACKEND: template_context[AnswerKeys.SCHEDULER_BACKEND],
AnswerKeys.SCHEDULER_WITH_PERSISTENCE: template_context[
AnswerKeys.SCHEDULER_WITH_PERSISTENCE
]
== "yes",
AnswerKeys.WORKER: template_context[AnswerKeys.WORKER] == "yes",
AnswerKeys.WORKER_BACKEND: template_context.get(
AnswerKeys.WORKER_BACKEND, "arq"
),
AnswerKeys.REDIS: template_context[AnswerKeys.REDIS] == "yes",
AnswerKeys.DATABASE: template_context[AnswerKeys.DATABASE] == "yes",
AnswerKeys.DATABASE_ENGINE: template_context.get(
AnswerKeys.DATABASE_ENGINE, StorageBackends.SQLITE
),
AnswerKeys.CACHE: False, # Default to no
AnswerKeys.INGRESS: template_context.get(AnswerKeys.INGRESS, "no") == "yes",
AnswerKeys.OBSERVABILITY: template_context.get(AnswerKeys.OBSERVABILITY, "no")
== "yes",
AnswerKeys.AUTH: template_context.get(AnswerKeys.AUTH, "no") == "yes",
AnswerKeys.AUTH_LEVEL: template_context.get(AnswerKeys.AUTH_LEVEL, "basic"),
AnswerKeys.AUTH_RBAC: template_context.get(AnswerKeys.AUTH_RBAC, "no") == "yes",
AnswerKeys.AUTH_ORG: template_context.get(AnswerKeys.AUTH_ORG, "no") == "yes",
AnswerKeys.AUTH_OAUTH: template_context.get(AnswerKeys.AUTH_OAUTH, "no")
== "yes",
AnswerKeys.AI: template_context.get(AnswerKeys.AI, "no") == "yes",
AnswerKeys.COMMS: template_context.get(AnswerKeys.COMMS, "no") == "yes",
AnswerKeys.AI_FRAMEWORK: template_context.get(
AnswerKeys.AI_FRAMEWORK, "pydantic-ai"
),
AnswerKeys.AI_PROVIDERS: template_context.get(
AnswerKeys.AI_PROVIDERS, "openai"
),
AnswerKeys.AI_BACKEND: template_context.get(
AnswerKeys.AI_BACKEND, StorageBackends.MEMORY
),
AnswerKeys.AI_WITH_PERSISTENCE: template_context.get(
AnswerKeys.AI_WITH_PERSISTENCE, "no"
)
== "yes",
AnswerKeys.AI_RAG: template_context.get(AnswerKeys.AI_RAG, "no") == "yes",
AnswerKeys.AI_VOICE: template_context.get(AnswerKeys.AI_VOICE, "no") == "yes",
AnswerKeys.OLLAMA_MODE: template_context.get(AnswerKeys.OLLAMA_MODE, "none"),
AnswerKeys.INSIGHTS: template_context.get(AnswerKeys.INSIGHTS, "no") == "yes",
AnswerKeys.INSIGHTS_GITHUB: template_context.get(
AnswerKeys.INSIGHTS_GITHUB, "no"
)
== "yes",
AnswerKeys.INSIGHTS_PYPI: template_context.get(AnswerKeys.INSIGHTS_PYPI, "no")
== "yes",
AnswerKeys.INSIGHTS_PLAUSIBLE: template_context.get(
AnswerKeys.INSIGHTS_PLAUSIBLE, "no"
)
== "yes",
AnswerKeys.INSIGHTS_REDDIT: template_context.get(
AnswerKeys.INSIGHTS_REDDIT, "no"
)
== "yes",
AnswerKeys.PAYMENT: template_context.get(AnswerKeys.PAYMENT, "no") == "yes",
AnswerKeys.PAYMENT_PROVIDER: template_context.get(
AnswerKeys.PAYMENT_PROVIDER, PaymentProviders.DEFAULT
),
}
# Detect dev vs production mode for template sourcing
# - Dev mode (--dev flag): Use plain file path to read from working tree
# - Development: Use git+file:// URL to access local git repo at HEAD
# - Production (pip/uvx install): Use GitHub URL (no local git repo)
from .copier_updater import get_template_root, resolve_version_to_ref
template_root = get_template_root()
if dev_mode:
# Dev mode: read directly from working tree (uncommitted changes)
# WARNING: Projects generated in dev mode cannot be updated with aegis update
template_source = str(template_root)
resolved_ref = None # No version pinning in dev mode
elif is_git_repo(template_root):
# Development mode: local git repository available
# Always use git+file:// URL so projects are updatable
template_source = f"git+file://{template_root}"
if vcs_ref:
# Specific version requested - resolve to git reference
resolved_ref = resolve_version_to_ref(vcs_ref, template_root)
else:
# No version specified - use HEAD so project has valid _commit
# This is CRITICAL for aegis update to work properly
resolved_ref = "HEAD"
else:
# Production mode: installed via pip/uvx (no .git directory)
# Use GitHub URL for template source with CLI version as default ref
# This ensures CLI v0.4.1 uses template v0.4.1, not HEAD
template_source = GITHUB_TEMPLATE_URL
resolved_ref = vcs_ref if vcs_ref else version_to_git_tag(__version__)
# Store template version in answers for future reference
# This allows aegis update to show "v0.4.1" instead of commit hash
if resolved_ref is None:
# Dev mode - no version tracking
copier_data["_template_version"] = "dev"
elif resolved_ref.startswith("v"):
# Version tag (e.g., "v0.4.1") -> strip 'v' prefix
copier_data["_template_version"] = resolved_ref[1:]
else:
# Branch or commit hash - store as-is
copier_data["_template_version"] = resolved_ref
# Generate project - Copier creates output_dir/project_slug via {{ project_slug }}/ wrapper
# NOTE: _tasks removed from copier.yml - we run them ourselves below
# Suppress Copier output unless --verbose flag is passed
run_copy(
template_source,
output_dir, # Copier creates project_slug subdirectory from template
data=copier_data,
defaults=True, # Use template defaults, overridden by our explicit data
unsafe=False, # No tasks in copier.yml anymore - we run them ourselves
vcs_ref=resolved_ref, # Use specified version if provided
quiet=not is_verbose(), # Silent unless --verbose
)
# Copier creates the project in output_dir/project_slug
project_path = output_dir / template_context["project_slug"]
# Store template version in answers file for future reference
# Copier only writes fields defined in copier.yml, so we add this manually
# This allows 'aegis update' to show "v0.4.1" instead of commit hash
# Copier doesn't persist conditional fields (those with `when:`) to the answers
# file even when their value is provided via `data`. Patch them in manually so
# downstream code (project_map, aegis update) can read them back.
answers_file = project_path / AnswerKeys.ANSWERS_FILENAME
if answers_file.exists():
answers = yaml.safe_load(answers_file.read_text()) or {}
template_version = copier_data.get("_template_version")
if template_version:
answers["_template_version"] = template_version
# Persist conditional choice fields that Copier omits.
# ``include_oauth`` is gated on ``include_auth`` (``when:`` in
# copier.yml) so Copier strips it from the answers file even
# when explicitly set in ``data``. Without this patch,
# ``aegis update`` and any other tooling reading the answers
# file can't tell whether OAuth was selected at init time.
for key in (
AnswerKeys.WORKER_BACKEND,
AnswerKeys.SCHEDULER_BACKEND,
AnswerKeys.AUTH_OAUTH,
):
if key in copier_data:
answers[key] = copier_data[key]
answers_file.write_text(yaml.safe_dump(answers, default_flow_style=False))
# Clean up unwanted component files based on selection
# This must happen BEFORE post-generation tasks (which run linting on the remaining files)
cleanup_components(project_path, copier_data)
# Run post-generation tasks with explicit working directory control
# This ensures consistent behavior with Cookiecutter
include_auth = copier_data.get(AnswerKeys.AUTH, False)
include_ai = copier_data.get(AnswerKeys.AI, False)
include_insights = copier_data.get(AnswerKeys.INSIGHTS, False)
ai_backend = copier_data.get(AnswerKeys.AI_BACKEND, StorageBackends.MEMORY)
database_engine = copier_data.get(
AnswerKeys.DATABASE_ENGINE, StorageBackends.SQLITE
)
# Type narrowing: ensure booleans for include_auth and include_ai
is_auth_included: bool = include_auth is True
is_ai_included: bool = include_ai is True
# Type narrowing: ai_backend should always be a string, but narrow from Any
ai_backend_str: str = str(ai_backend) if ai_backend else StorageBackends.MEMORY
is_insights_included: bool = include_insights is True
ai_needs_migrations = is_ai_included and ai_backend_str != StorageBackends.MEMORY
needs_migration_files = (
is_auth_included or ai_needs_migrations or is_insights_included
)
# Only run migrations automatically for SQLite (file-based, no server needed)
# PostgreSQL requires a running server, so skip auto-migration
is_sqlite = database_engine == StorageBackends.SQLITE
is_payment_included: bool = copier_data.get(AnswerKeys.PAYMENT, False) is True
needs_migration_files = needs_migration_files or is_payment_included
run_migrations = needs_migration_files and is_sqlite
# Generate migrations for services that need them (always, regardless of engine)
if needs_migration_files:
# Get ai_voice from copier_data (it's a boolean after conversion)
ai_voice_enabled: bool = copier_data.get(AnswerKeys.AI_VOICE, False) is True
context = {
"include_auth": is_auth_included,
"include_auth_org": copier_data.get(AnswerKeys.AUTH_ORG, False) is True,
"auth_level": copier_data.get(AnswerKeys.AUTH_LEVEL, "basic"),
"include_ai": is_ai_included,
"ai_backend": ai_backend_str,
"ai_voice": ai_voice_enabled,
"include_insights": is_insights_included,
"include_payment": is_payment_included,
}
services = get_services_needing_migrations(context)
if services:
generated = generate_migrations_for_services(project_path, services)
for migration_path in generated:
print(f"Generated migration: {migration_path.name}")
# AI needs seeding when using persistence backend AND sqlite (postgres needs running server)
ai_needs_seeding = ai_needs_migrations and is_sqlite
# Type narrowing: python_version from copier_data can be Any, so narrow to str | None
python_version_value = copier_data.get("python_version")
python_version_str: str | None = (
python_version_value if isinstance(python_version_value, str) else None
)
# Skip LLM sync for postgres (requires running database server)
should_skip_llm_sync = skip_llm_sync or not is_sqlite
run_post_generation_tasks(
project_path,
include_migrations=run_migrations,
python_version=python_version_str,
seed_ai=ai_needs_seeding,
skip_llm_sync=should_skip_llm_sync,
project_slug=template_context["project_slug"],
)
# Initialize git repository for Copier updates
# Copier requires a git-tracked project to perform updates
try:
subprocess.run(
["git", "init"],
cwd=project_path,
check=True,
capture_output=True,
)
# Configure git user AFTER init (local config requires .git to exist)
# This is needed for commits to work in CI environments
subprocess.run(
["git", "config", "user.name", "Aegis Stack"],
cwd=project_path,
capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "noreply@aegis-stack.dev"],
cwd=project_path,
capture_output=True,
)
subprocess.run(
["git", "add", "."],
cwd=project_path,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "Initial commit from Aegis Stack"],
cwd=project_path,
check=True,
capture_output=True,
)
verbose_print("Git repository initialized")
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to initialize git repository: {e}")
print("Run 'git init && git add . && git commit' manually")
# Show docs/star links
typer.echo()
typer.secho(t("postgen.docs_link"), dim=True)
typer.echo()
star = typer.style("\u2605", fg=typer.colors.BRIGHT_YELLOW, bold=True)
typer.echo(
f"{star} {t('postgen.star_prompt')}\n https://github.com/lbedner/aegis-stack"
)
# CRITICAL: Fix _src_path in .copier-answers.yml for future updates to work
#
# Problem: Copier stores a temp directory path during generation (e.g.,
# /private/var/folders/...) which won't exist later when running updates.
#
# Solution: Update _src_path to point to the actual template repository:
# - Development: git+file:// URL for local git repo
# - Production: GitHub URL for remote repo
#
# IMPORTANT: We do NOT modify _commit - Copier sets this correctly when using
# git+file:// URL. Manually overwriting _commit breaks Copier's 3-way merge
# algorithm for updates. See: https://copier.readthedocs.io/en/stable/updating/
try:
answers_file = project_path / ".copier-answers.yml"
if answers_file.exists():
with open(answers_file) as f:
answers = yaml.safe_load(f)
# Fix _src_path based on dev vs production mode
# We already determined template_root above
if is_git_repo(template_root):
# Development mode: use local git repo
answers["_src_path"] = f"git+file://{template_root}"
else:
# Production mode: use GitHub URL
answers["_src_path"] = GITHUB_TEMPLATE_URL
# Persist conditional auth fields (Copier may omit conditional
# questions from answers file when values are provided via data)
if copier_data.get(AnswerKeys.AUTH):
answers[AnswerKeys.AUTH_LEVEL] = copier_data.get(
AnswerKeys.AUTH_LEVEL, "basic"
)
answers[AnswerKeys.AUTH_RBAC] = copier_data.get(
AnswerKeys.AUTH_RBAC, False
)
answers[AnswerKeys.AUTH_ORG] = copier_data.get(
AnswerKeys.AUTH_ORG, False
)
with open(answers_file, "w") as f:
yaml.safe_dump(answers, f, default_flow_style=False, sort_keys=False)
# Commit the updated .copier-answers.yml
try:
subprocess.run(
["git", "add", ".copier-answers.yml"],
cwd=project_path,
check=True,
capture_output=True,
)
subprocess.run(
[
"git",
"commit",
"-m",
"Fix .copier-answers.yml _src_path for template updates",
],
cwd=project_path,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
# If commit fails (e.g., no changes), that's OK
pass
except Exception:
# If we can't fix _src_path, that's OK - project generation succeeded
# but updates won't work. This can happen in non-git environments.
pass
return project_path
def is_copier_project(project_path: Path) -> bool:
"""
Check if a project was generated with Copier.
Args:
project_path: Path to the project directory
Returns:
True if project has .copier-answers.yml file
"""
answers_file = project_path / ".copier-answers.yml"
return answers_file.exists()
def load_copier_answers(project_path: Path) -> dict[str, Any]:
"""
Load existing Copier answers from a project.
Args:
project_path: Path to the project directory
Returns:
Dictionary of Copier answers
Raises:
FileNotFoundError: If .copier-answers.yml doesn't exist
yaml.YAMLError: If answers file is corrupted
"""
answers_file = project_path / ".copier-answers.yml"
if not answers_file.exists():
raise FileNotFoundError(
f"No .copier-answers.yml found in {project_path}. "
"This doesn't appear to be a Copier-generated project."
)
try:
with open(answers_file) as f:
answers = yaml.safe_load(f)
if answers is None:
return {}
return answers
except yaml.YAMLError as e:
raise yaml.YAMLError(f"Failed to parse .copier-answers.yml: {e}") from e
def update_with_copier(
project_path: Path,
additional_data: dict[str, Any] | None = None,
conflict_mode: Literal["inline", "rej"] = "rej",
) -> None:
"""
Update an existing Copier-generated project with new data.
This function uses Copier's update mechanism to add new components
or update existing project configuration.
Args:
project_path: Path to the existing project directory
additional_data: New data to merge (e.g., {"include_scheduler": True})
conflict_mode: How to handle conflicts - "rej" (separate files) or "inline" (markers)
Raises:
FileNotFoundError: If project doesn't have .copier-answers.yml
Exception: If Copier update fails
Example:
# Add scheduler component to existing project
update_with_copier(
Path("my-project"),
{"include_scheduler": True, "scheduler_backend": "memory"}
)
"""
# Validate it's a Copier project
if not is_copier_project(project_path):
raise FileNotFoundError(
f"Project at {project_path} was not generated with Copier.\n"
f"The 'aegis add' command only works with Copier-generated projects.\n"
f"To add components, regenerate the project with the new components included."
)
# Load existing answers to validate state
try:
load_copier_answers(project_path)
except yaml.YAMLError as e:
raise Exception(
f"Failed to read project configuration: {e}\n"
f"The .copier-answers.yml file may be corrupted."
) from e
# Prepare update data
update_data = additional_data or {}
# Run Copier update
# NOTE: We do NOT pass src_path - Copier will read it from .copier-answers.yml
# This is the key to making updates work!
try:
run_update(
dst_path=str(project_path),
data=update_data,
defaults=True, # Use existing answers as defaults
overwrite=True, # Allow overwriting files
conflict=conflict_mode, # How to handle conflicts
unsafe=True, # Allow running tasks (uv sync, make fix)
vcs_ref="HEAD", # Use latest template (no versioning needed yet)
)
except Exception as e:
raise Exception(
f"Failed to update project: {e}\n"
f"This may be due to conflicts with manually modified files.\n"
f"Check for .rej files in the project directory for details."
) from e