diff --git a/README.md b/README.md index 9d9f913..7eaae0f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Docs are available [here](https://masci.github.io/banks/). - [Use a LLM to generate a text while rendering a prompt](#use-a-llm-to-generate-a-text-while-rendering-a-prompt) - [Go meta: create a prompt and `generate` its response](#go-meta-create-a-prompt-and-generate-its-response) - [Go meta(meta): process a LLM response](#go-metameta-process-a-llm-response) - - [Reuse templates from files](#reuse-templates-from-files) + - [Reuse templates from registries](#reuse-templates-from-registries) - [Async support](#async-support) - [License](#license) @@ -266,19 +266,22 @@ print(p.text({"topic": "climate change"})) The final answer from the LLM will be printed, this time all in uppercase. -### Reuse templates from files +### Reuse templates from registries -We can get the same result as the previous example loading the prompt template from file -instead of hardcoding it into the Python code. For convenience, Banks comes with a few -default templates distributed the package. We can load those templates from file like this: +We can get the same result as the previous example loading the prompt template from a registry +instead of hardcoding it into the Python code. For convenience, Banks comes with a few registry types +you can use to store your templates. For example, the `DirectoryTemplateRegistry` can load templates +from a directory in the file system. Suppose you have a folder called `templates` in the current path, +and the folder contains a file called `blog.jinja`. You can load the prompt template like this: ```py from banks import Prompt +from banks.registries import DirectoryTemplateRegistry +registry = DirectoryTemplateRegistry(populated_dir) +prompt = registry.get(name="blog") -p = Prompt.from_template("blog.jinja") -topic = "retrogame computing" -print(p.text({"topic": topic})) +print(prompt.text({"topic": "retrogame computing"})) ``` ### Async support @@ -292,7 +295,7 @@ Example: from banks import AsyncPrompt async def main(): - p = AsyncPrompt.from_template("blog.jinja") + p = AsyncPrompt("Write a blog article about the topic {{ topic }}") result = await p.text({"topic": "AI frameworks"}) print(result) diff --git a/docs/python.md b/docs/python.md index 8f61beb..516b5e9 100644 --- a/docs/python.md +++ b/docs/python.md @@ -5,16 +5,46 @@ ::: banks.prompt.AsyncPrompt -## Default templates +## Default macros -Banks' package comes with the following prompt templates ready to be used: +Banks' package comes with default template macros you can use in your prompts. -- `banks_macros.jinja` -- `generate_tweet.jinja` -- `run_prompt_process.jinja` -- `summarize_lemma.jinja` -- `blog.jinja` -- `run_prompt.jinja` -- `summarize.jinja` -If Banks is properly installed, something like `Prompt.from_template("blog.jinja")` should always work out of the box. +### `run_prompt` + + +We can use `run_prompt` in our templates to generate a prompt, send the result to the LLM and get a response. +Take this prompt for example: + +```py +from banks import Prompt + +prompt_template = """ +{% from "banks_macros.jinja" import run_prompt with context %} + +{%- call run_prompt() -%} +Write a 500-word blog post on {{ topic }} + +Blog post: +{%- endcall -%} +""" + +p = Prompt(prompt_template) +print(p.text({"topic": "climate change"})) +``` + +In this case, Banks will generate internally the prompt text + +``` +Write a 500-word blog post on climate change + +Blog post: +``` + +but instead of returning it, will send it to the LLM using the `generate` extension under the hood, eventually +returning the final response: + +``` +Climate change is a phenomenon that has been gaining attention in recent years... +... +``` diff --git a/pyproject.toml b/pyproject.toml index 8ec4b8c..ba86bc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pytest", "mkdocs-material", "mkdocstrings[python]", + "simplemma", ] [tool.hatch.envs.default.scripts] diff --git a/src/banks/config.py b/src/banks/config.py index 0f4686c..044e199 100644 --- a/src/banks/config.py +++ b/src/banks/config.py @@ -1,4 +1,6 @@ -import json +# SPDX-FileCopyrightText: 2023-present Massimiliano Pippi +# +# SPDX-License-Identifier: MIT import os from pathlib import Path from typing import Any diff --git a/src/banks/env.py b/src/banks/env.py index 77239ba..fd06c27 100644 --- a/src/banks/env.py +++ b/src/banks/env.py @@ -1,16 +1,10 @@ # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi # # SPDX-License-Identifier: MIT -import os -from pathlib import Path - -from jinja2 import Environment, select_autoescape +from jinja2 import Environment, PackageLoader, select_autoescape from .config import config from .filters import lemmatize -from .loader import MultiLoader -from .registries import FileTemplateRegistry -from .registry import TemplateRegistry def _add_extensions(env): @@ -25,15 +19,9 @@ def _add_extensions(env): env.add_extension(HFInferenceEndpointsExtension) -def _add_default_templates(r: TemplateRegistry): - templates_dir = Path(os.path.dirname(__file__)) / "templates" - for tpl_file in templates_dir.glob("*.jinja"): - r.set(name=tpl_file.name, prompt=tpl_file.read_text()) - - # Init the Jinja env env = Environment( - loader=MultiLoader(), + loader=PackageLoader("banks", "templates"), autoescape=select_autoescape( enabled_extensions=("html", "xml"), default_for_string=False, @@ -43,11 +31,7 @@ def _add_default_templates(r: TemplateRegistry): enable_async=bool(config.ASYNC_ENABLED), ) -# Init the Template registry -registry = FileTemplateRegistry(config.USER_DATA_PATH) - # Setup custom filters and defaults env.filters["lemmatize"] = lemmatize _add_extensions(env) -_add_default_templates(registry) diff --git a/src/banks/filters/lemmatize.py b/src/banks/filters/lemmatize.py index 4c196d6..707246d 100644 --- a/src/banks/filters/lemmatize.py +++ b/src/banks/filters/lemmatize.py @@ -4,7 +4,7 @@ from banks.errors import MissingDependencyError try: - from simplemma.simplemma import text_lemmatizer + from simplemma import text_lemmatizer simplemma_avail = True except ImportError: diff --git a/src/banks/prompt.py b/src/banks/prompt.py index fc1fb11..a89a556 100644 --- a/src/banks/prompt.py +++ b/src/banks/prompt.py @@ -5,7 +5,7 @@ from .cache import DefaultCache, RenderCache from .config import config -from .env import env, registry +from .env import env from .errors import AsyncError from .utils import generate_canary_word @@ -25,6 +25,7 @@ def __init__( be used. """ self._render_cache = render_cache or DefaultCache() + self._raw: str = text self._template = env.from_string(text) self.defaults = {"canary_word": canary_word or generate_canary_word()} @@ -39,53 +40,16 @@ def _get_context(self, data: Optional[dict]) -> dict: return self.defaults return data | self.defaults + @property + def raw(self) -> str: + return self._raw + def canary_leaked(self, text: str) -> bool: """ Returns whether the canary word is present in `text`, signalling the prompt might have leaked. """ return self.defaults["canary_word"] in text - @classmethod - def from_template(cls, name: str, version: str | None = None) -> "BasePrompt": - """ - Create a prompt instance from a template. - - Prompt templates can be really long and at some point you might want to store them on files. To avoid the - boilerplate code to read a file and pass the content as strings to the constructor, `Prompt`s can be - initialized by just passing the name of the template file, provided that the file is available to the - loaders that were configured (see `Multiloader`). - - One of the default loaders can load templates stored in a folder called `templates` in the current path: - - ``` - . - └── templates - └── foo.jinja - ``` - - The code would be the following: - - ```py - from banks import Prompt - - p = Prompt.from_template("foo.jinja") - prompt_text = p.text(data={"foo": "bar"}) - ``` - - !!! warning - Banks comes with its own set of default templates (see below) which takes precedence over the - ones loaded from the filesystem, so be sure to use different names for your custom - templates - - Parameters: - name: The name of the template. - - Returns: - A new `Prompt` instance. - """ - tpl = registry.get(name, version) - return cls(tpl.prompt) - class Prompt(BasePrompt): """ diff --git a/src/banks/registries/__init__.py b/src/banks/registries/__init__.py index adb4838..6a347d9 100644 --- a/src/banks/registries/__init__.py +++ b/src/banks/registries/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi # # SPDX-License-Identifier: MIT +from .directory import DirectoryTemplateRegistry from .file import FileTemplateRegistry -__all__ = ("FileTemplateRegistry",) +__all__ = ("FileTemplateRegistry", "DirectoryTemplateRegistry") diff --git a/src/banks/registries/directory.py b/src/banks/registries/directory.py new file mode 100644 index 0000000..c902dec --- /dev/null +++ b/src/banks/registries/directory.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2023-present Massimiliano Pippi +# +# SPDX-License-Identifier: MIT +from pathlib import Path + +from pydantic import BaseModel, Field + +from banks import Prompt +from banks.registry import TemplateNotFoundError + + +class PromptFile(BaseModel): + name: str + version: str + path: Path + + +class PromptFileIndex(BaseModel): + files: list[PromptFile] = Field(default=[]) + + +class DirectoryTemplateRegistry: + def __init__(self, directory_path: Path, *, force_reindex: bool = False): + if not directory_path.is_dir(): + msg = "{directory_path} must be a directory." + raise ValueError(msg) + + self._path = directory_path + self._index_path = self._path / "index.json" + if not self._index_path.exists() or force_reindex: + self._scan() + else: + self._load() + + def _load(self): + self._index = PromptFileIndex.model_validate_json(self._index_path.read_text()) + + def _scan(self): + self._index: PromptFileIndex = PromptFileIndex() + for path in self._path.glob("*.jinja*"): + pf = PromptFile(name=path.stem, version="0", path=path) + self._index.files.append(pf) + self._index_path.write_text(self._index.model_dump_json()) + + def get(self, *, name: str, version: str | None = None) -> "Prompt": + version = version or "0" + for pf in self._index.files: + if pf.name == name and pf.version == version and pf.path.exists(): + return Prompt(pf.path.read_text()) + raise TemplateNotFoundError + + def set(self, *, name: str, prompt: Prompt, version: str | None = None, overwrite: bool = False): + version = version or "0" + for pf in self._index.files: + if pf.name == name and pf.version == version and overwrite: + pf.path.write_text(prompt.raw) + return + new_prompt_file = self._path / "{name}.{version}.jinja" + new_prompt_file.write_text(prompt.raw) + pf = PromptFile(name=name, version=version, path=new_prompt_file) + self._index.files.append(pf) diff --git a/src/banks/registries/file.py b/src/banks/registries/file.py index 7fe7f07..7d7c614 100644 --- a/src/banks/registries/file.py +++ b/src/banks/registries/file.py @@ -3,7 +3,21 @@ # SPDX-License-Identifier: MIT from pathlib import Path -from banks.registry import PromptTemplate, PromptTemplateIndex, TemplateNotFoundError, InvalidTemplateError +from pydantic import BaseModel + +from banks.prompt import Prompt +from banks.registry import InvalidTemplateError, TemplateNotFoundError + + +class PromptTemplate(BaseModel): + id: str | None + name: str + version: str + prompt: str + + +class PromptTemplateIndex(BaseModel): + templates: list[PromptTemplate] class FileTemplateRegistry: @@ -19,7 +33,8 @@ def __init__(self, user_data_path: Path) -> None: @staticmethod def _make_id(name: str, version: str | None): if ":" in name: - raise InvalidTemplateError("Template name cannot contain ':'") + msg = "Template name cannot contain ':'" + raise InvalidTemplateError(msg) if version: return f"{name}:{version}" return name @@ -28,23 +43,27 @@ def save(self) -> None: with open(self._index_fpath, "w") as f: f.write(self._index.model_dump_json()) - def get(self, name: str, version: str | None = None) -> "PromptTemplate": + def get(self, name: str, version: str | None = None) -> "Prompt": tpl_id = self._make_id(name, version) + tpl = self._get_template(tpl_id) + return Prompt(tpl.prompt) + + def _get_template(self, tpl_id: str) -> "PromptTemplate": for tpl in self._index.templates: if tpl_id == tpl.id: return tpl - msg = f"cannot find template '{tpl_id}'" + msg = f"cannot find template '{id}'" raise TemplateNotFoundError(msg) - def set(self, *, name: str, prompt: str, version: str | None = None, overwrite: bool = False): + def set(self, *, name: str, prompt: Prompt, version: str | None = None, overwrite: bool = False): + tpl_id = self._make_id(name, version) try: - tpl = self.get(name, version) + tpl = self._get_template(tpl_id) if overwrite: - tpl.prompt = prompt + tpl.prompt = prompt.raw self.save() except TemplateNotFoundError: - tpl_id = self._make_id(name, version) - tpl = PromptTemplate(id=tpl_id, name=name, version=version or "", prompt=prompt) + tpl = PromptTemplate(id=tpl_id, name=name, version=version or "", prompt=prompt.raw) self._index.templates.append(tpl) self.save() diff --git a/src/banks/registry.py b/src/banks/registry.py index 19b5c0e..e330c18 100644 --- a/src/banks/registry.py +++ b/src/banks/registry.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT from typing import Protocol -from pydantic import BaseModel +from .prompt import Prompt class TemplateNotFoundError(Exception): ... @@ -12,18 +12,7 @@ class TemplateNotFoundError(Exception): ... class InvalidTemplateError(Exception): ... -class PromptTemplate(BaseModel): - id: str - name: str - version: str - prompt: str - - -class PromptTemplateIndex(BaseModel): - templates: list[PromptTemplate] - - class TemplateRegistry(Protocol): - def get(self, *, name: str, version: str | None = None) -> "PromptTemplate": ... + def get(self, *, name: str, version: str | None = None) -> "Prompt": ... - def set(self, *, name: str, prompt: str, version: str | None = None, overwrite: bool = False): ... + def set(self, *, name: str, prompt: Prompt, version: str | None = None, overwrite: bool = False): ... diff --git a/src/banks/templates/blog.jinja b/tests/templates/blog.jinja similarity index 100% rename from src/banks/templates/blog.jinja rename to tests/templates/blog.jinja diff --git a/src/banks/templates/generate_tweet.jinja b/tests/templates/generate_tweet.jinja similarity index 100% rename from src/banks/templates/generate_tweet.jinja rename to tests/templates/generate_tweet.jinja diff --git a/src/banks/templates/run_prompt.jinja b/tests/templates/run_prompt.jinja similarity index 100% rename from src/banks/templates/run_prompt.jinja rename to tests/templates/run_prompt.jinja diff --git a/src/banks/templates/run_prompt_process.jinja b/tests/templates/run_prompt_process.jinja similarity index 100% rename from src/banks/templates/run_prompt_process.jinja rename to tests/templates/run_prompt_process.jinja diff --git a/src/banks/templates/summarize.jinja b/tests/templates/summarize.jinja similarity index 100% rename from src/banks/templates/summarize.jinja rename to tests/templates/summarize.jinja diff --git a/src/banks/templates/summarize_lemma.jinja b/tests/templates/summarize_lemma.jinja similarity index 100% rename from src/banks/templates/summarize_lemma.jinja rename to tests/templates/summarize_lemma.jinja diff --git a/tests/test_config.py b/tests/test_config.py index 7ada35c..89bca0a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,16 +7,16 @@ def test_config_defaults(): c = _BanksConfig() - assert c.ASYNC_ENABLED == False + assert c.ASYNC_ENABLED is False assert c.USER_DATA_PATH == user_data_path("banks") def test_config_env_override(monkeypatch): c = _BanksConfig() monkeypatch.setenv("BANKS_ASYNC_ENABLED", "true") - assert c.ASYNC_ENABLED == True + assert c.ASYNC_ENABLED is True monkeypatch.setenv("BANKS_ASYNC_ENABLED", "false") - assert c.ASYNC_ENABLED == False + assert c.ASYNC_ENABLED is False monkeypatch.setenv("BANKS_USER_DATA_PATH", "/") assert c.USER_DATA_PATH == Path("/") @@ -32,6 +32,6 @@ class TestConfig(_BanksConfig): def test_config_env_prefix(monkeypatch): c = _BanksConfig("BANKS_TEST_") monkeypatch.setenv("BANKS_ASYNC_ENABLED", "true") - assert c.ASYNC_ENABLED == False + assert c.ASYNC_ENABLED is False monkeypatch.setenv("BANKS_TEST_ASYNC_ENABLED", "true") - assert c.ASYNC_ENABLED == True + assert c.ASYNC_ENABLED is True diff --git a/tests/test_default_templates.py b/tests/test_default_templates.py index d720a44..846dae1 100644 --- a/tests/test_default_templates.py +++ b/tests/test_default_templates.py @@ -7,7 +7,17 @@ import pytest -from banks import Prompt, env +from banks import env +from banks.registries import DirectoryTemplateRegistry + + +@pytest.fixture +def registry(tmp_path): + for fp in (Path(__file__).parent / "templates").iterdir(): + with open(tmp_path / fp.name, "w") as f: + f.write(fp.read_text()) + + return DirectoryTemplateRegistry(tmp_path) def _get_data(name): @@ -16,13 +26,13 @@ def _get_data(name): return f.read() -def test_blog(): - p = Prompt.from_template("blog.jinja") +def test_blog(registry): + p = registry.get(name="blog") assert _get_data("blog.jinja.out") == p.text({"topic": "climate change"}) -def test_summarize(): - p = Prompt.from_template("summarize.jinja") +def test_summarize(registry): + p = registry.get(name="summarize") documents = [ "A first paragraph talking about AI", "A second paragraph talking about climate change", @@ -31,15 +41,16 @@ def test_summarize(): assert _get_data("summarize.jinja.out") == p.text({"documents": documents}) -def test_summarize_lemma(): +def test_summarize_lemma(registry): pytest.importorskip("simplemma") - p = Prompt.from_template("summarize_lemma.jinja") + p = registry.get(name="summarize_lemma") assert _get_data("summarize_lemma.jinja.out") == p.text({"document": "The cats are running"}) -def test_generate_tweet(): - p = Prompt.from_template("generate_tweet.jinja") - env.extensions["banks.extensions.generate.GenerateExtension"]._generate = mock.MagicMock(return_value="foo") +def test_generate_tweet(registry): + p = registry.get(name="generate_tweet") + ext_name = "banks.extensions.generate.GenerateExtension" + env.extensions[ext_name]._generate = mock.MagicMock(return_value="foo") # type:ignore assert _get_data("generate_tweet.jinja.out") == p.text({"topic": "climate change"}) diff --git a/tests/test_directory_registry.py b/tests/test_directory_registry.py new file mode 100644 index 0000000..b92adf0 --- /dev/null +++ b/tests/test_directory_registry.py @@ -0,0 +1,67 @@ +import os +from pathlib import Path + +import pytest + +from banks.prompt import Prompt +from banks.registries.directory import DirectoryTemplateRegistry +from banks.registry import TemplateNotFoundError + + +@pytest.fixture +def populated_dir(tmp_path): + d = tmp_path / "templates" + d.mkdir() + for fp in (Path(__file__).parent / "templates").iterdir(): + with open(d / fp.name, "w") as f: + f.write(fp.read_text()) + return d + + +def test_init_from_scratch(populated_dir): + r = DirectoryTemplateRegistry(populated_dir) + p = r.get(name="blog") + assert p.raw.startswith("{# Zero-shot, this is already enough for most topics in english -#}") + + +def test_init_from_existing_index(populated_dir): + DirectoryTemplateRegistry(populated_dir) + # at this point, the index has been created + r = DirectoryTemplateRegistry(populated_dir) + assert len(r._index.files) == 6 + + +def test_init_from_existing_index_force(populated_dir): + r = DirectoryTemplateRegistry(populated_dir) # creates the index + # change the directory structure + f = populated_dir / "blog.jinja" + os.remove(f) + # force recreation, the renamed file should be updated in the index + r = DirectoryTemplateRegistry(populated_dir, force_reindex=True) + with pytest.raises(TemplateNotFoundError): + r.get(name="blog") + + +def test_init_invalid_dir(): + with pytest.raises(ValueError): + DirectoryTemplateRegistry(Path("does/not/exists")) + + +def test_get_not_found(populated_dir): + r = DirectoryTemplateRegistry(populated_dir) + with pytest.raises(TemplateNotFoundError): + r.get(name="FOO") + + +def test_set_existing_no_overwrite(populated_dir): + r = DirectoryTemplateRegistry(populated_dir) + new_prompt = Prompt("a new prompt!") + r.set(name="blog", prompt=new_prompt) # template already exists, expected to be no-op + assert r.get(name="blog").raw.startswith("{# Zero-shot, this is already enough for most topics in english -#}") + + +def test_set_existing_overwrite(populated_dir): + r = DirectoryTemplateRegistry(populated_dir) + new_prompt = Prompt("a new prompt!") + r.set(name="blog", prompt=new_prompt, overwrite=True) + assert r.get(name="blog").raw.startswith("a new prompt!") diff --git a/tests/test_env.py b/tests/test_env.py index 9f98559..9c1482c 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,9 +1,10 @@ # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi # # SPDX-License-Identifier: MIT +from jinja2 import PackageLoader + from banks import env -from banks.loader import MultiLoader def test_default_loader(): - assert type(env.loader) == MultiLoader + assert type(env.loader) == PackageLoader diff --git a/tests/test_file_registry.py b/tests/test_file_registry.py index 98617e2..02d71ae 100644 --- a/tests/test_file_registry.py +++ b/tests/test_file_registry.py @@ -1,7 +1,8 @@ import pytest -from banks.registries.file import FileTemplateRegistry -from banks.registry import PromptTemplate, PromptTemplateIndex, TemplateNotFoundError, InvalidTemplateError +from banks.prompt import Prompt +from banks.registries.file import FileTemplateRegistry, PromptTemplate, PromptTemplateIndex +from banks.registry import InvalidTemplateError, TemplateNotFoundError @pytest.fixture @@ -40,7 +41,7 @@ def test_make_id(): def test_get(populated_index_dir): r = FileTemplateRegistry(populated_index_dir) - tpl = r.get("name", "version") + tpl = r._get_template(FileTemplateRegistry._make_id("name", "version")) assert tpl.id == "name:version" @@ -53,20 +54,20 @@ def test_get_not_found(populated_index_dir): def test_set_existing_no_overwrite(populated_index_dir): r = FileTemplateRegistry(populated_index_dir) new_prompt = "a new prompt!" - r.set(name="name", prompt=new_prompt, version="version") # template already exists, expected to be no-op - assert r.get("name", "version").prompt == "prompt" + r.set(name="name", prompt=Prompt(new_prompt), version="version") # template already exists, expected to be no-op + assert r.get("name", "version").raw == "prompt" def test_set_existing_overwrite(populated_index_dir): r = FileTemplateRegistry(populated_index_dir) new_prompt = "a new prompt!" - r.set(name="name", prompt=new_prompt, version="version", overwrite=True) - assert r.get("name", "version").prompt == new_prompt + r.set(name="name", prompt=Prompt(new_prompt), version="version", overwrite=True) + assert r.get("name", "version").raw == new_prompt def test_set_new(populated_index_dir): r = FileTemplateRegistry(populated_index_dir) new_prompt = "a new prompt!" - r.set(name="name", prompt=new_prompt, version="version2") - assert r.get("name", "version").prompt == "prompt" - assert r.get("name", "version2").prompt == new_prompt + r.set(name="name", prompt=Prompt(new_prompt), version="version2") + assert r.get("name", "version").raw == "prompt" + assert r.get("name", "version2").raw == new_prompt diff --git a/tests/test_run_prompt.py b/tests/test_run_prompt.py deleted file mode 100644 index 33d0a6b..0000000 --- a/tests/test_run_prompt.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: 2023-present Massimiliano Pippi -# -# SPDX-License-Identifier: MIT -from unittest import mock - -from banks import Prompt, env - - -def test_run_prompt(): - p = Prompt.from_template("run_prompt.jinja") - env.extensions["banks.extensions.generate.GenerateExtension"]._generate = mock.MagicMock(return_value="foo") - - assert p.text({"topic": "climate change"}) == "\n\nfoo\n" - - -def test_run_prompt_process(): - p = Prompt.from_template("run_prompt_process.jinja") - env.extensions["banks.extensions.generate.GenerateExtension"]._generate = mock.MagicMock(return_value="foo bar baz") - - assert p.text({"topic": "climate change"}) == "FOO BAR BAZ"