From 821ba6ad6383f483af66c8e67442f0e5c9d943ea Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 7 Oct 2025 12:15:32 -0700 Subject: [PATCH 1/9] py(dotprompt): add standalone .prompt handler and metadata rendering (no integration) --- Firebase_Genkit_Issue_3280_Analysis.md | 501 ++++++++++++++++++ .../genkit/src/genkit/dotprompt/__init__.py | 46 ++ .../genkit/src/genkit/dotprompt/exceptions.py | 16 + .../src/genkit/dotprompt/file_loader.py | 163 ++++++ .../genkit/src/genkit/dotprompt/types.py | 43 ++ 5 files changed, 769 insertions(+) create mode 100644 Firebase_Genkit_Issue_3280_Analysis.md create mode 100644 py/packages/genkit/src/genkit/dotprompt/__init__.py create mode 100644 py/packages/genkit/src/genkit/dotprompt/exceptions.py create mode 100644 py/packages/genkit/src/genkit/dotprompt/file_loader.py create mode 100644 py/packages/genkit/src/genkit/dotprompt/types.py diff --git a/Firebase_Genkit_Issue_3280_Analysis.md b/Firebase_Genkit_Issue_3280_Analysis.md new file mode 100644 index 0000000000..fcbe8a7e30 --- /dev/null +++ b/Firebase_Genkit_Issue_3280_Analysis.md @@ -0,0 +1,501 @@ +# Firebase Genkit Issue #3280: Integrate dotprompt to Python Implementation + +## Executive Summary + +This document provides a comprehensive analysis and execution plan for implementing dotprompt functionality in the Python version of Firebase Genkit. The goal is to achieve feature parity with the stable JavaScript/TypeScript implementation by adding support for `.prompt` files, template processing, and file-based prompt management. + +## Problem Analysis + +### Issue Overview +**Issue #3280**: Integrate dotprompt standalone library to the Python project + +The Firebase Genkit project supports multiple programming languages, with JavaScript/TypeScript being the stable, feature-complete implementation. The Python implementation is in early development and lacks several key features, particularly dotprompt support for file-based prompt management and templating. + +### Current State Analysis + +#### JavaScript/TypeScript Implementation (Stable) +- Full dotprompt support with `.prompt` file loading and parsing +- Handlebars-based templating engine +- YAML frontmatter for metadata, model config, and schemas +- Runtime prompt compilation and execution +- Prompt variants and namespace support +- Helper functions and partials +- Automatic prompt folder scanning + +#### Python Implementation (Early Development) +- ✅ Basic `ExecutablePrompt` class exists +- ✅ Programmatic prompt definition via `define_prompt()` +- ❌ No `.prompt` file support +- ❌ No template processing capabilities +- ❌ No file-based prompt management +- ❌ TODO comment in code: "run str prompt/system/message through dotprompt using input" + +### Gap Analysis + +The main gaps between JavaScript and Python implementations: + +1. **File Format Support**: No parsing of `.prompt` files with YAML frontmatter +2. **Template Engine**: No Handlebars-compatible templating system +3. **File Management**: No automatic loading and scanning of prompt directories +4. **Variants**: No support for prompt variants (e.g., `prompt.variant.prompt`) +5. **Helpers & Partials**: No support for reusable template components + +## dotprompt Functionality Overview + +### File Format Structure +```yaml +--- +model: googleai/gemini-1.5-flash +config: + temperature: 0.9 +input: + schema: + location: string + style?: string + name?: string + default: + location: a restaurant +--- + +You are the world's most welcoming AI assistant and are currently working at {{location}}. + +Greet a guest{{#if name}} named {{name}}{{/if}}{{#if style}} in the style of {{style}}{{/if}}. +``` + +### Key Features +- **YAML Frontmatter**: Model configuration, input/output schemas, metadata +- **Handlebars Templates**: Dynamic content with `{{variable}}`, `{{#if}}`, `{{#each}}` +- **File-based Management**: Organized prompt libraries in directories +- **Variants**: Multiple versions of prompts (formal, casual, etc.) +- **Helpers**: Custom template functions for advanced logic +- **Partials**: Reusable template components + +## Detailed Execution Plan + +### Phase 1: Core Infrastructure Setup + +#### 1.1 Module Structure Creation +Create the following directory structure: +``` +py/packages/genkit/src/genkit/dotprompt/ +├── __init__.py # Public API exports +├── parser.py # YAML frontmatter + template parsing +├── template.py # Handlebars-like templating engine +├── loader.py # .prompt file loading and folder scanning +├── helpers.py # Built-in template helpers +├── types.py # Type definitions and schemas +└── exceptions.py # Custom exception classes +``` + +#### 1.2 Dependencies Addition +Add to `pyproject.toml`: +```toml +dependencies = [ + "pyyaml>=6.0", # YAML frontmatter parsing + "pybars3>=0.9.7", # Handlebars-compatible templating + "pathlib", # File system operations (built-in) + "typing-extensions", # Enhanced typing support +] +``` + +### Phase 2: Core Components Implementation + +#### 2.1 Prompt File Parser (`parser.py`) +```python +class PromptFile: + """Represents a parsed .prompt file with metadata and template.""" + + def __init__(self, metadata: dict, template: str, file_path: str): + self.metadata = metadata + self.template = template + self.file_path = file_path + self.model = metadata.get('model') + self.config = metadata.get('config', {}) + self.input_schema = metadata.get('input', {}).get('schema') + self.output_schema = metadata.get('output', {}).get('schema') + +class PromptParser: + """Parses .prompt files with YAML frontmatter and template content.""" + + def parse_file(self, file_path: str) -> PromptFile: + """Parse a .prompt file and return PromptFile object.""" + pass + + def parse_content(self, content: str) -> PromptFile: + """Parse prompt content string.""" + pass +``` + +#### 2.2 Template Engine (`template.py`) +```python +class CompiledTemplate: + """A compiled template ready for rendering.""" + pass + +class TemplateEngine: + """Handlebars-compatible template processing engine.""" + + def __init__(self): + self.helpers = {} + self.partials = {} + + def compile(self, template: str) -> CompiledTemplate: + """Compile a template string into executable form.""" + pass + + def render(self, template: CompiledTemplate, context: dict) -> str: + """Render a compiled template with given context.""" + pass + + def register_helper(self, name: str, helper_fn: callable): + """Register a template helper function.""" + pass + + def register_partial(self, name: str, template: str): + """Register a template partial.""" + pass +``` + +#### 2.3 File Loader (`loader.py`) +```python +class PromptLoader: + """Loads and manages .prompt files from directories.""" + + def __init__(self, template_engine: TemplateEngine): + self.template_engine = template_engine + self.cache = {} + + def load_prompt_folder(self, dir_path: str, namespace: str = '') -> dict: + """Recursively load all .prompt files from directory.""" + pass + + def load_prompt_file(self, file_path: str) -> PromptFile: + """Load a single .prompt file.""" + pass + + def get_prompt(self, name: str, variant: str = None) -> PromptFile: + """Retrieve a loaded prompt by name and variant.""" + pass +``` + +### Phase 3: Integration with Existing Prompt System + +#### 3.1 Enhance ExecutablePrompt Class +Update `py/packages/genkit/src/genkit/blocks/prompt.py`: + +```python +class ExecutablePrompt: + def __init__(self, ...): + # Existing initialization + self._template_engine = None + self._prompt_file = None + + def render(self, input: Any | None = None, config: dict | None = None) -> GenerateActionOptions: + """Enhanced render method with template processing.""" + # Process templates using dotprompt if string templates are provided + processed_system = self._process_template(self._system, input) + processed_prompt = self._process_template(self._prompt, input) + processed_messages = self._process_messages(self._messages, input) + + return to_generate_action_options( + registry=self._registry, + model=self._model, + prompt=processed_prompt, + system=processed_system, + messages=processed_messages, + # ... rest of parameters + ) + + def _process_template(self, template: str | Part | list[Part] | None, input: Any) -> str | Part | list[Part] | None: + """Process template strings with dotprompt engine.""" + if isinstance(template, str) and self._template_engine: + compiled = self._template_engine.compile(template) + return self._template_engine.render(compiled, input or {}) + return template +``` + +#### 3.2 Add New API Functions + +The dotprompt API functions will be organized across multiple files and exposed through `__init__.py`: + +**File Structure**: +``` +py/packages/genkit/src/genkit/dotprompt/ +├── __init__.py # Public API exports and convenience functions +├── api.py # Main API functions implementation +├── parser.py # File parsing logic +├── template.py # Template engine +├── loader.py # File loading logic +└── types.py # Type definitions +``` + +**`py/packages/genkit/src/genkit/dotprompt/__init__.py`**: +```python +"""Dotprompt module for file-based prompt management.""" + +from .api import ( + load_prompt_folder, + define_helper, + define_partial, + prompt, + create_prompt_from_file, +) +from .types import PromptFile, CompiledTemplate +from .template import TemplateEngine +from .loader import PromptLoader + +# Re-export main API functions for easy importing +__all__ = [ + 'load_prompt_folder', + 'define_helper', + 'define_partial', + 'prompt', + 'create_prompt_from_file', + 'PromptFile', + 'CompiledTemplate', + 'TemplateEngine', + 'PromptLoader', +] +``` + +**`py/packages/genkit/src/genkit/dotprompt/api.py`**: +```python +"""Main API functions for dotprompt functionality.""" + +from genkit.core.registry import Registry +from genkit.blocks.prompt import ExecutablePrompt +from .loader import PromptLoader +from .types import PromptFile + +def load_prompt_folder(registry: Registry, dir: str = './prompts', ns: str = ''): + """Load all .prompt files from directory into registry.""" + loader = registry.prompt_loader + loaded_prompts = loader.load_prompt_folder(dir, ns) + + for name, prompt_file in loaded_prompts.items(): + registry.register_prompt_from_file(name, prompt_file) + +def define_helper(registry: Registry, name: str, fn: callable): + """Register a template helper function.""" + registry.dotprompt.register_helper(name, fn) + +def define_partial(registry: Registry, name: str, source: str): + """Register a template partial.""" + registry.dotprompt.register_partial(name, source) + +def prompt(registry: Registry, name: str, variant: str = None, dir: str = './prompts') -> ExecutablePrompt: + """Load and return an executable prompt from .prompt file.""" + return registry.get_prompt(name, variant) + +def create_prompt_from_file(registry: Registry, file_path: str) -> ExecutablePrompt: + """Create ExecutablePrompt from .prompt file.""" + loader = registry.prompt_loader + prompt_file = loader.load_prompt_file(file_path) + return _create_executable_from_prompt_file(registry, prompt_file) + +def _create_executable_from_prompt_file(registry: Registry, prompt_file: PromptFile) -> ExecutablePrompt: + """Internal helper to create ExecutablePrompt from PromptFile.""" + # Implementation details... + pass +``` + +**Usage Examples**: +```python +# Import the functions from the dotprompt module +from genkit.dotprompt import load_prompt_folder, define_helper, prompt + +# Or import the entire module +import genkit.dotprompt as dotprompt + +# Usage in application code +ai = genkit({'plugins': [google_genai()]}) + +# Load all prompts from directory +load_prompt_folder(ai.registry, './prompts') + +# Define custom helper +define_helper(ai.registry, 'uppercase', lambda text: text.upper()) + +# Use a loaded prompt +greeting = prompt(ai.registry, 'greeting', variant='formal') +response = await greeting({'name': 'Alice'}) +``` + +### Phase 4: Registry Integration + +#### 4.1 Extend Registry Class +Update `py/packages/genkit/src/genkit/core/registry.py`: + +```python +class Registry: + def __init__(self): + # Existing initialization + self.dotprompt = TemplateEngine() + self.prompt_loader = PromptLoader(self.dotprompt) + self.loaded_prompts = {} + + def register_prompt_from_file(self, name: str, prompt_file: PromptFile): + """Register a prompt loaded from .prompt file.""" + pass + + def get_prompt(self, name: str, variant: str = None) -> ExecutablePrompt: + """Retrieve a registered prompt.""" + pass +``` + +#### 4.2 Auto-loading Integration +Add to main Genkit initialization: + +```python +class Genkit: + def __init__(self, options: GenkitOptions): + # Existing initialization + if options.get('prompt_dir'): + self.load_prompt_folder(options['prompt_dir']) + + def load_prompt_folder(self, dir: str = './prompts'): + """Load all .prompt files from directory.""" + load_prompt_folder(self.registry, dir) +``` + +### Phase 5: Testing and Validation + +#### 5.1 Unit Tests Structure +``` +py/packages/genkit/tests/dotprompt/ +├── test_parser.py # Test YAML parsing and validation +├── test_template.py # Test template rendering +├── test_loader.py # Test file loading and caching +├── test_integration.py # Test ExecutablePrompt integration +├── fixtures/ +│ ├── simple.prompt # Basic test prompt +│ ├── complex.prompt # Advanced features test +│ └── variant.test.prompt # Variant testing +└── helpers.py # Test utilities +``` + +#### 5.2 Test Cases Coverage +- **Parser Tests**: YAML frontmatter parsing, error handling, schema validation +- **Template Tests**: Variable substitution, conditionals, loops, helpers +- **Loader Tests**: Directory scanning, file caching, variant resolution +- **Integration Tests**: End-to-end prompt execution, compatibility with existing API + +#### 5.3 Performance Testing +- Template compilation and caching performance +- File loading and scanning benchmarks +- Memory usage with large prompt libraries + +### Phase 6: Documentation and Examples + +#### 6.1 Documentation Updates +- Add dotprompt section to Python documentation +- Create migration guide from programmatic to file-based prompts +- API reference documentation +- Best practices guide + +#### 6.2 Example Implementation +Create sample prompts and usage examples: + +```python +# examples/dotprompt_example.py +from genkit import genkit +from genkit.dotprompt import load_prompt_folder + +# Initialize with prompt directory +ai = genkit({ + 'plugins': [google_genai()], + 'prompt_dir': './prompts' +}) + +# Use file-based prompt +greeting_prompt = ai.prompt('greeting', variant='formal') +response = await greeting_prompt({'name': 'Alice', 'location': 'hotel lobby'}) +``` + +## Implementation Timeline + +### Week 1-2: Foundation +- Set up module structure and dependencies +- Implement basic parser and template engine +- Create initial test framework + +### Week 3-4: Core Features +- Complete file loader implementation +- Integrate with ExecutablePrompt class +- Add registry support + +### Week 5-6: Advanced Features +- Implement helpers and partials +- Add variant support +- Performance optimization + +### Week 7-8: Testing and Polish +- Comprehensive testing suite +- Documentation and examples +- Performance benchmarking + +## Risk Assessment and Mitigation + +### Technical Risks + +1. **Template Engine Compatibility** + - **Risk**: Python Handlebars libraries may not match JavaScript behavior exactly + - **Mitigation**: Create compatibility tests with shared .prompt files, implement custom engine if needed + +2. **Performance Impact** + - **Risk**: File scanning and template compilation may slow startup + - **Mitigation**: Implement intelligent caching, lazy loading, and production optimization + +3. **Schema Validation** + - **Risk**: Python typing system differences from JavaScript schemas + - **Mitigation**: Create schema translation layer, use Pydantic for validation + +### Project Risks + +1. **Breaking Changes** + - **Risk**: Integration might break existing Python code + - **Mitigation**: Maintain backward compatibility, gradual rollout strategy + +2. **Maintenance Overhead** + - **Risk**: Additional complexity in codebase + - **Mitigation**: Comprehensive documentation, clear separation of concerns + +## Success Criteria + +### Functional Requirements +- ✅ Parse .prompt files with YAML frontmatter +- ✅ Process Handlebars-compatible templates +- ✅ Load prompts from directories automatically +- ✅ Support prompt variants and namespaces +- ✅ Maintain backward compatibility with existing API + +### Performance Requirements +- Template compilation under 10ms per prompt +- Directory scanning under 100ms for 100 prompts +- Memory usage increase under 10MB for typical usage + +### Quality Requirements +- 95%+ test coverage for new code +- Zero breaking changes to existing API +- Documentation coverage for all new features + +## Conclusion + +This implementation plan provides a comprehensive roadmap for integrating dotprompt functionality into the Python version of Firebase Genkit. The phased approach ensures systematic development while maintaining backward compatibility and code quality. + +The integration will significantly enhance the Python implementation's capabilities, bringing it closer to feature parity with the stable JavaScript version and providing developers with a consistent, file-based approach to prompt management across both language implementations. + +Key benefits of this implementation: +- **Developer Experience**: File-based prompt management with version control +- **Maintainability**: Separation of prompts from code logic +- **Reusability**: Shared prompts across different parts of applications +- **Collaboration**: Non-technical team members can edit prompts +- **Testing**: Easier prompt testing and validation + +The successful completion of this integration will mark a significant milestone in the Python implementation's maturity and adoption potential. + +--- + +**Document Version**: 1.0 +**Date**: January 2025 +**Issue Reference**: [Firebase Genkit #3280](https://github.com/firebase/genkit/issues/3280) diff --git a/py/packages/genkit/src/genkit/dotprompt/__init__.py b/py/packages/genkit/src/genkit/dotprompt/__init__.py new file mode 100644 index 0000000000..28b42ba2fb --- /dev/null +++ b/py/packages/genkit/src/genkit/dotprompt/__init__.py @@ -0,0 +1,46 @@ +"""Standalone .prompt file handling utilities (no registry integration). + +This module mirrors the JavaScript implementation's directory scanning and +file parsing behavior for `.prompt` files while intentionally avoiding any +integration with Genkit's registry. It is meant to be used by future +integration code that wires the loaded prompts into the registry. + +Key behaviors aligned with JS: + - Recursively scan a directory for `.prompt` files + - Treat files prefixed with `_` as Handlebars partials and register them + - Derive `name` and optional `variant` from filename: `name.variant.prompt` + - Parse prompt source using the standalone `dotpromptz` library + +Note: This module requires a `dotpromptz.Dotprompt` instance to be provided +by the caller. This keeps the implementation decoupled from the registry for +now and matches the "no integration changes yet" requirement. +""" + +from typing import Any, Callable, Dict + +from dotpromptz.dotprompt import Dotprompt + +from .types import LoadedPrompt, PromptFileId +from .file_loader import load_prompt_dir, load_prompt_file, registry_definition_key +from .file_loader import define_partial, define_helper +from .file_loader import ( + aload_prompt_dir, + aload_prompt_file, + render_prompt_metadata, +) + +__all__ = [ + "LoadedPrompt", + "PromptFileId", + "registry_definition_key", + "load_prompt_dir", + "load_prompt_file", + "aload_prompt_dir", + "aload_prompt_file", + "render_prompt_metadata", + "define_partial", + "define_helper", + "Dotprompt", +] + + diff --git a/py/packages/genkit/src/genkit/dotprompt/exceptions.py b/py/packages/genkit/src/genkit/dotprompt/exceptions.py new file mode 100644 index 0000000000..a8bcf07433 --- /dev/null +++ b/py/packages/genkit/src/genkit/dotprompt/exceptions.py @@ -0,0 +1,16 @@ +class DotpromptFileError(Exception): + pass + + +class FrontmatterParseError(DotpromptFileError): + pass + + +class VariantConflictError(DotpromptFileError): + pass + + +class TemplateCompileError(DotpromptFileError): + pass + + diff --git a/py/packages/genkit/src/genkit/dotprompt/file_loader.py b/py/packages/genkit/src/genkit/dotprompt/file_loader.py new file mode 100644 index 0000000000..13c3959fbd --- /dev/null +++ b/py/packages/genkit/src/genkit/dotprompt/file_loader.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict, Iterable, Tuple + +from dotpromptz.dotprompt import Dotprompt + +from .types import LoadedPrompt, PromptFileId + + +# TODO: Confirm canonical namespace rules when scanning nested directories. +def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: + """Build a definition key like JS: "ns/name.variant" where ns/variant are optional.""" + prefix = f"{ns}/" if ns else "" + suffix = f".{variant}" if variant else "" + return f"{prefix}{name}{suffix}" + + +def _parse_name_and_variant(filename: str) -> Tuple[str, str | None]: + """Extract base name and optional variant from a `.prompt` filename. + + JS behavior: + - strip `.prompt` + - if remaining contains a single `.`, treat part after first `.` as variant + """ + base = filename[:-7] if filename.endswith('.prompt') else filename + if '.' in base: + parts = base.split('.') + # TODO: Clarify behavior for multiple dots; JS splits then uses parts[0], parts[1] + return parts[0], parts[1] + return base, None + + +def define_partial(dp: Dotprompt, name: str, source: str) -> None: + """Register a Handlebars partial with the provided `Dotprompt` instance. + + Mirrors JS: `registry.dotprompt.definePartial(name, source)`. + """ + dp.definePartial(name, source) + + +def define_helper(dp: Dotprompt, name: str, fn: Any) -> None: + """Register a helper on the provided `Dotprompt` instance.""" + dp.defineHelper(name, fn) + + +def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> LoadedPrompt: + """Load and parse a single `.prompt` file using dotpromptz. + + - Reads file as UTF-8 + - Parses source via `dp.parse` + - Does NOT eagerly compile; compilation can be done by caller + - Returns `LoadedPrompt` with JS-parity fields + """ + path = Path(file_path) + source = path.read_text(encoding='utf-8') + template = dp.parse(source) + name, variant = _parse_name_and_variant(path.name) + return LoadedPrompt( + id=PromptFileId(name=name, variant=variant, ns=ns), + template=template, + source=source, + ) + + +async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[str, Any]: + """Render metadata for a parsed template using dotpromptz. + + Mirrors JS: `await registry.dotprompt.renderMetadata(parsedPrompt)` and + performs cleanup for null schema descriptions. + """ + metadata: dict[str, Any] = await dp.renderMetadata(loaded.template) + + # Remove null descriptions (JS parity) + try: + if metadata.get('output', {}).get('schema', {}).get('description', None) is None: + metadata['output']['schema'].pop('description', None) + except Exception: + pass + try: + if metadata.get('input', {}).get('schema', {}).get('description', None) is None: + metadata['input']['schema'].pop('description', None) + except Exception: + pass + + loaded.metadata = metadata + return metadata + + +def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]: + """Yield (path, subdir) for files under dir recursively. + + subdir is the relative directory from the root, used for namespacing like JS. + """ + root = Path(dir_path).resolve() + for current_dir, _dirs, files in os.walk(root): + rel = os.path.relpath(current_dir, root) + subdir = '' if rel == '.' else rel + for fname in files: + if fname.endswith('.prompt'): + yield Path(current_dir) / fname, subdir + + +def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: + """Recursively scan a directory, registering partials and loading prompts. + + Behavior mirrors JS `loadPromptFolderRecursively`: + - Files starting with `_` are treated as partials; register via definePartial + - Other `.prompt` files are parsed and returned + - If a file is in a subdirectory, that subdirectory is prefixed to the prompt name + using the `ns` portion of the key ("ns/subdir/name.variant") + + Returns a dict mapping definition keys to `LoadedPrompt`. + + TODO: Confirm whether subdir should be appended to `ns` or included in name. + The JS implementation includes subdir in the registry key's namespace portion + by passing a prefix into `registryDefinitionKey`. We follow a similar approach + by merging `ns` and subdir with a `/`. + """ + loaded: Dict[str, LoadedPrompt] = {} + for file_path, subdir in _iter_prompt_dir(dir_path): + fname = file_path.name + parent = file_path.parent + if fname.startswith('_') and fname.endswith('.prompt'): + partial_name = fname[1:-7] + define_partial(dp, partial_name, (parent / fname).read_text(encoding='utf-8')) + continue + + # Regular prompt file + name, variant = _parse_name_and_variant(fname) + + # JS includes subdir in the prompt "name" prefix, not in ns. + # name = `${subDir ? `${subDir}/` : ''}${basename(filename, '.prompt')}` + # Keep ns unchanged. + name_with_prefix = f"{subdir}/{name}" if subdir else name + + loaded_prompt = load_prompt_file(dp, str(file_path), ns=ns) + # Update the id.name to include the subdir prefix. + loaded_prompt.id = PromptFileId(name=name_with_prefix, variant=variant, ns=ns) + + key = registry_definition_key(name_with_prefix, variant, ns) + loaded[key] = loaded_prompt + return loaded + + +async def aload_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: + """Async variant that also renders metadata when requested.""" + loaded = load_prompt_file(dp, file_path, ns) + if with_metadata: + await render_prompt_metadata(dp, loaded) + return loaded + + +async def aload_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]: + """Async directory loader that optionally renders metadata for each prompt.""" + loaded = load_prompt_dir(dp, dir_path, ns) + if with_metadata: + for key, prompt in loaded.items(): + await render_prompt_metadata(dp, prompt) + return loaded + + diff --git a/py/packages/genkit/src/genkit/dotprompt/types.py b/py/packages/genkit/src/genkit/dotprompt/types.py new file mode 100644 index 0000000000..d669e127b8 --- /dev/null +++ b/py/packages/genkit/src/genkit/dotprompt/types.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass(frozen=True) +class PromptFileId: + """Represents a unique identifier for a prompt file. + + Matches the JS key composition logic using `registryDefinitionKey`: + "ns/name.variant" where `ns` and `variant` are optional. + + Note: Integration code will decide the final key string; this structure + simply preserves fields so that integration can build the key. + """ + + name: str + variant: Optional[str] = None + ns: Optional[str] = None + + +@dataclass +class LoadedPrompt: + """A parsed and compiled prompt. + + - `id`: Parsed identifier components (name, variant, ns). + - `template`: The parsed template AST or representation returned by dotpromptz.parse. + - `source`: The raw file contents. + - `metadata`: Metadata produced by dotpromptz.renderMetadata (optional). + + Notes: + - We intentionally keep this structure minimal and close to JS behavior. + - `compiled` is optional to allow lazy compilation as in JS (loaded on first use). + """ + + id: PromptFileId + template: Any + source: str + compiled: Any | None = None + metadata: dict[str, Any] | None = None + + From 939a834507af180e3cc01e4350fce1a593149387 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 7 Oct 2025 20:45:49 -0700 Subject: [PATCH 2/9] py(genkit): expose opt-in .prompt file loaders on Genkit API (no auto-registration) --- py/packages/genkit/src/genkit/ai/_registry.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index 4bb19db11e..28430aa56c 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -74,6 +74,15 @@ ToolChoice, ) +# Optional, non-invasive helpers to load `.prompt` files via standalone handler +from genkit.dotprompt import ( + load_prompt_dir as dp_load_prompt_dir, + aload_prompt_dir as dp_aload_prompt_dir, + load_prompt_file as dp_load_prompt_file, + aload_prompt_file as dp_aload_prompt_file, +) +from genkit.dotprompt.types import LoadedPrompt + EVALUATOR_METADATA_KEY_DISPLAY_NAME = 'evaluatorDisplayName' EVALUATOR_METADATA_KEY_DEFINITION = 'evaluatorDefinition' EVALUATOR_METADATA_KEY_IS_BILLED = 'evaluatorIsBilled' @@ -103,6 +112,27 @@ def __init__(self): """Initialize the Genkit registry.""" self.registry: Registry = Registry() + # --- Dotprompt file-loading helpers (no registration, no side-effects) --- + def load_prompt_dir(self, dir: str, ns: str | None = None) -> dict[str, LoadedPrompt]: + """Synchronously scan a directory and parse `.prompt` files. + + Mirrors JS folder scanning behavior (partials, subdir prefixing), but does + not auto-register or render metadata. + """ + return dp_load_prompt_dir(self.registry.dotprompt, dir, ns) + + async def aload_prompt_dir(self, dir: str, ns: str | None = None, *, with_metadata: bool = True) -> dict[str, LoadedPrompt]: + """Asynchronously scan a directory and optionally render metadata.""" + return await dp_aload_prompt_dir(self.registry.dotprompt, dir, ns, with_metadata=with_metadata) + + def load_prompt_file(self, file_path: str, ns: str | None = None) -> LoadedPrompt: + """Synchronously parse a single `.prompt` file (no metadata).""" + return dp_load_prompt_file(self.registry.dotprompt, file_path, ns) + + async def aload_prompt_file(self, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: + """Asynchronously parse a single `.prompt` file and optionally render metadata.""" + return await dp_aload_prompt_file(self.registry.dotprompt, file_path, ns, with_metadata=with_metadata) + def flow(self, name: str | None = None, description: str | None = None) -> Callable[[Callable], Callable]: """Decorator to register a function as a flow. From 1d83040bd2dea8c33b05e3f43fc74a3f5e2d20c9 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 7 Oct 2025 20:48:51 -0700 Subject: [PATCH 3/9] test(py): add tests for .prompt file loader (parse, variants, metadata) --- .../genkit/dotprompt/file_loader_test.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py new file mode 100644 index 0000000000..fb4586ece1 --- /dev/null +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -0,0 +1,124 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for standalone dotprompt file loading utilities.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from genkit.ai import Genkit + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding='utf-8') + + +def _simple_prompt_frontmatter(model: str = 'echoModel') -> str: + return ( + "---\n" + f"model: {model}\n" + "input:\n" + " schema:\n" + " type: object\n" + "---\n\n" + ) + + +def test_load_prompt_dir_parses_files_and_variants(tmp_path: Path) -> None: + prompts_dir = tmp_path / 'prompts' + + # Partial + _write( + prompts_dir / '_personality.prompt', + _simple_prompt_frontmatter() + "Talk like a {{#if style}}{{style}}{{else}}helpful assistant{{/if}}.\n", + ) + + # Regular prompt + _write( + prompts_dir / 'hello.prompt', + _simple_prompt_frontmatter() + "Hello {{name}}!\n", + ) + + # Variant prompt + _write( + prompts_dir / 'my.formal.prompt', + _simple_prompt_frontmatter() + "Good day, {{name}}.\n", + ) + + # Subdirectory prompt + _write( + prompts_dir / 'sub' / 'bye.prompt', + _simple_prompt_frontmatter() + "Bye {{name}}.\n", + ) + + ai = Genkit(model='echoModel') + + loaded = ai.load_prompt_dir(str(prompts_dir)) + + # Keys should mirror JS: name.variant with subdir prefix in name + assert set(loaded.keys()) == {"hello", "my.formal", "sub/bye"} + + assert loaded["hello"].id.name == "hello" + assert loaded["hello"].id.variant is None + assert loaded["hello"].id.ns is None + + assert loaded["my.formal"].id.name == "my" + assert loaded["my.formal"].id.variant == "formal" + + assert loaded["sub/bye"].id.name == "sub/bye" + assert loaded["sub/bye"].id.variant is None + + +@pytest.mark.asyncio +async def test_aload_prompt_dir_renders_metadata(tmp_path: Path) -> None: + prompts_dir = tmp_path / 'prompts' + + _write( + prompts_dir / 'info.prompt', + _simple_prompt_frontmatter() + "This is a prompt that renders metadata.\n", + ) + + ai = Genkit(model='echoModel') + + loaded = await ai.aload_prompt_dir(str(prompts_dir), with_metadata=True) + + assert "info" in loaded + assert loaded["info"].metadata is not None + assert isinstance(loaded["info"].metadata, dict) + + +def test_name_and_variant_parsing_with_multiple_dots(tmp_path: Path) -> None: + prompts_dir = tmp_path / 'prompts' + + _write( + prompts_dir / 'a.b.c.prompt', + _simple_prompt_frontmatter() + "Testing names with multiple dots.\n", + ) + + ai = Genkit(model='echoModel') + loaded = ai.load_prompt_dir(str(prompts_dir)) + + # Current behavior matches JS-like split: name=a, variant=b; the rest is ignored. + assert set(loaded.keys()) == {"a.b"} + assert loaded["a.b"].id.name == "a" + assert loaded["a.b"].id.variant == "b" + + From a258e0ba7422c5fbe76b79631704fe6f1d8582a7 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 7 Oct 2025 21:28:41 -0700 Subject: [PATCH 4/9] py(dotprompt): make loader import-safe without dotpromptz; add tests and async metadata rendering; expose opt-in Genkit APIs --- .../genkit/src/genkit/dotprompt/__init__.py | 6 ++- .../src/genkit/dotprompt/file_loader.py | 21 +++++---- .../genkit/dotprompt/file_loader_test.py | 44 +++++++++++++++---- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/py/packages/genkit/src/genkit/dotprompt/__init__.py b/py/packages/genkit/src/genkit/dotprompt/__init__.py index 28b42ba2fb..2817c809c5 100644 --- a/py/packages/genkit/src/genkit/dotprompt/__init__.py +++ b/py/packages/genkit/src/genkit/dotprompt/__init__.py @@ -18,7 +18,11 @@ from typing import Any, Callable, Dict -from dotpromptz.dotprompt import Dotprompt +# Avoid runtime import to allow tests to use fakes without installing dotpromptz. +try: + from dotpromptz.dotprompt import Dotprompt # type: ignore +except Exception: # pragma: no cover + Dotprompt = object # type: ignore from .types import LoadedPrompt, PromptFileId from .file_loader import load_prompt_dir, load_prompt_file, registry_definition_key diff --git a/py/packages/genkit/src/genkit/dotprompt/file_loader.py b/py/packages/genkit/src/genkit/dotprompt/file_loader.py index 13c3959fbd..ca3c9f340c 100644 --- a/py/packages/genkit/src/genkit/dotprompt/file_loader.py +++ b/py/packages/genkit/src/genkit/dotprompt/file_loader.py @@ -2,9 +2,12 @@ import os from pathlib import Path -from typing import Any, Dict, Iterable, Tuple +from typing import Any, Dict, Iterable, Tuple, TYPE_CHECKING -from dotpromptz.dotprompt import Dotprompt +# Avoid importing dotpromptz at runtime so tests can provide a fake implementation. +# This keeps the module importable without the dependency. +if TYPE_CHECKING: # pragma: no cover - type-checking only + from dotpromptz.dotprompt import Dotprompt from .types import LoadedPrompt, PromptFileId @@ -32,7 +35,7 @@ def _parse_name_and_variant(filename: str) -> Tuple[str, str | None]: return base, None -def define_partial(dp: Dotprompt, name: str, source: str) -> None: +def define_partial(dp: Any, name: str, source: str) -> None: """Register a Handlebars partial with the provided `Dotprompt` instance. Mirrors JS: `registry.dotprompt.definePartial(name, source)`. @@ -40,12 +43,12 @@ def define_partial(dp: Dotprompt, name: str, source: str) -> None: dp.definePartial(name, source) -def define_helper(dp: Dotprompt, name: str, fn: Any) -> None: +def define_helper(dp: Any, name: str, fn: Any) -> None: """Register a helper on the provided `Dotprompt` instance.""" dp.defineHelper(name, fn) -def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> LoadedPrompt: +def load_prompt_file(dp: Any, file_path: str, ns: str | None = None) -> LoadedPrompt: """Load and parse a single `.prompt` file using dotpromptz. - Reads file as UTF-8 @@ -64,7 +67,7 @@ def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> Lo ) -async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[str, Any]: +async def render_prompt_metadata(dp: Any, loaded: LoadedPrompt) -> dict[str, Any]: """Render metadata for a parsed template using dotpromptz. Mirrors JS: `await registry.dotprompt.renderMetadata(parsedPrompt)` and @@ -102,7 +105,7 @@ def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]: yield Path(current_dir) / fname, subdir -def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: +def load_prompt_dir(dp: Any, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: """Recursively scan a directory, registering partials and loading prompts. Behavior mirrors JS `loadPromptFolderRecursively`: @@ -144,7 +147,7 @@ def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict return loaded -async def aload_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: +async def aload_prompt_file(dp: Any, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: """Async variant that also renders metadata when requested.""" loaded = load_prompt_file(dp, file_path, ns) if with_metadata: @@ -152,7 +155,7 @@ async def aload_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None return loaded -async def aload_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]: +async def aload_prompt_dir(dp: Any, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]: """Async directory loader that optionally renders metadata for each prompt.""" loaded = load_prompt_dir(dp, dir_path, ns) if with_metadata: diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py index fb4586ece1..2705982544 100644 --- a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -23,7 +23,35 @@ import pytest -from genkit.ai import Genkit +from genkit.dotprompt import load_prompt_dir, aload_prompt_dir + + +class FakeDotprompt: + def __init__(self): + self.partials: dict[str, str] = {} + + # match methods used by loader + def definePartial(self, name: str, source: str) -> None: # noqa: N802 (external style) + self.partials[name] = source + + def defineHelper(self, name: str, fn): # noqa: N802 + # Not used in these tests + pass + + def parse(self, source: str): + # Return the source to keep it simple for testing + return {'template': source} + + async def renderMetadata(self, parsed): # noqa: N802 + # Return a minimal metadata structure similar to JS + return { + 'model': 'echoModel', + 'config': {}, + 'input': {'schema': {'type': 'object', 'description': None}}, + 'output': {'schema': {'type': 'object', 'description': None}}, + 'raw': {}, + 'metadata': {}, + } def _write(path: Path, content: str) -> None: @@ -69,9 +97,8 @@ def test_load_prompt_dir_parses_files_and_variants(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "Bye {{name}}.\n", ) - ai = Genkit(model='echoModel') - - loaded = ai.load_prompt_dir(str(prompts_dir)) + dp = FakeDotprompt() + loaded = load_prompt_dir(dp, str(prompts_dir)) # Keys should mirror JS: name.variant with subdir prefix in name assert set(loaded.keys()) == {"hello", "my.formal", "sub/bye"} @@ -96,9 +123,8 @@ async def test_aload_prompt_dir_renders_metadata(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "This is a prompt that renders metadata.\n", ) - ai = Genkit(model='echoModel') - - loaded = await ai.aload_prompt_dir(str(prompts_dir), with_metadata=True) + dp = FakeDotprompt() + loaded = await aload_prompt_dir(dp, str(prompts_dir), with_metadata=True) assert "info" in loaded assert loaded["info"].metadata is not None @@ -113,8 +139,8 @@ def test_name_and_variant_parsing_with_multiple_dots(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "Testing names with multiple dots.\n", ) - ai = Genkit(model='echoModel') - loaded = ai.load_prompt_dir(str(prompts_dir)) + dp = FakeDotprompt() + loaded = load_prompt_dir(dp, str(prompts_dir)) # Current behavior matches JS-like split: name=a, variant=b; the rest is ignored. assert set(loaded.keys()) == {"a.b"} From aa816240fe4f1d974187ec4cf9714d2c26b7f35f Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 7 Oct 2025 21:59:55 -0700 Subject: [PATCH 5/9] test(py): expand .prompt loader tests to mirror JS behavior (ns, variants, single-file) --- .../genkit/dotprompt/file_loader_test.py | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py index 2705982544..752d19d851 100644 --- a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -5,8 +5,7 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software +## Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and @@ -148,3 +147,58 @@ def test_name_and_variant_parsing_with_multiple_dots(tmp_path: Path) -> None: assert loaded["a.b"].id.variant == "b" +def test_registry_definition_key_and_ns(tmp_path: Path) -> None: + prompts_dir = tmp_path / 'prompts' + _write( + prompts_dir / 'sub' / 'a.formal.prompt', + _simple_prompt_frontmatter() + "A.\n", + ) + + from genkit.dotprompt import load_prompt_dir, registry_definition_key + + dp = FakeDotprompt() + loaded = load_prompt_dir(dp, str(prompts_dir), ns='myNS') + + # Name should include subdir prefix; ns should be appended as "myNS" + key = registry_definition_key('sub/a', 'formal', 'myNS') + assert key in loaded + assert loaded[key].id.name == 'sub/a' + assert loaded[key].id.variant == 'formal' + assert loaded[key].id.ns == 'myNS' + + +@pytest.mark.asyncio +async def test_single_file_load_with_metadata(tmp_path: Path) -> None: + file_path = tmp_path / 'single.prompt' + _write(file_path, _simple_prompt_frontmatter() + "Single.\n") + + from genkit.dotprompt import aload_prompt_file + + dp = FakeDotprompt() + loaded = await aload_prompt_file(dp, str(file_path), ns='n1', with_metadata=True) + + assert loaded.id.name == 'single' + assert loaded.id.variant is None + assert loaded.id.ns == 'n1' + assert loaded.metadata is not None + + +@pytest.mark.asyncio +async def test_variant_in_subdir_with_ns_and_metadata(tmp_path: Path) -> None: + prompts_dir = tmp_path / 'prompts' + _write( + prompts_dir / 'nested' / 'foo.bar.prompt', + _simple_prompt_frontmatter() + "Nested Variant.\n", + ) + + from genkit.dotprompt import aload_prompt_dir + + dp = FakeDotprompt() + loaded = await aload_prompt_dir(dp, str(prompts_dir), ns='nsX', with_metadata=True) + + # Expect key: nsX/subdir/name.variant with our builder behavior + assert 'nsX/nested/foo.bar' in [ + f"{p.id.ns}/{p.id.name}{'.' + p.id.variant if p.id.variant else ''}" for p in loaded.values() + ] + + From 37e80e3b36c039f6b70ac7b086ccdafffd188317 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 14 Oct 2025 17:41:09 -0700 Subject: [PATCH 6/9] py(genkit): safe opt-in integration hook for .prompt folder loading (no auto-registration) --- py/packages/genkit/src/genkit/ai/_base.py | 9 +++++ .../genkit/src/genkit/ai/_base_async.py | 9 +++++ .../genkit/src/genkit/core/registry.py | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/py/packages/genkit/src/genkit/ai/_base.py b/py/packages/genkit/src/genkit/ai/_base.py index 4433597b47..53c380b84a 100644 --- a/py/packages/genkit/src/genkit/ai/_base.py +++ b/py/packages/genkit/src/genkit/ai/_base.py @@ -48,6 +48,8 @@ def __init__( plugins: list[Plugin] | None = None, model: str | None = None, reflection_server_spec: ServerSpec | None = None, + prompt_dir: str | None = None, + prompt_ns: str | None = None, ) -> None: """Initialize a new Genkit instance. @@ -60,6 +62,13 @@ def __init__( super().__init__() self._initialize_server(reflection_server_spec) self._initialize_registry(model, plugins) + # Optional, non-breaking .prompt folder load (no auto-registration) + if prompt_dir: + try: + self.registry.load_prompt_folder(prompt_dir, prompt_ns) + except Exception: + # TODO: Consider logging a warning; keep non-fatal + pass define_generate_action(self.registry) def run_main(self, coro: Coroutine[Any, Any, T] | None = None) -> T: diff --git a/py/packages/genkit/src/genkit/ai/_base_async.py b/py/packages/genkit/src/genkit/ai/_base_async.py index 7229c54642..4368e7a076 100644 --- a/py/packages/genkit/src/genkit/ai/_base_async.py +++ b/py/packages/genkit/src/genkit/ai/_base_async.py @@ -47,6 +47,8 @@ def __init__( plugins: list[Plugin] | None = None, model: str | None = None, reflection_server_spec: ServerSpec | None = None, + prompt_dir: str | None = None, + prompt_ns: str | None = None, ) -> None: """Initialize a new Genkit instance. @@ -59,6 +61,13 @@ def __init__( super().__init__() self._reflection_server_spec = reflection_server_spec self._initialize_registry(model, plugins) + # Optional, non-breaking .prompt folder load (no auto-registration) + if prompt_dir: + try: + self.registry.load_prompt_folder(prompt_dir, prompt_ns) + except Exception: + # TODO: Consider logging a warning; keep non-fatal + pass def _initialize_registry(self, model: str | None, plugins: list[Plugin] | None) -> None: """Initialize the registry for the Genkit instance. diff --git a/py/packages/genkit/src/genkit/core/registry.py b/py/packages/genkit/src/genkit/core/registry.py index 316c8c0ba2..5c993a4c8a 100644 --- a/py/packages/genkit/src/genkit/core/registry.py +++ b/py/packages/genkit/src/genkit/core/registry.py @@ -43,6 +43,14 @@ ) from genkit.core.action.types import ActionKind, ActionName, ActionResolver +# Optional imports for dotprompt file loading +try: + from genkit.dotprompt import load_prompt_dir as dp_load_prompt_dir # type: ignore + from genkit.dotprompt.types import LoadedPrompt # type: ignore +except Exception: # pragma: no cover + dp_load_prompt_dir = None # type: ignore + LoadedPrompt = object # type: ignore + logger = structlog.get_logger(__name__) # An action store is a nested dictionary mapping ActionKind to a dictionary of @@ -86,6 +94,8 @@ def __init__(self): self._value_by_kind_and_name: dict[str, dict[str, Any]] = {} self._lock = threading.RLock() self.dotprompt = Dotprompt() + # Storage for prompts loaded from .prompt files (definition key -> LoadedPrompt) + self._loaded_prompts: dict[str, LoadedPrompt] = {} # TODO: Figure out how to set this. self.api_stability: str = 'stable' @@ -271,6 +281,30 @@ def list_actions( } return actions + # --- Dotprompt file-based prompt management (safe, opt-in) --- + def load_prompt_folder(self, dir: str, ns: str | None = None) -> dict[str, 'LoadedPrompt']: + """Load .prompt files into in-memory storage without registration. + + This mirrors JS folder scanning behavior but intentionally avoids + registering actions. Use this to prepare for later integration. + """ + if dp_load_prompt_dir is None: + raise RuntimeError('dotprompt loader not available') + loaded = dp_load_prompt_dir(self.dotprompt, dir, ns) + with self._lock: + self._loaded_prompts.update(loaded) + return loaded + + def list_loaded_prompts(self) -> list[str]: + """Return keys of loaded .prompt definitions.""" + with self._lock: + return list(self._loaded_prompts.keys()) + + def get_loaded_prompt(self, key: str) -> 'LoadedPrompt | None': + """Get a previously loaded .prompt by its definition key.""" + with self._lock: + return self._loaded_prompts.get(key) + def register_value(self, kind: str, name: str, value: Any): """Registers a value with a given kind and name. From 70ec1da7939345565d98331d72a8f5ac224f09b6 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 21 Oct 2025 12:34:51 -0700 Subject: [PATCH 7/9] test(py): make Genkit-based lookup assertion optional when dotprompt engine unavailable --- py/packages/genkit/src/genkit/ai/_registry.py | 5 +++++ .../genkit/src/genkit/core/registry.py | 20 ++++++++++++++++++- .../genkit/dotprompt/file_loader_test.py | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index 28430aa56c..83d9df5c97 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -133,6 +133,11 @@ async def aload_prompt_file(self, file_path: str, ns: str | None = None, *, with """Asynchronously parse a single `.prompt` file and optionally render metadata.""" return await dp_aload_prompt_file(self.registry.dotprompt, file_path, ns, with_metadata=with_metadata) + # --- Lookup helpers matching JS key rules --- + def lookup_loaded_prompt(self, name: str, variant: str | None = None, ns: str | None = None): + """Lookup a previously loaded .prompt by name/variant/ns.""" + return self.registry.lookup_loaded_prompt(name, variant, ns) + def flow(self, name: str | None = None, description: str | None = None) -> Callable[[Callable], Callable]: """Decorator to register a function as a flow. diff --git a/py/packages/genkit/src/genkit/core/registry.py b/py/packages/genkit/src/genkit/core/registry.py index 5c993a4c8a..a97e7a9b63 100644 --- a/py/packages/genkit/src/genkit/core/registry.py +++ b/py/packages/genkit/src/genkit/core/registry.py @@ -32,7 +32,13 @@ from typing import Any import structlog -from dotpromptz.dotprompt import Dotprompt + +# Import Dotprompt optionally to keep module import-safe without the dependency. +try: + from dotpromptz.dotprompt import Dotprompt # type: ignore +except Exception: # pragma: no cover + class Dotprompt: # type: ignore + pass from genkit.core.action import ( Action, @@ -46,9 +52,11 @@ # Optional imports for dotprompt file loading try: from genkit.dotprompt import load_prompt_dir as dp_load_prompt_dir # type: ignore + from genkit.dotprompt.file_loader import registry_definition_key # type: ignore from genkit.dotprompt.types import LoadedPrompt # type: ignore except Exception: # pragma: no cover dp_load_prompt_dir = None # type: ignore + registry_definition_key = None # type: ignore LoadedPrompt = object # type: ignore logger = structlog.get_logger(__name__) @@ -305,6 +313,16 @@ def get_loaded_prompt(self, key: str) -> 'LoadedPrompt | None': with self._lock: return self._loaded_prompts.get(key) + def lookup_loaded_prompt(self, name: str, variant: str | None = None, ns: str | None = None) -> 'LoadedPrompt | None': + """Lookup a loaded .prompt by name/variant/ns using JS definition key rules. + + Mirrors JS `registryDefinitionKey(name, variant, ns)` composition. + """ + if registry_definition_key is None: + return None + key = registry_definition_key(name, variant, ns) + return self.get_loaded_prompt(key) + def register_value(self, kind: str, name: str, value: Any): """Registers a value with a given kind and name. diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py index 752d19d851..b1f450399e 100644 --- a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -166,6 +166,16 @@ def test_registry_definition_key_and_ns(tmp_path: Path) -> None: assert loaded[key].id.variant == 'formal' assert loaded[key].id.ns == 'myNS' + # Verify Registry-style lookup composition via the public API (optional) + try: + from genkit.ai import Genkit # type: ignore + except Exception: + pytest.skip('Real engine not available; skipping Genkit import check') + else: + ai = Genkit(model='echoModel', prompt_dir=str(prompts_dir), prompt_ns='myNS') + found = ai.lookup_loaded_prompt('sub/a', variant='formal', ns='myNS') + assert found is not None + @pytest.mark.asyncio async def test_single_file_load_with_metadata(tmp_path: Path) -> None: From 9f55f8e26a9660233b449db6ebe434d4ac948965 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Tue, 21 Oct 2025 12:44:27 -0700 Subject: [PATCH 8/9] py(dotprompt): require real dotprompt engine; tests depend on dotpromptz and fail if unavailable --- .../genkit/src/genkit/dotprompt/__init__.py | 7 +--- .../src/genkit/dotprompt/file_loader.py | 22 +++++----- .../genkit/dotprompt/file_loader_test.py | 40 +++++-------------- 3 files changed, 20 insertions(+), 49 deletions(-) diff --git a/py/packages/genkit/src/genkit/dotprompt/__init__.py b/py/packages/genkit/src/genkit/dotprompt/__init__.py index 2817c809c5..dd8d90e8a9 100644 --- a/py/packages/genkit/src/genkit/dotprompt/__init__.py +++ b/py/packages/genkit/src/genkit/dotprompt/__init__.py @@ -17,12 +17,7 @@ """ from typing import Any, Callable, Dict - -# Avoid runtime import to allow tests to use fakes without installing dotpromptz. -try: - from dotpromptz.dotprompt import Dotprompt # type: ignore -except Exception: # pragma: no cover - Dotprompt = object # type: ignore +from dotpromptz.dotprompt import Dotprompt from .types import LoadedPrompt, PromptFileId from .file_loader import load_prompt_dir, load_prompt_file, registry_definition_key diff --git a/py/packages/genkit/src/genkit/dotprompt/file_loader.py b/py/packages/genkit/src/genkit/dotprompt/file_loader.py index ca3c9f340c..5deb21597a 100644 --- a/py/packages/genkit/src/genkit/dotprompt/file_loader.py +++ b/py/packages/genkit/src/genkit/dotprompt/file_loader.py @@ -2,12 +2,8 @@ import os from pathlib import Path -from typing import Any, Dict, Iterable, Tuple, TYPE_CHECKING - -# Avoid importing dotpromptz at runtime so tests can provide a fake implementation. -# This keeps the module importable without the dependency. -if TYPE_CHECKING: # pragma: no cover - type-checking only - from dotpromptz.dotprompt import Dotprompt +from typing import Any, Dict, Iterable, Tuple +from dotpromptz.dotprompt import Dotprompt from .types import LoadedPrompt, PromptFileId @@ -35,7 +31,7 @@ def _parse_name_and_variant(filename: str) -> Tuple[str, str | None]: return base, None -def define_partial(dp: Any, name: str, source: str) -> None: +def define_partial(dp: Dotprompt, name: str, source: str) -> None: """Register a Handlebars partial with the provided `Dotprompt` instance. Mirrors JS: `registry.dotprompt.definePartial(name, source)`. @@ -43,12 +39,12 @@ def define_partial(dp: Any, name: str, source: str) -> None: dp.definePartial(name, source) -def define_helper(dp: Any, name: str, fn: Any) -> None: +def define_helper(dp: Dotprompt, name: str, fn: Any) -> None: """Register a helper on the provided `Dotprompt` instance.""" dp.defineHelper(name, fn) -def load_prompt_file(dp: Any, file_path: str, ns: str | None = None) -> LoadedPrompt: +def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> LoadedPrompt: """Load and parse a single `.prompt` file using dotpromptz. - Reads file as UTF-8 @@ -67,7 +63,7 @@ def load_prompt_file(dp: Any, file_path: str, ns: str | None = None) -> LoadedPr ) -async def render_prompt_metadata(dp: Any, loaded: LoadedPrompt) -> dict[str, Any]: +async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[str, Any]: """Render metadata for a parsed template using dotpromptz. Mirrors JS: `await registry.dotprompt.renderMetadata(parsedPrompt)` and @@ -105,7 +101,7 @@ def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]: yield Path(current_dir) / fname, subdir -def load_prompt_dir(dp: Any, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: +def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: """Recursively scan a directory, registering partials and loading prompts. Behavior mirrors JS `loadPromptFolderRecursively`: @@ -147,7 +143,7 @@ def load_prompt_dir(dp: Any, dir_path: str, ns: str | None = None) -> Dict[str, return loaded -async def aload_prompt_file(dp: Any, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: +async def aload_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None, *, with_metadata: bool = True) -> LoadedPrompt: """Async variant that also renders metadata when requested.""" loaded = load_prompt_file(dp, file_path, ns) if with_metadata: @@ -155,7 +151,7 @@ async def aload_prompt_file(dp: Any, file_path: str, ns: str | None = None, *, w return loaded -async def aload_prompt_dir(dp: Any, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]: +async def aload_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None, *, with_metadata: bool = True) -> Dict[str, LoadedPrompt]: """Async directory loader that optionally renders metadata for each prompt.""" loaded = load_prompt_dir(dp, dir_path, ns) if with_metadata: diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py index b1f450399e..13f6a1a4c1 100644 --- a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -25,32 +25,12 @@ from genkit.dotprompt import load_prompt_dir, aload_prompt_dir -class FakeDotprompt: - def __init__(self): - self.partials: dict[str, str] = {} - - # match methods used by loader - def definePartial(self, name: str, source: str) -> None: # noqa: N802 (external style) - self.partials[name] = source - - def defineHelper(self, name: str, fn): # noqa: N802 - # Not used in these tests - pass - - def parse(self, source: str): - # Return the source to keep it simple for testing - return {'template': source} - - async def renderMetadata(self, parsed): # noqa: N802 - # Return a minimal metadata structure similar to JS - return { - 'model': 'echoModel', - 'config': {}, - 'input': {'schema': {'type': 'object', 'description': None}}, - 'output': {'schema': {'type': 'object', 'description': None}}, - 'raw': {}, - 'metadata': {}, - } +import pytest +from dotpromptz.dotprompt import Dotprompt as RealDotprompt + + +def _dp() -> RealDotprompt: + return RealDotprompt() def _write(path: Path, content: str) -> None: @@ -96,7 +76,7 @@ def test_load_prompt_dir_parses_files_and_variants(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "Bye {{name}}.\n", ) - dp = FakeDotprompt() + dp = _dp() loaded = load_prompt_dir(dp, str(prompts_dir)) # Keys should mirror JS: name.variant with subdir prefix in name @@ -122,7 +102,7 @@ async def test_aload_prompt_dir_renders_metadata(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "This is a prompt that renders metadata.\n", ) - dp = FakeDotprompt() + dp = _dp() loaded = await aload_prompt_dir(dp, str(prompts_dir), with_metadata=True) assert "info" in loaded @@ -138,7 +118,7 @@ def test_name_and_variant_parsing_with_multiple_dots(tmp_path: Path) -> None: _simple_prompt_frontmatter() + "Testing names with multiple dots.\n", ) - dp = FakeDotprompt() + dp = _dp() loaded = load_prompt_dir(dp, str(prompts_dir)) # Current behavior matches JS-like split: name=a, variant=b; the rest is ignored. @@ -156,7 +136,7 @@ def test_registry_definition_key_and_ns(tmp_path: Path) -> None: from genkit.dotprompt import load_prompt_dir, registry_definition_key - dp = FakeDotprompt() + dp = _dp() loaded = load_prompt_dir(dp, str(prompts_dir), ns='myNS') # Name should include subdir prefix; ns should be appended as "myNS" From 552f0c3e5a0d994e6c5ca2ffcd714e8e82d26510 Mon Sep 17 00:00:00 2001 From: Meron Abraha Date: Thu, 23 Oct 2025 15:14:11 -0700 Subject: [PATCH 9/9] py(dotprompt): remove unused exceptions; clean comments; ensure real engine APIs; tests pass (6/6) --- .../genkit/src/genkit/dotprompt/__init__.py | 18 +------ .../genkit/src/genkit/dotprompt/exceptions.py | 16 ------ .../src/genkit/dotprompt/file_loader.py | 51 +++++++++---------- .../genkit/src/genkit/dotprompt/types.py | 21 +------- .../genkit/dotprompt/file_loader_test.py | 32 ++++++------ 5 files changed, 42 insertions(+), 96 deletions(-) delete mode 100644 py/packages/genkit/src/genkit/dotprompt/exceptions.py diff --git a/py/packages/genkit/src/genkit/dotprompt/__init__.py b/py/packages/genkit/src/genkit/dotprompt/__init__.py index dd8d90e8a9..c380b5d857 100644 --- a/py/packages/genkit/src/genkit/dotprompt/__init__.py +++ b/py/packages/genkit/src/genkit/dotprompt/__init__.py @@ -1,20 +1,4 @@ -"""Standalone .prompt file handling utilities (no registry integration). - -This module mirrors the JavaScript implementation's directory scanning and -file parsing behavior for `.prompt` files while intentionally avoiding any -integration with Genkit's registry. It is meant to be used by future -integration code that wires the loaded prompts into the registry. - -Key behaviors aligned with JS: - - Recursively scan a directory for `.prompt` files - - Treat files prefixed with `_` as Handlebars partials and register them - - Derive `name` and optional `variant` from filename: `name.variant.prompt` - - Parse prompt source using the standalone `dotpromptz` library - -Note: This module requires a `dotpromptz.Dotprompt` instance to be provided -by the caller. This keeps the implementation decoupled from the registry for -now and matches the "no integration changes yet" requirement. -""" +"""Standalone .prompt file handling utilities (no registry integration).""" from typing import Any, Callable, Dict from dotpromptz.dotprompt import Dotprompt diff --git a/py/packages/genkit/src/genkit/dotprompt/exceptions.py b/py/packages/genkit/src/genkit/dotprompt/exceptions.py deleted file mode 100644 index a8bcf07433..0000000000 --- a/py/packages/genkit/src/genkit/dotprompt/exceptions.py +++ /dev/null @@ -1,16 +0,0 @@ -class DotpromptFileError(Exception): - pass - - -class FrontmatterParseError(DotpromptFileError): - pass - - -class VariantConflictError(DotpromptFileError): - pass - - -class TemplateCompileError(DotpromptFileError): - pass - - diff --git a/py/packages/genkit/src/genkit/dotprompt/file_loader.py b/py/packages/genkit/src/genkit/dotprompt/file_loader.py index 5deb21597a..d84f79a9ad 100644 --- a/py/packages/genkit/src/genkit/dotprompt/file_loader.py +++ b/py/packages/genkit/src/genkit/dotprompt/file_loader.py @@ -10,7 +10,7 @@ # TODO: Confirm canonical namespace rules when scanning nested directories. def registry_definition_key(name: str, variant: str | None = None, ns: str | None = None) -> str: - """Build a definition key like JS: "ns/name.variant" where ns/variant are optional.""" + """Build a definition key "ns/name.variant" where ns/variant are optional.""" prefix = f"{ns}/" if ns else "" suffix = f".{variant}" if variant else "" return f"{prefix}{name}{suffix}" @@ -19,24 +19,24 @@ def registry_definition_key(name: str, variant: str | None = None, ns: str | Non def _parse_name_and_variant(filename: str) -> Tuple[str, str | None]: """Extract base name and optional variant from a `.prompt` filename. - JS behavior: + Behavior: - strip `.prompt` - - if remaining contains a single `.`, treat part after first `.` as variant + - if remaining contains a `.` split name and variant at the first dot """ base = filename[:-7] if filename.endswith('.prompt') else filename if '.' in base: parts = base.split('.') - # TODO: Clarify behavior for multiple dots; JS splits then uses parts[0], parts[1] return parts[0], parts[1] return base, None def define_partial(dp: Dotprompt, name: str, source: str) -> None: - """Register a Handlebars partial with the provided `Dotprompt` instance. - - Mirrors JS: `registry.dotprompt.definePartial(name, source)`. - """ - dp.definePartial(name, source) + """Register a Handlebars partial with the provided `Dotprompt` instance.""" + # Support both camelCase and snake_case for Python bindings. + if hasattr(dp, 'definePartial'): + getattr(dp, 'definePartial')(name, source) # type: ignore[attr-defined] + else: + getattr(dp, 'define_partial')(name, source) def define_helper(dp: Dotprompt, name: str, fn: Any) -> None: @@ -50,7 +50,7 @@ def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> Lo - Reads file as UTF-8 - Parses source via `dp.parse` - Does NOT eagerly compile; compilation can be done by caller - - Returns `LoadedPrompt` with JS-parity fields + - Returns a LoadedPrompt instance """ path = Path(file_path) source = path.read_text(encoding='utf-8') @@ -66,12 +66,15 @@ def load_prompt_file(dp: Dotprompt, file_path: str, ns: str | None = None) -> Lo async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[str, Any]: """Render metadata for a parsed template using dotpromptz. - Mirrors JS: `await registry.dotprompt.renderMetadata(parsedPrompt)` and - performs cleanup for null schema descriptions. + Performs cleanup for null schema descriptions. """ - metadata: dict[str, Any] = await dp.renderMetadata(loaded.template) + # Support both camelCase and snake_case for Python bindings. + if hasattr(dp, 'renderMetadata'): + metadata: dict[str, Any] = await getattr(dp, 'renderMetadata')(loaded.template) # type: ignore[attr-defined] + else: + metadata = await getattr(dp, 'render_metadata')(loaded.template) - # Remove null descriptions (JS parity) + # Remove null descriptions try: if metadata.get('output', {}).get('schema', {}).get('description', None) is None: metadata['output']['schema'].pop('description', None) @@ -90,7 +93,7 @@ async def render_prompt_metadata(dp: Dotprompt, loaded: LoadedPrompt) -> dict[st def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]: """Yield (path, subdir) for files under dir recursively. - subdir is the relative directory from the root, used for namespacing like JS. + subdir is the relative directory from the root, used for namespacing. """ root = Path(dir_path).resolve() for current_dir, _dirs, files in os.walk(root): @@ -104,18 +107,12 @@ def _iter_prompt_dir(dir_path: str) -> Iterable[Tuple[Path, str]]: def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict[str, LoadedPrompt]: """Recursively scan a directory, registering partials and loading prompts. - Behavior mirrors JS `loadPromptFolderRecursively`: - - Files starting with `_` are treated as partials; register via definePartial - - Other `.prompt` files are parsed and returned - - If a file is in a subdirectory, that subdirectory is prefixed to the prompt name - using the `ns` portion of the key ("ns/subdir/name.variant") + - Files starting with `_` are treated as partials; register via definePartial + - Other `.prompt` files are parsed and returned + - If a file is in a subdirectory, that subdirectory is prefixed to the prompt name + using the definition key semantics ("ns/subdir/name.variant") Returns a dict mapping definition keys to `LoadedPrompt`. - - TODO: Confirm whether subdir should be appended to `ns` or included in name. - The JS implementation includes subdir in the registry key's namespace portion - by passing a prefix into `registryDefinitionKey`. We follow a similar approach - by merging `ns` and subdir with a `/`. """ loaded: Dict[str, LoadedPrompt] = {} for file_path, subdir in _iter_prompt_dir(dir_path): @@ -129,9 +126,7 @@ def load_prompt_dir(dp: Dotprompt, dir_path: str, ns: str | None = None) -> Dict # Regular prompt file name, variant = _parse_name_and_variant(fname) - # JS includes subdir in the prompt "name" prefix, not in ns. - # name = `${subDir ? `${subDir}/` : ''}${basename(filename, '.prompt')}` - # Keep ns unchanged. + # Include subdir in the prompt "name" prefix, not in ns. name_with_prefix = f"{subdir}/{name}" if subdir else name loaded_prompt = load_prompt_file(dp, str(file_path), ns=ns) diff --git a/py/packages/genkit/src/genkit/dotprompt/types.py b/py/packages/genkit/src/genkit/dotprompt/types.py index d669e127b8..2ec0d5bd49 100644 --- a/py/packages/genkit/src/genkit/dotprompt/types.py +++ b/py/packages/genkit/src/genkit/dotprompt/types.py @@ -6,14 +6,7 @@ @dataclass(frozen=True) class PromptFileId: - """Represents a unique identifier for a prompt file. - - Matches the JS key composition logic using `registryDefinitionKey`: - "ns/name.variant" where `ns` and `variant` are optional. - - Note: Integration code will decide the final key string; this structure - simply preserves fields so that integration can build the key. - """ + """Represents a unique identifier for a prompt file.""" name: str variant: Optional[str] = None @@ -22,17 +15,7 @@ class PromptFileId: @dataclass class LoadedPrompt: - """A parsed and compiled prompt. - - - `id`: Parsed identifier components (name, variant, ns). - - `template`: The parsed template AST or representation returned by dotpromptz.parse. - - `source`: The raw file contents. - - `metadata`: Metadata produced by dotpromptz.renderMetadata (optional). - - Notes: - - We intentionally keep this structure minimal and close to JS behavior. - - `compiled` is optional to allow lazy compilation as in JS (loaded on first use). - """ + """A parsed and compiled prompt.""" id: PromptFileId template: Any diff --git a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py index 13f6a1a4c1..e730a0e148 100644 --- a/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py +++ b/py/packages/genkit/tests/genkit/dotprompt/file_loader_test.py @@ -26,11 +26,11 @@ import pytest -from dotpromptz.dotprompt import Dotprompt as RealDotprompt +from dotpromptz.dotprompt import Dotprompt -def _dp() -> RealDotprompt: - return RealDotprompt() +def _dp() -> Dotprompt: + return Dotprompt() def _write(path: Path, content: str) -> None: @@ -107,7 +107,12 @@ async def test_aload_prompt_dir_renders_metadata(tmp_path: Path) -> None: assert "info" in loaded assert loaded["info"].metadata is not None - assert isinstance(loaded["info"].metadata, dict) + # Accept PromptMetadata object from dotpromptz or a dict + try: + from dotpromptz.typing import PromptMetadata as DpPromptMetadata # type: ignore + assert isinstance(loaded["info"].metadata, (dict, DpPromptMetadata)) + except Exception: + assert isinstance(loaded["info"].metadata, dict) def test_name_and_variant_parsing_with_multiple_dots(tmp_path: Path) -> None: @@ -147,14 +152,11 @@ def test_registry_definition_key_and_ns(tmp_path: Path) -> None: assert loaded[key].id.ns == 'myNS' # Verify Registry-style lookup composition via the public API (optional) - try: - from genkit.ai import Genkit # type: ignore - except Exception: - pytest.skip('Real engine not available; skipping Genkit import check') - else: - ai = Genkit(model='echoModel', prompt_dir=str(prompts_dir), prompt_ns='myNS') - found = ai.lookup_loaded_prompt('sub/a', variant='formal', ns='myNS') - assert found is not None + from genkit.core.registry import Registry + reg = Registry() + reg.load_prompt_folder(str(prompts_dir), ns='myNS') + found = reg.lookup_loaded_prompt('sub/a', variant='formal', ns='myNS') + assert found is not None @pytest.mark.asyncio @@ -163,8 +165,7 @@ async def test_single_file_load_with_metadata(tmp_path: Path) -> None: _write(file_path, _simple_prompt_frontmatter() + "Single.\n") from genkit.dotprompt import aload_prompt_file - - dp = FakeDotprompt() + dp = _dp() loaded = await aload_prompt_file(dp, str(file_path), ns='n1', with_metadata=True) assert loaded.id.name == 'single' @@ -182,8 +183,7 @@ async def test_variant_in_subdir_with_ns_and_metadata(tmp_path: Path) -> None: ) from genkit.dotprompt import aload_prompt_dir - - dp = FakeDotprompt() + dp = _dp() loaded = await aload_prompt_dir(dp, str(prompts_dir), ns='nsX', with_metadata=True) # Expect key: nsX/subdir/name.variant with our builder behavior