Source code for promptdb.files

"""Load prompts from files and write specs and version bundles to disk.

Supported input formats:

- **YAML / JSON** (``.yaml``, ``.yml``, ``.json``) — parsed as a full
  :class:`~promptdb.domain.PromptSpec` with kind, messages, metadata, etc.
- **Plain text** (``.txt``, ``.md``, ``.prompt``, ``.jinja``, ``.mustache``)
  — the file body becomes the template. You must specify ``kind`` explicitly.

Loading a structured file::

    from promptdb.files import load_prompt_file

    spec = load_prompt_file("prompts/support_classifier.yaml")
    print(spec.kind)       # PromptKind.CHAT
    print(spec.messages)   # [ChatMessage(...), ...]

Loading a plain-text file::

    spec = load_prompt_file(
        "prompts/answerer.md", kind=PromptKind.STRING,
    )

Saving a spec back to disk::

    from promptdb.files import save_prompt_spec

    save_prompt_spec(spec, "build/classifier.yaml")   # YAML
    save_prompt_spec(spec, "build/classifier.json")   # JSON

Exporting a full version bundle (includes version_id, revision, aliases)::

    from promptdb.files import write_version_bundle

    write_version_bundle(version_view, "build/classifier.json")
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import yaml

from promptdb.domain import (
    ChatMessage,
    MessageRole,
    PromptKind,
    PromptMetadata,
    PromptSpec,
    PromptVersionView,
)

_STRUCTURED_SUFFIXES = {".json", ".yaml", ".yml"}
_TEXT_SUFFIXES = {".txt", ".md", ".prompt", ".jinja", ".mustache"}


def _normalize_structured_payload(payload: dict[str, Any], source_path: Path) -> PromptSpec:
    """Normalize a structured prompt payload into a prompt spec.

    Args:
        payload: Parsed mapping payload.
        source_path: Source file path.

    Returns:
        PromptSpec: Validated prompt specification.

    Raises:
        ValueError: If the payload shape is invalid.

    Examples:
        >>> payload = {'kind': 'string', 'template': 'Hi {name}'}
        >>> spec = _normalize_structured_payload(payload, Path('x.yaml'))
        >>> spec.kind.value
        'string'
    """
    data = dict(payload)
    metadata_payload = dict(data.get("metadata") or {})
    metadata_payload.setdefault("source_path", str(source_path))
    data["metadata"] = PromptMetadata.model_validate(metadata_payload)
    return PromptSpec.model_validate(data)


[docs] def load_prompt_file( path: str | Path, *, kind: PromptKind | None = None, message_role: MessageRole = MessageRole.HUMAN, ) -> PromptSpec: """Load a prompt spec from a plain-text or structured file. Args: path: File path. kind: Prompt kind for plain-text files. Structured files can omit this. message_role: Message role for plain-text chat prompts. Returns: PromptSpec: Loaded prompt specification. Raises: FileNotFoundError: If the file is missing. ValueError: If ``kind`` is omitted for plain-text files. Examples: .. code-block:: python spec = load_prompt_file('prompts/demo.txt', kind=PromptKind.STRING) spec = load_prompt_file('prompts/demo.yaml') """ file_path = Path(path) suffix = file_path.suffix.lower() if suffix in _STRUCTURED_SUFFIXES: text = file_path.read_text(encoding="utf-8") payload = json.loads(text) if suffix == ".json" else yaml.safe_load(text) if not isinstance(payload, dict): raise ValueError("Structured prompt files must contain a mapping payload.") return _normalize_structured_payload(payload, file_path) if kind is None: raise ValueError("Plain-text prompt files require an explicit kind.") body = file_path.read_text(encoding="utf-8") metadata = PromptMetadata(title=file_path.stem, source_path=str(file_path)) if kind is PromptKind.STRING: return PromptSpec(kind=kind, template=body, metadata=metadata) messages = [ChatMessage(role=message_role, template=body)] return PromptSpec(kind=kind, messages=messages, metadata=metadata)
[docs] def save_prompt_spec(spec: PromptSpec, path: str | Path) -> Path: """Write a prompt spec to JSON or YAML. Args: spec: Prompt specification. path: Output file path. Returns: Path: Output path. Raises: ValueError: If the suffix is unsupported. OSError: If writing fails. Examples: .. code-block:: python save_prompt_spec(spec, 'build/demo.yaml') """ output_path = Path(path) output_path.parent.mkdir(parents=True, exist_ok=True) payload = spec.model_dump(mode="json", exclude_none=True, exclude_computed_fields=True) suffix = output_path.suffix.lower() if suffix == ".json": output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") return output_path if suffix in {".yaml", ".yml"}: output_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") return output_path raise ValueError("Prompt specs can only be saved as .json, .yaml, or .yml.")
[docs] def write_version_bundle(version: PromptVersionView, path: str | Path) -> Path: """Write a version bundle to a file. Args: version: Prompt version. path: Output file path. Returns: Path: Output path. Raises: OSError: If writing fails. Examples: .. code-block:: python write_version_bundle(version, 'build/version.json') """ output_path = Path(path) output_path.parent.mkdir(parents=True, exist_ok=True) payload = version.model_dump(mode="json", exclude_none=True) suffix = output_path.suffix.lower() if suffix in {".yaml", ".yml"}: output_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") else: output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") return output_path