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
26 changes: 12 additions & 14 deletions backend/druks/prompts/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,28 @@
import importlib.util
from pathlib import Path

from jinja2 import Environment, FileSystemLoader, StrictUndefined
from jinja2 import Environment, FileSystemLoader, PrefixLoader, StrictUndefined
from jinja2.sandbox import ImmutableSandboxedEnvironment

from druks.extensions.fetcher import fetch_file
from druks.extensions.loader import iter_extensions

PROMPTS_DIR = Path(__file__).resolve().parents[2] / "templates" / "prompts"


@functools.cache
def _environment() -> Environment:
# One Jinja environment over the bundled core templates plus each installed
# extension's own ``templates/prompts`` root, so a separately-shipped extension carries
# its prompts in its package. Overrides resolved as strings via
# ``from_string`` still see the loader for ``{% include %}`` against partials.
# One Jinja environment over every installed extension's own ``templates`` root,
# each mounted under the extension's name: ``ship/build/implement.md`` is
# ``build/implement.md`` inside ship's package, so nothing repeats the extension in
# its own tree. Overrides resolved as strings via ``from_string`` still see the
# loader for ``{% include %}`` against partials.
#
# Sandboxed because a ``.druks/<ext>/prompts/*`` override is authored by anyone with
# push access to a monitored repo: the sandbox blocks the ``__globals__`` walk to
# ``os.system``, and being immutable it blocks mutating the live ``workflow``/
# ``workspace`` objects in context. Bundled templates only read public attributes,
# so the sandbox is invisible to them.
return ImmutableSandboxedEnvironment(
loader=FileSystemLoader([PROMPTS_DIR, *_extension_prompt_roots()]),
loader=PrefixLoader(_extension_template_roots()),
autoescape=False,
undefined=StrictUndefined,
keep_trailing_newline=True,
Expand All @@ -33,15 +32,15 @@ def _environment() -> Environment:
)


def _extension_prompt_roots() -> list[Path]:
roots: list[Path] = []
def _extension_template_roots() -> dict[str, FileSystemLoader]:
roots: dict[str, FileSystemLoader] = {}
for extension in iter_extensions():
spec = importlib.util.find_spec(extension.package)
if not spec or not spec.submodule_search_locations:
continue
root = Path(spec.submodule_search_locations[0]) / "templates" / "prompts"
root = Path(spec.submodule_search_locations[0]) / "templates"
if root.is_dir():
roots.append(root)
roots[extension.name] = FileSystemLoader(root)
return roots


Expand All @@ -58,8 +57,7 @@ async def render_prompt(

1. ``<repo>/.druks/<extension>/prompts/<rest>`` — repo-specific tuning
2. ``<owner>/.druks`` repo ``<extension>/prompts/<rest>`` — org-wide tuning
3. bundled ``backend/templates/prompts/<name>`` and each installed extension's
own ``<package>/templates/prompts`` root — built-in baseline
3. ``<rest>`` under the extension's own ``<package>/templates`` root — built-in baseline

A 404 at a tier silently falls through to the next. Auth or network
failures propagate — those are real misconfigurations and the
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_build_prompts.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import re
from pathlib import Path
from types import SimpleNamespace

import pytest
from druks.contrib import ship
from druks.contrib.ship.contracts import PlanData
from druks.contrib.ship.journal import BuildJournal
from druks.contrib.ship.models import Project, ProjectRepo
from druks.contrib.ship.prompt_context import BuildPromptContext
from druks.prompts import render_prompt
from druks.prompts.resolver import PROMPTS_DIR
from druks.workflows import FatalError

_OP_TEMPLATES = [
Expand Down Expand Up @@ -260,7 +261,7 @@ async def test_contract_context_is_omitted_only_from_code_review():
def test_build_prompt_context_covers_template_attrs():
# Every build prompt reads build.<attr>; assert BuildPromptContext carries them
# all, so a template ref can never outrun the context contract.
prompts_dir = PROMPTS_DIR / "ship/build"
prompts_dir = Path(ship.__file__).parent / "templates/build"
templates = sorted(prompts_dir.glob("*.md"))
assert templates, f"no build prompts under {prompts_dir}"
attrs: set[str] = set()
Expand Down
7 changes: 0 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,11 @@ packages = ["backend/druks"]
[tool.hatch.build.targets.sdist]
include = [
"/backend/druks",
"/backend/templates",
"/LICENSE",
"/pyproject.toml",
"/README.md",
]

# Ship the prompt templates dir alongside the wheel. ``druks.core.prompts``
# resolves the path relative to ``backend/`` so the directory has to land
# at the same height as the ``druks`` package in the installed tree.
[tool.hatch.build.targets.wheel.force-include]
"backend/templates" = "templates"

[tool.ruff]
line-length = 100
target-version = "py311"
Expand Down