Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions aegis/core/manual_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from .component_files import get_component_files, get_copier_defaults, get_template_path
from .copier_manager import is_copier_project, load_copier_answers
from .plugin_template_resolver import get_plugin_template_root
from .verbosity import verbose_print

# Constants
Expand Down Expand Up @@ -533,6 +534,74 @@ def _render_template_file(
except TemplateNotFound:
return None

def install_plugin_template_tree(self, plugin_module_name: str) -> list[str]:
"""Render the plugin's template tree into the project.

Plugins ship a ``<package>/templates/{{ project_slug }}/...``
directory parallel to aegis-stack's own. This method locates
that tree via :func:`plugin_template_resolver.get_plugin_template_root`,
renders every ``*.jinja`` file through a fresh Jinja2 environment
rooted at the plugin's templates dir (so ``include`` / ``extends``
resolve against the plugin's tree, not aegis-stack's), and writes
the rendered output at the corresponding relative path under
``self.project_path``.

The render context is the project's current ``self.answers``,
so plugin templates can branch on the same project state that
aegis-stack templates do (``include_database``, ``database_engine``,
etc.).

Returns the list of relative paths written. Empty list when the
plugin ships no templates (pure-code plugin).

Existing files are overwritten. Per-file conflict policy lives at
the calling level (``aegis add`` in round 8b); for now this is
used by tests and by future ``aegis add`` once it lands.

**Filesystem-only.** Uses ``Path.rglob`` and ``FileSystemLoader``
on the resolver's returned path, which requires a real on-disk
directory. Zipped wheels are not supported today — see
``plugin_template_resolver`` for the rationale.
"""
template_root = get_plugin_template_root(plugin_module_name)
if template_root is None:
return []

# Plugin templates mirror aegis-stack's: every file is nested
# under ``{{ project_slug }}/`` so the rendered path is naturally
# rooted at the project tree.
project_slug_dir = template_root / PROJECT_SLUG_PLACEHOLDER
if not project_slug_dir.is_dir():
return []

plugin_env = Environment(
loader=FileSystemLoader(str(template_root)),
trim_blocks=False,
lstrip_blocks=False,
)
Comment thread
lbedner marked this conversation as resolved.

files_written: list[str] = []
for source_file in sorted(project_slug_dir.rglob(f"*{JINJA_EXTENSION}")):
# Path relative to the project slug dir → relative path
# inside the target project. Strip the ``.jinja`` suffix
# since the rendered file shouldn't keep it.
rel_inside_slug = source_file.relative_to(project_slug_dir)
out_rel = rel_inside_slug.with_suffix("")
out_path = self.project_path / out_rel

# Jinja2 needs the template name relative to the loader's
# root (template_root, not project_slug_dir) so it can
# resolve includes against sibling files.
template_name = str(source_file.relative_to(template_root))
template = plugin_env.get_template(template_name)
content = template.render(self.answers)

out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content)
files_written.append(str(out_rel))

return files_written

def _save_answers(self, answers: dict[str, Any]) -> None:
"""
Save updated answers to .copier-answers.yml.
Expand Down
8 changes: 8 additions & 0 deletions aegis/core/plugin_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ def serialize_plugin_to_answer(
"version": spec.version,
"verified": spec.verified,
"options": dict(plugin_options or {}),
# Packaging metadata — flat lists templates iterate to emit
# pyproject.toml deps and docker-compose service blocks. Not
# under ``wiring`` because these aren't injection-point hooks
# (they don't have ``when`` predicates and don't need
# serialize-time filtering); they're declarative packaging
# data the plugin ships unconditionally.
"pyproject_deps": list(spec.pyproject_deps),
"docker_services": list(spec.docker_services),
"wiring": _serialize_wiring(spec.wiring, opts, spec.name),
}

Expand Down
87 changes: 87 additions & 0 deletions aegis/core/plugin_template_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Locate a plugin's template tree on disk.

Convention: every plugin package ships a ``templates/`` subdirectory
laid out the same way as aegis-stack's own
``aegis/templates/copier-aegis-project/{{ project_slug }}/...``.
Standard Python packaging picks the directory up automatically (both
hatchling and setuptools include non-Python files under the package
root by default), so plugin authors don't need any extra config beyond
shipping the files.

This resolver returns the on-disk path to a plugin's
``<package>/templates/`` directory, or ``None`` if the plugin doesn't
ship templates (some plugins are pure code — CLI hooks, dependency
providers, etc. — and contribute via wiring data alone).

Lookup uses ``importlib.resources`` and supports filesystem-backed
plugin installs:

* installed packages (``pip install aegis-plugin-scraper``)
* development editable installs (``pip install -e .``)

Zipped wheels are **not** supported today — the caller
(``ManualUpdater.install_plugin_template_tree``) walks the resolved
directory with ``Path.rglob`` and renders through ``FileSystemLoader``,
both of which require a real on-disk path. Adding zip-wheel support is
straightforward (use ``importlib.resources.as_file`` as a context
manager and keep it alive for the duration of the render) but is
deferred until a real plugin ships as a non-editable wheel.

The caller — ``ManualUpdater`` and round 8b's ``aegis add`` — iterates
the resolved tree, renders each ``*.jinja`` through the same Jinja2
environment used for the project's own templates, and writes the
result into the target project at the corresponding relative path.
"""

from __future__ import annotations

from importlib import resources
from pathlib import Path

TEMPLATE_SUBDIR = "templates"
"""Directory name plugins must use for their template tree.

Hardcoded — keeping this a convention rather than a configurable per-plugin
setting means tooling can locate plugin templates without first reading
plugin metadata."""


def get_plugin_template_root(plugin_module_name: str) -> Path | None:
"""Return the on-disk path to a plugin's ``templates/`` directory.

Args:
plugin_module_name: Top-level module name of the plugin package
(e.g. ``"aegis_plugin_scraper"``). Must be importable.

Returns:
Path to the templates directory if the package has one, else
``None``. The path may point at a real filesystem location (for
editable installs) or a temporary materialized directory (for
zipped wheels) — callers should treat it as read-only.

Raises:
ModuleNotFoundError: if the plugin package isn't installed.
"""
# ``files()`` returns a Traversable rooted at the package; joinpath
# then names the templates subdir. We don't ``as_file()`` here
# because callers iterate the tree with ``rglob``/``walk`` and
# construct individual file paths — staying at the Traversable
# layer keeps that working for both filesystem and zipfile cases.
pkg_root = resources.files(plugin_module_name)
templates = pkg_root / TEMPLATE_SUBDIR

# ``Traversable.is_dir()`` works for both filesystem and zip-backed
# resources. We bail out early when the directory simply isn't there
# — that's the "pure-code plugin" case, not an error.
if not templates.is_dir():
return None

# Filesystem installs only (see module docstring): the Traversable's
# ``__fspath__`` IS a real path on disk for both ``pip install`` and
# ``pip install -e .``. For zipped wheels the str() would point inside
# the zip and ``rglob`` downstream would fail — that case is out of
# scope until a real plugin ships zipped, at which point we'd switch
# to ``importlib.resources.as_file`` with a context manager that
# stays open across the iteration.
return Path(str(templates))
105 changes: 84 additions & 21 deletions aegis/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
PluginSpec,
PluginWiring,
RouterWiring,
SymbolWiring,
)


Expand Down Expand Up @@ -109,14 +110,45 @@ class ServiceSpec(PluginSpec):
FrontendWidgetWiring(
module="app.components.frontend.dashboard.cards.auth_card",
symbol="AuthCard",
modal_id="auth_modal",
modal_id="auth",
),
],
dashboard_modals=[
FrontendWidgetWiring(
module="app.components.frontend.dashboard.modals.auth_modal",
symbol="AuthModal",
modal_id="auth_modal",
symbol="AuthDetailDialog",
modal_id="auth",
),
],
# FastAPI dependency providers — service-facade deps that
# take an AsyncSession and return a service instance. These
# used to live inline in the shared deps.py.jinja behind
# ``{% if include_auth %}`` blocks. Round 7.x moves them
# into ``app/services/auth/deps.py.jinja`` (Option-1 refactor),
# and the shared template just imports them via this list.
#
# Org-scoped providers gate on ``include_auth_org`` because
# the org/membership/invite tables only exist at that auth
# level.
deps_providers=[
SymbolWiring(
module="app.services.auth.deps",
symbol="get_user_service",
),
SymbolWiring(
module="app.services.auth.deps",
symbol="get_org_service",
when=lambda opts: bool(opts.get("include_auth_org")),
),
SymbolWiring(
module="app.services.auth.deps",
symbol="get_membership_service",
when=lambda opts: bool(opts.get("include_auth_org")),
),
SymbolWiring(
module="app.services.auth.deps",
symbol="get_invite_service",
when=lambda opts: bool(opts.get("include_auth_org")),
),
],
),
Expand Down Expand Up @@ -158,10 +190,16 @@ class ServiceSpec(PluginSpec):
default=False,
),
],
# Mirrors the ``{%- if include_auth %}`` + cross-service blocks in
# pyproject.toml.jinja. Auth contributes ``alembic`` (auth has
# migrations) and ``email-validator`` (User.email is EmailStr).
# Order matches the legacy render for byte-parity.
pyproject_deps=[
"python-jose[cryptography]==3.3.0",
"passlib[bcrypt]==1.7.4",
"python-multipart==0.0.9", # For form data parsing
"bcrypt>=4.0.0",
"python-multipart==0.0.9",
"alembic==1.16.5",
"email-validator==2.2.0",
],
template_files=[
"app/components/backend/api/auth/",
Expand Down Expand Up @@ -245,14 +283,14 @@ class ServiceSpec(PluginSpec):
FrontendWidgetWiring(
module="app.components.frontend.dashboard.cards.ai_card",
symbol="AICard",
modal_id="ai_modal",
modal_id="ai",
),
],
dashboard_modals=[
FrontendWidgetWiring(
module="app.components.frontend.dashboard.modals.ai_modal",
symbol="AIModal",
modal_id="ai_modal",
symbol="AIDetailDialog",
modal_id="ai",
),
],
),
Expand Down Expand Up @@ -362,21 +400,21 @@ class ServiceSpec(PluginSpec):
FrontendWidgetWiring(
module="app.components.frontend.dashboard.cards.comms_card",
symbol="CommsCard",
modal_id="comms_modal",
modal_id="comms",
),
],
dashboard_modals=[
FrontendWidgetWiring(
module="app.components.frontend.dashboard.modals.comms_modal",
symbol="CommsModal",
modal_id="comms_modal",
symbol="CommsDetailDialog",
modal_id="comms",
),
],
),
pyproject_deps=[
"resend>=2.4.0", # Email provider
"twilio>=9.3.7", # SMS/Voice provider
# Note: email-validator is shared with auth service, handled in pyproject.toml.jinja
"resend>=2.4.0",
"twilio>=9.3.7",
"email-validator==2.2.0",
],
template_files=[
"app/services/comms/",
Expand Down Expand Up @@ -423,14 +461,30 @@ class ServiceSpec(PluginSpec):
FrontendWidgetWiring(
module="app.components.frontend.dashboard.cards.insights_card",
symbol="InsightsCard",
modal_id="insights_modal",
modal_id="service_insights",
),
],
dashboard_modals=[
FrontendWidgetWiring(
module="app.components.frontend.dashboard.modals.insights_modal",
symbol="InsightsModal",
modal_id="insights_modal",
symbol="InsightsDetailDialog",
modal_id="service_insights",
),
],
# FastAPI dependency providers — moved from inline definitions
# in shared deps.py.jinja into ``app/services/insights/deps.py``.
deps_providers=[
SymbolWiring(
module="app.services.insights.deps",
symbol="get_insight_service",
),
SymbolWiring(
module="app.services.insights.deps",
symbol="get_collector_service",
),
SymbolWiring(
module="app.services.insights.deps",
symbol="get_query_service",
),
],
),
Expand Down Expand Up @@ -512,21 +566,30 @@ class ServiceSpec(PluginSpec):
FrontendWidgetWiring(
module="app.components.frontend.dashboard.cards.payment_card",
symbol="PaymentCard",
modal_id="payment_modal",
modal_id="service_payment",
),
],
dashboard_modals=[
FrontendWidgetWiring(
module="app.components.frontend.dashboard.modals.payment_modal",
symbol="PaymentModal",
modal_id="payment_modal",
symbol="PaymentDetailDialog",
modal_id="service_payment",
),
],
# FastAPI dependency providers — moved from inline definitions
# in shared deps.py.jinja into ``app/services/payment/deps.py``.
deps_providers=[
SymbolWiring(
module="app.services.payment.deps",
symbol="get_payment_service",
),
],
),
# R4-A: migrations declared on the spec.
migrations=[PAYMENT_MIGRATION, PAYMENT_AUTH_LINK_MIGRATION],
pyproject_deps=[
"stripe>=11.0.0", # Stripe Python SDK
"alembic==1.16.5",
"stripe>=11.0.0",
],
template_files=[
"app/services/payment/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ from __future__ import annotations
from typing import Any

import httpx
from app.components.backend.api.deps import get_async_db, get_user_service
from app.core.db import get_async_db
from app.services.auth.deps import get_user_service
from app.core.config import settings
from app.core.log import logger
from app.core.security import create_access_token, set_session_cookie
Expand Down
Loading
Loading