promptmap 0.5.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. promptmap-0.5.12/PKG-INFO +101 -0
  2. promptmap-0.5.12/README.md +85 -0
  3. promptmap-0.5.12/promptmap/__init__.py +479 -0
  4. promptmap-0.5.12/promptmap/classifier.py +165 -0
  5. promptmap-0.5.12/promptmap/cli/__init__.py +828 -0
  6. promptmap-0.5.12/promptmap/cli/init_files.py +694 -0
  7. promptmap-0.5.12/promptmap/cli/pretty_print.py +273 -0
  8. promptmap-0.5.12/promptmap/cli/test.py +560 -0
  9. promptmap-0.5.12/promptmap/cli/type_hints.py +249 -0
  10. promptmap-0.5.12/promptmap/cli/vscode.py +33 -0
  11. promptmap-0.5.12/promptmap/db.py +124 -0
  12. promptmap-0.5.12/promptmap/docs.py +282 -0
  13. promptmap-0.5.12/promptmap/executor/__init__.py +48 -0
  14. promptmap-0.5.12/promptmap/executor/builtin_kinds.py +467 -0
  15. promptmap-0.5.12/promptmap/executor/executor.py +524 -0
  16. promptmap-0.5.12/promptmap/executor/graph.py +251 -0
  17. promptmap-0.5.12/promptmap/executor/parser_initial.py +191 -0
  18. promptmap-0.5.12/promptmap/executor/parser_model.py +223 -0
  19. promptmap-0.5.12/promptmap/executor/run_context.py +580 -0
  20. promptmap-0.5.12/promptmap/expectations.py +42 -0
  21. promptmap-0.5.12/promptmap/files.py +910 -0
  22. promptmap-0.5.12/promptmap/gates.py +158 -0
  23. promptmap-0.5.12/promptmap/global_instruction.py +59 -0
  24. promptmap-0.5.12/promptmap/html_templates/_nav.html +8 -0
  25. promptmap-0.5.12/promptmap/html_templates/docs.html +324 -0
  26. promptmap-0.5.12/promptmap/html_templates/run.html +96 -0
  27. promptmap-0.5.12/promptmap/jsonpath.py +121 -0
  28. promptmap-0.5.12/promptmap/llm.py +161 -0
  29. promptmap-0.5.12/promptmap/model_spec.py +64 -0
  30. promptmap-0.5.12/promptmap/model_types.py +238 -0
  31. promptmap-0.5.12/promptmap/project_values.py +70 -0
  32. promptmap-0.5.12/promptmap/promptparams.py +444 -0
  33. promptmap-0.5.12/promptmap/rag.py +52 -0
  34. promptmap-0.5.12/promptmap/run_ids.py +120 -0
  35. promptmap-0.5.12/promptmap/secrets.py +80 -0
  36. promptmap-0.5.12/promptmap/server/__init__.py +28 -0
  37. promptmap-0.5.12/promptmap/server/__main__.py +36 -0
  38. promptmap-0.5.12/promptmap/server/app.py +259 -0
  39. promptmap-0.5.12/promptmap/storage/__init__.py +15 -0
  40. promptmap-0.5.12/promptmap/storage/base.py +69 -0
  41. promptmap-0.5.12/promptmap/storage/memory.py +255 -0
  42. promptmap-0.5.12/promptmap/storage/sqlite.py +457 -0
  43. promptmap-0.5.12/promptmap/tester.py +475 -0
  44. promptmap-0.5.12/promptmap/tokens.py +64 -0
  45. promptmap-0.5.12/promptmap/types.py +25 -0
  46. promptmap-0.5.12/promptmap/validator.py +113 -0
  47. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/__init__.py +9 -0
  48. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent/run_agent.py +517 -0
  49. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent37_exec.py +881 -0
  50. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent_exec.py +659 -0
  51. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/modal_exec.py +422 -0
  52. promptmap-0.5.12/promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/models.py +29 -0
  53. promptmap-0.5.12/promptmap.egg-info/PKG-INFO +101 -0
  54. promptmap-0.5.12/promptmap.egg-info/SOURCES.txt +84 -0
  55. promptmap-0.5.12/promptmap.egg-info/dependency_links.txt +1 -0
  56. promptmap-0.5.12/promptmap.egg-info/entry_points.txt +2 -0
  57. promptmap-0.5.12/promptmap.egg-info/requires.txt +12 -0
  58. promptmap-0.5.12/promptmap.egg-info/top_level.txt +2 -0
  59. promptmap-0.5.12/pyproject.toml +51 -0
  60. promptmap-0.5.12/setup.cfg +4 -0
  61. promptmap-0.5.12/tests/test_agent_kind.py +169 -0
  62. promptmap-0.5.12/tests/test_agent_opencode.py +179 -0
  63. promptmap-0.5.12/tests/test_cache_opt_out.py +52 -0
  64. promptmap-0.5.12/tests/test_classifier.py +322 -0
  65. promptmap-0.5.12/tests/test_cli.py +105 -0
  66. promptmap-0.5.12/tests/test_config_keys.py +92 -0
  67. promptmap-0.5.12/tests/test_each.py +226 -0
  68. promptmap-0.5.12/tests/test_each_fanout.py +228 -0
  69. promptmap-0.5.12/tests/test_expectations.py +30 -0
  70. promptmap-0.5.12/tests/test_files.py +618 -0
  71. promptmap-0.5.12/tests/test_gates.py +126 -0
  72. promptmap-0.5.12/tests/test_global_instruction.py +212 -0
  73. promptmap-0.5.12/tests/test_integration_agent.py +66 -0
  74. promptmap-0.5.12/tests/test_integration_opencode.py +81 -0
  75. promptmap-0.5.12/tests/test_main.py +77 -0
  76. promptmap-0.5.12/tests/test_model_folders.py +33 -0
  77. promptmap-0.5.12/tests/test_model_types.py +192 -0
  78. promptmap-0.5.12/tests/test_plugins.py +100 -0
  79. promptmap-0.5.12/tests/test_progress_callbacks.py +91 -0
  80. promptmap-0.5.12/tests/test_project_values.py +152 -0
  81. promptmap-0.5.12/tests/test_promptparams.py +727 -0
  82. promptmap-0.5.12/tests/test_python_ref_cache.py +37 -0
  83. promptmap-0.5.12/tests/test_run_ids.py +359 -0
  84. promptmap-0.5.12/tests/test_secrets.py +260 -0
  85. promptmap-0.5.12/tests/test_test_cache.py +54 -0
  86. promptmap-0.5.12/tests/test_tokens.py +142 -0
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: promptmap
3
+ Version: 0.5.12
4
+ Summary: A dbt-inspired prompt orchestration tool for LLMs
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: networkx>=3.0
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: python-dotenv>=1.2.2
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: mini-swe-agent>=2.0
14
+ Provides-Extra: extras
15
+ Requires-Dist: promptmap-extra-model-kinds; python_version >= "3.11" and extra == "extras"
16
+
17
+ # promptmap
18
+
19
+ A **data-engineering-inspired** prompt orchestration tool for LLMs (think `dbt` for prompts).
20
+
21
+ **Why `promptmap`?** Treating complex LLM workflows as giant one-shot prompts is like navigating a city by staring at your feet, one wrong turn and you're lost. `promptmap` turns your prompts into a visual map of connected hypotheses (`models`) and reality-check landmarks (`tests`), with built-in tools to inspect runs (`promptmap docs`).
22
+
23
+ **Mechanically, this means you:** write modular prompts in Jinja2, chain them into a dependency graph with `ref()`, and validate outputs against reality with `promptmap test`. Iterate on complex LLM workflows without losing context or rewriting from scratch. *(Read more on the philosophy in [motivation.md](motivation.md)).*
24
+
25
+ ![DAG visual](promptmap_docs.png)
26
+
27
+ ---
28
+
29
+ ## Quick start
30
+
31
+ ```bash
32
+ uv tool install promptmap --with anthropic # or: openai | gemini
33
+ promptmap init --provider anthropic # or: openai | gemini
34
+
35
+ ```
36
+
37
+ Edit your `.prompt` files in `map/`, then run the pipeline, test expectations, and inspect the run:
38
+
39
+ ```bash
40
+ export ANTHROPIC_API_KEY=your_key_here
41
+
42
+ promptmap run # Execute the prompt DAG
43
+ promptmap test # Validate outputs against tests/
44
+ promptmap docs # Generate HTML report of the run and run history
45
+ promptmap serve # Launch the interactive local UI / HTTP server
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Core concepts
51
+
52
+ ### 1. Models (`map/*.prompt`)
53
+
54
+ Each `.prompt` file is a node in your DAG. Use `ref('model_name')` to inject the output of an upstream model:
55
+
56
+ ```jinja
57
+ {# map/outline.prompt #}
58
+ Based on this topic, create a detailed outline:
59
+
60
+ {{ ref('topic') }}
61
+
62
+ ```
63
+
64
+ ### 2. Model types (`config(model_type=...)`)
65
+
66
+ Not every node in your pipeline needs to be a standard LLM call. Override `model_type` at the top of a `.prompt` file for deterministic steps, formatting, or coding agents:
67
+
68
+ | Model Type | What it does | Declaration |
69
+ | --- | --- | --- |
70
+ | **LLM** *(default)* | Sends the rendered Jinja prompt to your configured LLM. | Standard `.prompt` file |
71
+ | **`template`** | Renders Jinja directly with no LLM call (for reshaping upstream outputs). | `{{ config(model_type="template") }}` |
72
+ | **`execute_python`** | Runs rendered template as Python; captures `stdout` or `output` variable. | `{{ config(model_type="execute_python") }}` |
73
+ | **`agent`** | Hands the rendered prompt as a task to a coding agent (`mini-swe-agent`). | `{{ config(model_type="agent", agent_dir="./repo") }}` |
74
+
75
+ *(Optional extra model kinds like `python_modal`, `agent_modal`, and `agent37` are available via `pip install "promptmap[extras]"`).*
76
+
77
+ ### 3. Tests (`tests/*.prompt`)
78
+
79
+ Run `promptmap test` to validate the latest run's outputs against your expectations:
80
+
81
+ * **LLM-as-a-judge:** Prompt tests in `tests/*.prompt` pass when the LLM returns `{"results": "pass"}`.
82
+ * **Deterministic checks:** Compare output fields directly in YAML using `expect` (no LLM judge needed).
83
+
84
+ ### 4. Visualizing & serving (`promptmap docs` & `promptmap serve`)
85
+
86
+ Inspect prompt graph visually:
87
+
88
+ * **`promptmap docs`** generates a self-contained HTML report of every run—showing rendered prompts, model outputs, token usage, and test results across the DAG.
89
+ * **`promptmap serve`** starts a local server to interactively explore the DAG, trigger runs or inspect previous results.
90
+
91
+ ---
92
+
93
+ ## Documentation
94
+
95
+ | Category | Guides |
96
+ | --- | --- |
97
+ | **Inputs & Data** | [Passing variables (`promptdata`)](docs/INTERMEDIATE_USAGE.md#passing-variables-to-templates-promptdata) · [Passing files (`promptfiles`)](docs/INTERMEDIATE_USAGE.md#passing-files-to-models-promptfiles) · [RAG inside prompts](docs/INTERMEDIATE_USAGE.md#rag-inside-prompts-ragpy) · [Global instructions](docs/ADVANCED_USAGE.md#global-instructions-globalprompt) |
98
+ | **Workflow & DAG** | [Grouping models in folders](docs/INTERMEDIATE_USAGE.md#grouping-models-in-folders) · [Map & reduce (`each=`)](docs/ADVANCED_USAGE.md#map-and-reduce-each) · [Dynamically skipping models](docs/INTERMEDIATE_USAGE.md#how-to-dynamically-skip-a-model) · [Models that produce files](docs/ADVANCED_USAGE.md#models-that-produce-files-promptmapfile-promptmapdir-promptmapoutput) · [Run IDs & iterating on the last answer (`previous`)](docs/ADVANCED_USAGE.md#run-ids-and-iterating-on-the-last-answer-previous) |
99
+ | **Testing & Eval** | [Writing tests](docs/INTERMEDIATE_USAGE.md#promptmap-tests) · [Validation](docs/INTERMEDIATE_USAGE.md#validation-validation) · [Bulk YAML testing & `expect`](docs/ADVANCED_USAGE.md#bulk-testing-with-yaml-cases-promptmap-test) |
100
+ | **CLI, UI & API** | [`promptmap docs` (HTML report)](docs/INTERMEDIATE_USAGE.md#promptmap-docs) · [`promptmap serve` & HTTP server](docs/ADVANCED_USAGE.md#promptmap-serve) · [`promptmap ls`](docs/ADVANCED_USAGE.md#promptmap-ls) · [Python API & callbacks](docs/ADVANCED_USAGE.md#python-api) · [Token usage](docs/ADVANCED_USAGE.md#token-usage-promptmapllmresult) |
101
+ | **Customization** | [Custom LLM backend (`client.py`)](docs/INTERMEDIATE_USAGE.md#customising-the-llm-backend-clientpy) · [Output format & `config()`](docs/ADVANCED_USAGE.md#output-format-config-config) · [Agent models](docs/ADVANCED_USAGE.md#agent-models-model_typeagent) · [Custom model kinds](docs/ADVANCED_USAGE.md#writing-your-own-model-kind-model_type) · [Architecture](docs/ARCHITECTURE.md) |
@@ -0,0 +1,85 @@
1
+ # promptmap
2
+
3
+ A **data-engineering-inspired** prompt orchestration tool for LLMs (think `dbt` for prompts).
4
+
5
+ **Why `promptmap`?** Treating complex LLM workflows as giant one-shot prompts is like navigating a city by staring at your feet, one wrong turn and you're lost. `promptmap` turns your prompts into a visual map of connected hypotheses (`models`) and reality-check landmarks (`tests`), with built-in tools to inspect runs (`promptmap docs`).
6
+
7
+ **Mechanically, this means you:** write modular prompts in Jinja2, chain them into a dependency graph with `ref()`, and validate outputs against reality with `promptmap test`. Iterate on complex LLM workflows without losing context or rewriting from scratch. *(Read more on the philosophy in [motivation.md](motivation.md)).*
8
+
9
+ ![DAG visual](promptmap_docs.png)
10
+
11
+ ---
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ uv tool install promptmap --with anthropic # or: openai | gemini
17
+ promptmap init --provider anthropic # or: openai | gemini
18
+
19
+ ```
20
+
21
+ Edit your `.prompt` files in `map/`, then run the pipeline, test expectations, and inspect the run:
22
+
23
+ ```bash
24
+ export ANTHROPIC_API_KEY=your_key_here
25
+
26
+ promptmap run # Execute the prompt DAG
27
+ promptmap test # Validate outputs against tests/
28
+ promptmap docs # Generate HTML report of the run and run history
29
+ promptmap serve # Launch the interactive local UI / HTTP server
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Core concepts
35
+
36
+ ### 1. Models (`map/*.prompt`)
37
+
38
+ Each `.prompt` file is a node in your DAG. Use `ref('model_name')` to inject the output of an upstream model:
39
+
40
+ ```jinja
41
+ {# map/outline.prompt #}
42
+ Based on this topic, create a detailed outline:
43
+
44
+ {{ ref('topic') }}
45
+
46
+ ```
47
+
48
+ ### 2. Model types (`config(model_type=...)`)
49
+
50
+ Not every node in your pipeline needs to be a standard LLM call. Override `model_type` at the top of a `.prompt` file for deterministic steps, formatting, or coding agents:
51
+
52
+ | Model Type | What it does | Declaration |
53
+ | --- | --- | --- |
54
+ | **LLM** *(default)* | Sends the rendered Jinja prompt to your configured LLM. | Standard `.prompt` file |
55
+ | **`template`** | Renders Jinja directly with no LLM call (for reshaping upstream outputs). | `{{ config(model_type="template") }}` |
56
+ | **`execute_python`** | Runs rendered template as Python; captures `stdout` or `output` variable. | `{{ config(model_type="execute_python") }}` |
57
+ | **`agent`** | Hands the rendered prompt as a task to a coding agent (`mini-swe-agent`). | `{{ config(model_type="agent", agent_dir="./repo") }}` |
58
+
59
+ *(Optional extra model kinds like `python_modal`, `agent_modal`, and `agent37` are available via `pip install "promptmap[extras]"`).*
60
+
61
+ ### 3. Tests (`tests/*.prompt`)
62
+
63
+ Run `promptmap test` to validate the latest run's outputs against your expectations:
64
+
65
+ * **LLM-as-a-judge:** Prompt tests in `tests/*.prompt` pass when the LLM returns `{"results": "pass"}`.
66
+ * **Deterministic checks:** Compare output fields directly in YAML using `expect` (no LLM judge needed).
67
+
68
+ ### 4. Visualizing & serving (`promptmap docs` & `promptmap serve`)
69
+
70
+ Inspect prompt graph visually:
71
+
72
+ * **`promptmap docs`** generates a self-contained HTML report of every run—showing rendered prompts, model outputs, token usage, and test results across the DAG.
73
+ * **`promptmap serve`** starts a local server to interactively explore the DAG, trigger runs or inspect previous results.
74
+
75
+ ---
76
+
77
+ ## Documentation
78
+
79
+ | Category | Guides |
80
+ | --- | --- |
81
+ | **Inputs & Data** | [Passing variables (`promptdata`)](docs/INTERMEDIATE_USAGE.md#passing-variables-to-templates-promptdata) · [Passing files (`promptfiles`)](docs/INTERMEDIATE_USAGE.md#passing-files-to-models-promptfiles) · [RAG inside prompts](docs/INTERMEDIATE_USAGE.md#rag-inside-prompts-ragpy) · [Global instructions](docs/ADVANCED_USAGE.md#global-instructions-globalprompt) |
82
+ | **Workflow & DAG** | [Grouping models in folders](docs/INTERMEDIATE_USAGE.md#grouping-models-in-folders) · [Map & reduce (`each=`)](docs/ADVANCED_USAGE.md#map-and-reduce-each) · [Dynamically skipping models](docs/INTERMEDIATE_USAGE.md#how-to-dynamically-skip-a-model) · [Models that produce files](docs/ADVANCED_USAGE.md#models-that-produce-files-promptmapfile-promptmapdir-promptmapoutput) · [Run IDs & iterating on the last answer (`previous`)](docs/ADVANCED_USAGE.md#run-ids-and-iterating-on-the-last-answer-previous) |
83
+ | **Testing & Eval** | [Writing tests](docs/INTERMEDIATE_USAGE.md#promptmap-tests) · [Validation](docs/INTERMEDIATE_USAGE.md#validation-validation) · [Bulk YAML testing & `expect`](docs/ADVANCED_USAGE.md#bulk-testing-with-yaml-cases-promptmap-test) |
84
+ | **CLI, UI & API** | [`promptmap docs` (HTML report)](docs/INTERMEDIATE_USAGE.md#promptmap-docs) · [`promptmap serve` & HTTP server](docs/ADVANCED_USAGE.md#promptmap-serve) · [`promptmap ls`](docs/ADVANCED_USAGE.md#promptmap-ls) · [Python API & callbacks](docs/ADVANCED_USAGE.md#python-api) · [Token usage](docs/ADVANCED_USAGE.md#token-usage-promptmapllmresult) |
85
+ | **Customization** | [Custom LLM backend (`client.py`)](docs/INTERMEDIATE_USAGE.md#customising-the-llm-backend-clientpy) · [Output format & `config()`](docs/ADVANCED_USAGE.md#output-format-config-config) · [Agent models](docs/ADVANCED_USAGE.md#agent-models-model_typeagent) · [Custom model kinds](docs/ADVANCED_USAGE.md#writing-your-own-model-kind-model_type) · [Architecture](docs/ARCHITECTURE.md) |
@@ -0,0 +1,479 @@
1
+ """promptmap — dbt-inspired LLM prompt orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import Enum
7
+ from importlib.metadata import PackageNotFoundError, version as package_version
8
+ from pathlib import Path
9
+ from typing import Awaitable, Callable, Mapping
10
+
11
+ from promptmap.executor.parser_initial import (
12
+ UnknownConfigKeyWarning,
13
+ known_config_keys,
14
+ register_config_keys,
15
+ )
16
+ from promptmap.classifier import classifier_cache_check, systemone_classifier
17
+ from promptmap.executor.graph import UnknownModelTypeError
18
+ from promptmap.model_spec import ModelSpec
19
+ from promptmap.model_types import (
20
+ ModelCall,
21
+ ModelKind,
22
+ get_model_kind,
23
+ known_model_kinds,
24
+ model_kind,
25
+ register_model_kind,
26
+ )
27
+ from promptmap.files import (
28
+ BlobStore,
29
+ Dir,
30
+ File,
31
+ MemoryBlobStore,
32
+ Output,
33
+ SQLiteBlobStore,
34
+ )
35
+ from promptmap.secrets import Secrets
36
+ from promptmap.storage.base import StorageBackend
37
+ from promptmap.tokens import LLMResult
38
+ from promptmap.executor.executor import ModelRunResult
39
+ from promptmap.executor.parser_model import PreviousRun
40
+ from promptmap.types import PromptFile, PromptModelsDict
41
+
42
+ # Registers the built-in kinds ("", template, execute_python, agent). Imported here,
43
+ # after the registry exists, so that every entry point into promptmap has them without
44
+ # the registry needing a lazy-import hook.
45
+ import promptmap.executor.builtin_kinds # noqa: E402,F401
46
+
47
+ __all__ = [
48
+ "run",
49
+ "async_run",
50
+ "ModelSpec",
51
+ "ModelKind",
52
+ "ModelCall",
53
+ "model_kind",
54
+ "register_model_kind",
55
+ "get_model_kind",
56
+ "known_model_kinds",
57
+ "register_config_keys",
58
+ "known_config_keys",
59
+ "UnknownConfigKeyWarning",
60
+ "UnknownModelTypeError",
61
+ "systemone_classifier",
62
+ "classifier_cache_check",
63
+ "ModelStatus",
64
+ "ModelError",
65
+ "PromptModelsDict",
66
+ "PromptFile",
67
+ "StorageBackend",
68
+ "Secrets",
69
+ "File",
70
+ "Dir",
71
+ "Output",
72
+ "LLMResult",
73
+ "ModelRunResult",
74
+ "PreviousRun",
75
+ "BlobStore",
76
+ "MemoryBlobStore",
77
+ "SQLiteBlobStore",
78
+ "__version__",
79
+ ]
80
+
81
+
82
+ def _version_from_pyproject() -> str:
83
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
84
+ contents = pyproject.read_text(encoding="utf-8")
85
+ project_block = re.search(r"(?ms)^\[project\]\n(.*?)(?:^\[|\Z)", contents)
86
+ if project_block is None:
87
+ raise RuntimeError("Could not find [project] table in pyproject.toml")
88
+
89
+ match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', project_block.group(1), re.MULTILINE)
90
+ if match is None:
91
+ raise RuntimeError("Could not find project.version in pyproject.toml")
92
+ return match.group(1)
93
+
94
+
95
+ def _detect_version() -> str:
96
+ try:
97
+ return package_version("promptmap")
98
+ except PackageNotFoundError:
99
+ return _version_from_pyproject()
100
+
101
+
102
+ __version__ = _detect_version()
103
+
104
+
105
+ class ModelStatus(Enum):
106
+ """Returned in the promptmap.run() dict for models that produced no LLM output."""
107
+ SKIPPED = "skipped" # upstream dependency failed → model was not run
108
+ ERROR = "error" # LLM call or render raised an exception (legacy)
109
+
110
+
111
+ class ModelError:
112
+ """Returned by promptmap.run() when a model fails; carries the exception message."""
113
+ def __init__(self, message: str) -> None:
114
+ self.message = message
115
+
116
+ def __str__(self) -> str:
117
+ return f"error: {self.message}"
118
+
119
+
120
+ async def async_run(
121
+ map_dir: str = "map",
122
+ models_from_dict: PromptModelsDict | dict[str, str] | None = None,
123
+ select: list[str] | None = None,
124
+ llm_call: Callable[[str], str | Awaitable[str]] | None = None,
125
+ rag_call: Callable[..., list] | None = None,
126
+ verbose: bool = True,
127
+ promptdata: dict | None = None,
128
+ promptfiles: dict[str, PromptFile] | None = None,
129
+ validation_dir: str | None = "validation",
130
+ tests_dir: str | None = "tests",
131
+ storage_backend: StorageBackend | None = None,
132
+ global_instruction: str | Callable[[], str] | None = None,
133
+ blob_store: BlobStore | None = None,
134
+ on_model_start: Callable[[str], None] | None = None,
135
+ on_model_done: Callable[[ModelRunResult], None] | None = None,
136
+ secrets: Mapping[str, str] | None = None,
137
+ cache_check: Callable[..., bool | Awaitable[bool]] | None = None,
138
+ ):
139
+ """
140
+ Execute prompt models as a Python library call.
141
+
142
+ Parameters
143
+ ----------
144
+ map_dir:
145
+ Path to the directory containing *.prompt files.
146
+ select:
147
+ Optional list of model names to run. All upstream dependencies are
148
+ also executed fresh; the prompt cache makes unchanged nodes instant.
149
+ llm_call:
150
+ Optional function ``(prompt: str) -> str`` to use as the LLM backend.
151
+ Falls back to ``llm_call`` in client.py alongside *map_dir*; with
152
+ neither, the run fails with a FileNotFoundError naming the fix.
153
+ rag_call:
154
+ Optional function ``(*args) -> list | str`` to back
155
+ ``return_list_RAG_results()`` in templates.
156
+ Falls back to ``map/rag.py::do_RAG`` if present.
157
+ verbose:
158
+ Print a dbt-style progress log to stdout (default: True).
159
+ promptdata:
160
+ Optional dict of runtime variables. Access them in templates via
161
+ ``{{ promptdata('key') }}``.
162
+ promptfiles:
163
+ Optional dict mapping name → file reference. Models that declare
164
+ ``promptfiles: name`` in their config block will receive these as a
165
+ list passed to ``llm_call(prompt, files=[…])``.
166
+ Values may be a **string path**, a :class:`pathlib.Path`, or any
167
+ **binary file-like object** (``open(..., "rb")``, ``io.BytesIO``,
168
+ etc.) — whatever your ``llm_call`` implementation accepts.
169
+ tests_dir:
170
+ The tests directory, hashed into the run's ``map_hash`` so a changed
171
+ test changes the map's ID (see :mod:`promptmap.run_ids`). Ignored
172
+ for ``models_from_dict`` runs.
173
+ global_instruction:
174
+ Optional prompt text rendered into **every** model prompt — promptmap's
175
+ analogue of dbt's query-comment. Either a string or a zero-argument
176
+ callable returning one. Falls back to a ``global.prompt`` file
177
+ alongside ``map_dir`` when not given (file-based runs only).
178
+
179
+ The text is itself a Jinja template rendered with each model's own
180
+ context, so ``promptdata()``, ``model.name`` and ``was_skipped()``
181
+ work inside it; ``ref()`` does not, since that would make every model
182
+ depend on the referenced one. Reference ``{{ prompt }}`` to place the
183
+ model body explicitly, otherwise the instruction is prepended.
184
+
185
+ Individual models opt out with
186
+ ``{{ config(global_instruction=False) }}``, and ``execute_python``
187
+ models never receive it.
188
+ blob_store:
189
+ Where the bytes of file outputs (:class:`File`, :class:`Dir`,
190
+ :class:`Output`) are kept. Falls back to ``blob_store`` in client.py,
191
+ then to the storage backend's own store (the SQLite database).
192
+ on_model_start:
193
+ Optional callback ``(model_name: str) -> None`` fired just before a
194
+ model starts executing. Use it for live progress in your own code.
195
+ on_model_done:
196
+ Optional callback ``(result: ModelRunResult) -> None`` fired when a
197
+ model finishes, whether it succeeded, errored or was skipped.
198
+ ``result.status`` is ``"success"``, ``"error"`` or ``"skipped"``.
199
+ secrets:
200
+ Optional dict of run-scoped credentials (API keys, tokens). Model
201
+ kinds read them as ``call.secrets["NAME"]``; an ``llm_call`` that
202
+ declares a ``secrets`` parameter receives them as well, so one
203
+ ``llm_call`` can serve a different key per run. They are never
204
+ rendered into templates, part of the cache key, stored, or shown in a
205
+ repr — changing a key's value still gets cache hits.
206
+ cache_check:
207
+ Optional function ``(prompt: str, previous: PreviousRun) -> bool``
208
+ asked on an exact prompt-cache miss whether the model's last
209
+ successful result (``previous.prompt``, ``previous.output``) is still
210
+ good enough for the new *prompt*. Return True to reuse it as a cache
211
+ hit — e.g. a cheap judge model deciding whether the prompt changed
212
+ meaningfully. Sync or async; may also declare ``config``,
213
+ ``secrets`` and ``model`` parameters. Falls back to ``cache_check``
214
+ in client.py.
215
+
216
+ Returns
217
+ -------
218
+ ``dict`` keyed by model name. Each value is the model's output string —
219
+ or, for a model that produced files, its :class:`File` / :class:`Dir` /
220
+ :class:`Output` value (or the dict/list holding them) — or
221
+ :class:`ModelStatus.SKIPPED` when an upstream model failed, or a
222
+ :class:`ModelError` carrying the message when that model itself failed.
223
+
224
+ The full per-model detail — rendered prompt, timing, cache hit — is not in
225
+ this return value; it is written to storage and read back by ``promptmap test``
226
+ and ``promptmap docs``.
227
+ """
228
+ import time
229
+ from datetime import datetime
230
+
231
+ from promptmap.executor.executor import execute_run, ModelRunResult
232
+ from promptmap.executor.graph import (
233
+ load_models,
234
+ build_models_from_dict,
235
+ build_dag,
236
+ )
237
+ if storage_backend is None:
238
+ from promptmap.storage.sqlite import SQLiteStorageBackend
239
+ storage_backend = SQLiteStorageBackend()
240
+
241
+ console = None
242
+ if verbose:
243
+ from rich.console import Console
244
+ console = Console(highlight=False, soft_wrap=True)
245
+
246
+ def ts() -> str:
247
+ return datetime.now().strftime("%H:%M:%S")
248
+
249
+ def log(msg: str) -> None:
250
+ if verbose:
251
+ console.print(f"[dim]{ts()}[/dim] {msg}")
252
+
253
+ storage_backend.init_db()
254
+
255
+ # client.py is imported before the models are parsed: it is where a project
256
+ # registers its own model types, and an unregistered type looks like a typo.
257
+ if models_from_dict is None:
258
+ from promptmap.llm import try_load_client_module
259
+
260
+ try_load_client_module(map_dir)
261
+
262
+ raw = models_from_dict.models if isinstance(models_from_dict, PromptModelsDict) else models_from_dict
263
+ all_models = build_models_from_dict(raw) if models_from_dict is not None else load_models(map_dir)
264
+
265
+ if select:
266
+ import networkx as nx
267
+ dag = build_dag(all_models)
268
+ to_run: set[str] = set(select)
269
+ for name in select:
270
+ to_run.update(nx.ancestors(dag, name))
271
+ ordered = [all_models[name] for name in to_run if name in all_models]
272
+ else:
273
+ ordered = list(all_models.values())
274
+
275
+ git_sha = None
276
+ if models_from_dict is None:
277
+ import subprocess
278
+ try:
279
+ git_sha = subprocess.check_output(
280
+ ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL
281
+ ).decode().strip()
282
+ except Exception:
283
+ git_sha = None
284
+
285
+ # Resolve LLM and RAG backends from user files if not explicitly provided
286
+ if llm_call is None:
287
+ if models_from_dict is not None:
288
+ raise ValueError(
289
+ "llm_call must be provided when using models_from_dict. "
290
+ "Inline runs do not auto-load client.py from disk."
291
+ )
292
+ from promptmap.llm import resolve_llm_call
293
+ llm_call = resolve_llm_call(map_dir)
294
+ if rag_call is None and models_from_dict is None:
295
+ from promptmap.rag import resolve_rag_call
296
+ rag_call = resolve_rag_call(map_dir)
297
+ if blob_store is None and models_from_dict is None:
298
+ from promptmap.llm import resolve_blob_store
299
+ blob_store = resolve_blob_store(map_dir)
300
+ if cache_check is None and models_from_dict is None:
301
+ from promptmap.llm import resolve_cache_check
302
+ cache_check = resolve_cache_check(map_dir)
303
+
304
+ # Global instruction: explicit argument wins, else global.prompt on disk.
305
+ from promptmap.global_instruction import (
306
+ coerce_global_instruction,
307
+ resolve_global_instruction,
308
+ )
309
+ global_instruction = coerce_global_instruction(global_instruction)
310
+ if global_instruction is None and models_from_dict is None:
311
+ global_instruction = resolve_global_instruction(map_dir)
312
+
313
+ # Load per-model validators from validation_dir (optional)
314
+ validators = None
315
+ if validation_dir is not None and models_from_dict is None:
316
+ from promptmap.validator import load_validators
317
+ validators = load_validators(validation_dir)
318
+
319
+ run_id = storage_backend.create_run(
320
+ model_count=len(ordered),
321
+ git_sha=git_sha,
322
+ )
323
+ from promptmap.run_ids import record_run_ids
324
+
325
+ record_run_ids(
326
+ storage_backend,
327
+ run_id,
328
+ all_models.values(),
329
+ ordered,
330
+ tests_dir=tests_dir if models_from_dict is None else None,
331
+ global_instruction=global_instruction,
332
+ promptdata=promptdata,
333
+ promptfiles=promptfiles,
334
+ )
335
+
336
+ total = len(ordered)
337
+
338
+ if verbose:
339
+ console.print(
340
+ f"[dim]{ts()}[/dim] Running with [bold cyan]promptmap[/bold cyan]={__version__}"
341
+ )
342
+ console.print(
343
+ f"[dim]{ts()}[/dim] Found [bold]{total}[/bold] prompt model{'s' if total != 1 else ''}"
344
+ + (f" [dim](select: {sorted(select)})[/dim]" if select else "")
345
+ )
346
+ if git_sha:
347
+ console.print(f"[dim]{ts()}[/dim] git SHA [dim]{git_sha}[/dim]")
348
+ console.print(f"[dim]{ts()}[/dim]")
349
+
350
+ completed: list[ModelRunResult] = []
351
+ model_start_times: dict[str, float] = {}
352
+
353
+ def _log_model_line(idx: int, verb: str, name: str, badge: str) -> None:
354
+ prefix = f"{idx} of {total} {verb} prompt model {name} "
355
+ dots = "." * max(2, 50 - len(prefix))
356
+ if verbose:
357
+ console.print(f"[dim]{ts()}[/dim] [bold]{idx} of {total}[/bold] {verb} prompt model [cyan]{name}[/cyan] {dots} {badge}")
358
+
359
+ def on_start(name: str) -> None:
360
+ idx = len(completed) + 1
361
+ model_start_times[name] = time.monotonic()
362
+ _log_model_line(idx, "START", name, "[[yellow]RUN[/yellow]]")
363
+ if on_model_start:
364
+ on_model_start(name)
365
+
366
+ def on_done(result: ModelRunResult) -> None:
367
+ completed.append(result)
368
+ idx = len(completed)
369
+ elapsed = time.monotonic() - model_start_times.get(result.model_name, 0)
370
+ if result.status == "success":
371
+ cached = " cached" if result.cached else ""
372
+ _log_model_line(idx, "OK ", result.model_name, f"[[green]OK{cached} in {elapsed:.2f}s[/green]]")
373
+ elif result.status == "skipped":
374
+ _log_model_line(idx, "SKIP ", result.model_name, "[[yellow]SKIP[/yellow]]")
375
+ else:
376
+ _log_model_line(idx, "ERR ", result.model_name, "[[red]ERROR[/red]]")
377
+ if verbose:
378
+ console.print(f" [dim]{result.error}[/dim]")
379
+ if on_model_done:
380
+ on_model_done(result)
381
+
382
+ run_start = time.monotonic()
383
+
384
+ results = await execute_run(
385
+ run_id=run_id,
386
+ ordered_models=ordered,
387
+ storage_backend=storage_backend,
388
+ llm_call=llm_call,
389
+ rag_call=rag_call,
390
+ on_model_start=on_start,
391
+ on_model_done=on_done,
392
+ promptdata=promptdata,
393
+ promptfiles=promptfiles,
394
+ validators=validators or None,
395
+ global_instruction=global_instruction,
396
+ blob_store=blob_store,
397
+ secrets=secrets,
398
+ cache_check=cache_check,
399
+ )
400
+
401
+ errors = sum(1 for r in results if r.status == "error")
402
+ skipped = sum(1 for r in results if r.status == "skipped")
403
+ successes = sum(1 for r in results if r.status == "success")
404
+ final_status = "success" if errors == 0 else ("partial" if successes > 0 else "error")
405
+ storage_backend.finish_run(run_id, final_status)
406
+ total_elapsed = time.monotonic() - run_start
407
+
408
+ if verbose:
409
+ console.print(f"[dim]{ts()}[/dim]")
410
+ console.print(
411
+ f"[dim]{ts()}[/dim] Finished running {total} prompt model{'s' if total != 1 else ''} "
412
+ f"in [bold]{total_elapsed:.2f}s[/bold]. "
413
+ + ("[green]Completed successfully[/green]" if errors == 0 else "[red]Completed with errors[/red]")
414
+ )
415
+ pass_str = f"[green]PASS={successes}[/green]"
416
+ error_str = f"[red]ERROR={errors}[/red]"
417
+ skip_str = f"[yellow]SKIP={skipped}[/yellow]"
418
+ total_str = f"TOTAL={total}"
419
+ console.print(f"[dim]{ts()}[/dim] {pass_str} {error_str} {skip_str} {total_str}")
420
+
421
+ def _value(r):
422
+ if r.status == "skipped":
423
+ return ModelStatus.SKIPPED
424
+ if r.status == "error":
425
+ return ModelError(r.error or "error")
426
+ from promptmap.files import contains_files
427
+
428
+ if contains_files(r.value) or isinstance(r.value, str):
429
+ return r.value
430
+ return r.llm_output
431
+
432
+ return {r.model_name: _value(r) for r in results}
433
+
434
+
435
+ def run(
436
+ map_dir: str = "map",
437
+ models_from_dict: "PromptModelsDict | dict[str, str] | None" = None,
438
+ select: "list[str] | None" = None,
439
+ llm_call: "Callable[[str], str | Awaitable[str]] | None" = None,
440
+ rag_call: "Callable[..., list] | None" = None,
441
+ verbose: bool = True,
442
+ promptdata: "dict | None" = None,
443
+ promptfiles: "dict[str, PromptFile] | None" = None,
444
+ validation_dir: "str | None" = "validation",
445
+ tests_dir: "str | None" = "tests",
446
+ storage_backend: "StorageBackend | None" = None,
447
+ global_instruction: "str | Callable[[], str] | None" = None,
448
+ blob_store: "BlobStore | None" = None,
449
+ on_model_start: "Callable[[str], None] | None" = None,
450
+ on_model_done: "Callable[[ModelRunResult], None] | None" = None,
451
+ secrets: "Mapping[str, str] | None" = None,
452
+ cache_check: "Callable[..., bool | Awaitable[bool]] | None" = None,
453
+ ):
454
+ """Run prompt models synchronously.
455
+
456
+ Wraps :func:`async_run` in ``asyncio.run()``; same arguments, same dict
457
+ return value. Call ``async_run`` directly from inside an event loop.
458
+ """
459
+ import asyncio
460
+
461
+ return asyncio.run(async_run(
462
+ map_dir=map_dir,
463
+ models_from_dict=models_from_dict,
464
+ select=select,
465
+ llm_call=llm_call,
466
+ rag_call=rag_call,
467
+ verbose=verbose,
468
+ promptdata=promptdata,
469
+ promptfiles=promptfiles,
470
+ validation_dir=validation_dir,
471
+ tests_dir=tests_dir,
472
+ storage_backend=storage_backend,
473
+ global_instruction=global_instruction,
474
+ blob_store=blob_store,
475
+ on_model_start=on_model_start,
476
+ on_model_done=on_model_done,
477
+ secrets=secrets,
478
+ cache_check=cache_check,
479
+ ))