diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca6132..b220ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ -# [0.2.1] - 31.03.2026 +# [0.2.2] - 2026-04-20 +* Default global client — `Template` now uses a shared process-wide `PyHandlebars` instance when no `client=` is passed, so partials and helpers are shared across templates without explicit wiring. +* `PyHandlebars.helper` decorator at the class level registers helpers on the global client (no instance needed). + +# [0.2.1] - 2026-03-31 * Remove benchmarks from repo and move to [sukram42/python-templating-benchmarks](https://github.com/sukram42/python-templating-benchmarks) -# [0.2.0] - 31.03.2026 +# [0.2.0] - 2026-03-31 * Decorator for helper functions. -# [0.1.1] - 09.03.2026 +# [0.1.1] - 2026-03-09 * Adding meta information to the package. -# [0.1.0] - 09.03.2026 +# [0.1.0] - 2026-03-09 * Initial setup. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0baf191..d8e072e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ dependencies = [ [[package]] name = "pyhandlebars" -version = "0.2.0" +version = "0.2.2" dependencies = [ "handlebars", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index e9a644f..d960c82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyhandlebars" -version = "0.2.0" +version = "0.2.2" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 955dd99..57ddb38 100644 --- a/README.md +++ b/README.md @@ -38,25 +38,68 @@ def test_simple_pydantic_support(): assert rendered_text == "This is Alice!" ``` -### 3. Adding Helper Functions +### 3. Default Global Client + +Every `Template` created without an explicit `client=` argument shares a single process-wide global client. This means partials and helpers registered on one template are immediately available to all other templates that use the default client. + +```python +from pyhandlebars import Template + +# Both templates share the global client — the partial is visible to t2. +_ = Template("Hi {{name}}", name="greeting") +t = Template("{{> greeting}}") +print(t.format({"name": "Bob"})) # Hi Bob +``` + +### 4. Adding Helper Functions PyHandlebars allows you to register your own helper functions. However, keep in mind that these are not as fast and -performant as built-in helpers. +performant as built-in helpers. + +Each helper receives two arguments: `params` (a list of positional arguments from the template call) and `context` (the full data object passed to `format`). + +**Option A — `PyHandlebars.helper` on the global client** + +Access `helper` on the *class* (not an instance) to register directly on the global client — no explicit `PyHandlebars()` needed. ```python - def test_custom_helper(): - from pyhandlebars import PyHandlebars, Template +from pyhandlebars import PyHandlebars, Template - # 1. Define the helper function. - def shout(params: list[str], context: dict): - return f"{params[0].upper()} from {context['location']}" +@PyHandlebars.helper +def shout(params: list, context: dict): + return f"{params[0].upper()} from {context['location']}" - client = PyHandlebars() - client.register_helper("shout", shout) +# Supply a custom template name with name= +@PyHandlebars.helper(name="whisper") +def my_whisper_fn(params: list, context: dict): + return params[0].lower() - t: Template[dict] = Template("{{shout name}}", client=client) - assert t.format({"name": "Alice", "location": "Wonderland"}) == "ALICE from Wonderland" +t = Template("{{shout name}} / {{whisper name}}") +assert t.format({"name": "Alice", "location": "Wonderland"}) == "ALICE from Wonderland / alice" ``` -### 4. More Examples + +**Option B — `register_helper` / `@client.helper()` on a dedicated client** + +Use an explicit `PyHandlebars()` instance when you want helpers isolated from the global client. + +```python +from pyhandlebars import PyHandlebars, Template + +def shout(params: list, context: dict): + return f"{params[0].upper()} from {context['location']}" + +client = PyHandlebars() +client.register_helper("shout", shout) + +# Decorator style: +@client.helper() +def whisper(params: list, context: dict): + return params[0].lower() + +t = Template("{{shout name}} / {{whisper name}}", client=client) +assert t.format({"name": "Alice", "location": "Wonderland"}) == "ALICE from Wonderland / alice" +``` + +### 5. More Examples More examples can be found in the tests/test_examples.py file. ## Supported Template Functions diff --git a/pyhandlebars.pyi b/pyhandlebars.pyi index d04fcea..7538cd9 100644 --- a/pyhandlebars.pyi +++ b/pyhandlebars.pyi @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Generic, Protocol, TypeAlias, TypeVar +from typing import Any, Callable, ClassVar, Generic, Protocol, TypeAlias, TypeVar, overload class PyHandlebarsError(Exception): ... class TemplateParseError(PyHandlebarsError): ... @@ -15,10 +15,18 @@ F = TypeVar("F", bound=Callable[[HelperParameter, Context], str]) HelperParameter: TypeAlias = list[str] Context: TypeAlias = dict +class _HelperDescriptor: + @overload + def __call__(self, func: F) -> F: ... + @overload + def __call__(self, name: str | None = None) -> Callable[[F], F]: ... + @overload + def __call__(self, *args: Any, **kwargs: Any) -> Any: ... + class PyHandlebars: def __init__(self, strict_mode: bool = False, dev_mode: bool = False) -> None: ... def register_helper(self, name: str, func: Callable[[HelperParameter, Context], str]) -> None: ... - def helper(self, name: str | None = None) -> Callable[[F], F]: ... + helper: ClassVar[_HelperDescriptor] class Template(Generic[T]): def __init__(self, template: str, name: str | None = None, client: PyHandlebars | None = None) -> None: ... diff --git a/src/lib.rs b/src/lib.rs index 7f37748..f97c8ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,6 @@ +use std::sync::OnceLock; + +use pyo3::pyclass; use pyo3::types::PyAnyMethods; use pyo3::{prelude::*, types::PyCFunction}; @@ -67,6 +70,87 @@ impl HelperDef for PyHelper { } } +fn _register( + py: Python<'_>, + target: Py, + name: Option, + func: Bound<'_, PyAny>, +) -> Result, PyErr> { + let func_clone = func.clone(); + let resolved_name = match name { + Some(n) => n, + None => func_clone.getattr("__name__")?.extract::()?, + }; + + target + .borrow_mut(py) + .register_helper(&resolved_name, func.unbind()); + Ok(func_clone.unbind()) +} + +#[pyclass] +struct HelperMethod; + +#[pymethods] +impl HelperMethod { + fn __get__<'py>( + &self, + py: Python<'py>, + obj: Option>, + _cls: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + let target: Py = match obj { + Some(instance) => instance, + None => global_client(py)?, + }; + + PyCFunction::new_closure(py, None, None, move |args: &Bound<'_, pyo3::types::PyTuple>, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>| { + let raw_func = args.get_item(0).ok(); + let name: Option = match kwargs { + Some(kw) => kw + .get_item("name")? + .map(|v: Bound<'_, PyAny>| v.extract::()) + .transpose()?, + None => None, + }; + + let target_none = target.clone_ref(args.py()); + + match raw_func { + Some(f) => _register(args.py(), target.clone_ref(args.py()), name, f), + None => { + let new_f = + PyCFunction::new_closure(args.py(), None, None, move |args: &Bound<'_, pyo3::types::PyTuple>, _kwargs: Option<&Bound<'_, pyo3::types::PyDict>>| { + let raw_func = args.get_item(0)?; + _register( + args.py(), + target_none.clone_ref(args.py()), + name.clone(), + raw_func, + ) + })?; + Ok(new_f.into_any().unbind()) + } + } + }) + } +} + +static GLOBAL_CLIENT: OnceLock> = OnceLock::new(); + +fn global_client(py: Python<'_>) -> PyResult> { + let client = GLOBAL_CLIENT.get_or_init(|| { + Py::new( + py, + PyHandlebars { + client: Handlebars::new(), + }, + ) + .expect("failed to create global client") + }); + Ok(client.clone_ref(py)) +} + #[pyclass] struct PyHandlebars { client: Handlebars<'static>, @@ -87,27 +171,32 @@ impl PyHandlebars { self.client.register_helper(name, Box::new(PyHelper(func))); } - #[pyo3(signature=(*, name=None))] - fn helper( - slf: Py, - py: Python<'_>, - name: Option, - ) -> PyResult> { - PyCFunction::new_closure(py, None, None, move |args, _kwargs| { - let func = args.get_item(0)?; - let resolved_name = match &name { - Some(n)=>n.clone(), - None =>func.getattr("__name__")?.extract::()? - }; - - let py = args.py(); - let func_clone = func.clone(); - slf.borrow_mut(py) - .client - .register_helper(&resolved_name, Box::new(PyHelper(func_clone.unbind()))); - Ok::<_, PyErr>(func.unbind()) - }) + #[classattr] + fn helper(py: Python<'_>) -> PyResult> { + Py::new(py, HelperMethod) } + + // #[pyo3(signature=(*, name=None))] + // fn helper( + // slf: Py, + // py: Python<'_>, + // name: Option, + // ) -> PyResult> { + // PyCFunction::new_closure(py, None, None, move |args, _kwargs| { + // let func = args.get_item(0)?; + // let resolved_name = match &name { + // Some(n) => n.clone(), + // None => func.getattr("__name__")?.extract::()?, + // }; + + // let py = args.py(); + // let func_clone = func.clone(); + // slf.borrow_mut(py) + // .client + // .register_helper(&resolved_name, Box::new(PyHelper(func_clone.unbind()))); + // Ok::<_, PyErr>(func.unbind()) + // }) + // } } fn render_error_to_py(e: RenderError) -> PyErr { @@ -144,12 +233,7 @@ impl Template { ) -> PyResult { let client = match client { Some(c) => c, - None => Py::new( - py, - PyHandlebars { - client: Handlebars::new(), - }, - )?, + None => global_client(py)?, }; let key = make_template_name(name); client @@ -173,12 +257,7 @@ impl Template { let path_str: String = path.str()?.extract()?; let client = match client { Some(c) => c, - None => Py::new( - py, - PyHandlebars { - client: Handlebars::new(), - }, - )?, + None => global_client(py)?, }; client .borrow_mut(py) diff --git a/tests/test_examples.py b/tests/test_examples.py index 4fc9285..b780902 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -169,3 +169,39 @@ def shout(params: list[str], context: dict): t: Template[dict] = Template("{{shout name}}", client=client) assert t.format({"name": "Alice", "location": "Wonderland"}) == "ALICE from Wonderland" + + +def test_global_client_helper_decorator(): + # PyHandlebars.helper used at the class level registers on the shared global client, + # so no explicit PyHandlebars() instance is needed. + from pyhandlebars import PyHandlebars, Template + + @PyHandlebars.helper + def exclaim(params: list[str], context: dict): + return f"{params[0]}!" + + t: Template[dict] = Template("{{exclaim greeting}}") + assert t.format({"greeting": "Hello"}) == "Hello!" + + +def test_global_client_helper_decorator_with_name(): + # The name= kwarg overrides the function name used in templates. + from pyhandlebars import PyHandlebars, Template + + @PyHandlebars.helper(name="loud") + def make_loud(params: list[str], context: dict): + return params[0].upper() + + t: Template[dict] = Template("{{loud word}}") + assert t.format({"word": "hello"}) == "HELLO" + + +def test_global_client_shared_partials(): + # Templates registered without an explicit client share the same global client, + # so partials work across Template instances created independently. + from pyhandlebars import Template + + _ = Template("Hi {{name}}", name="greeting") + t: Template[dict] = Template("{{> greeting}}") + + assert t.format({"name": "Bob"}) == "Hi Bob" diff --git a/tests/test_pyhandlebars.py b/tests/test_pyhandlebars.py index 678dfbf..776b520 100644 --- a/tests/test_pyhandlebars.py +++ b/tests/test_pyhandlebars.py @@ -92,6 +92,37 @@ def shouty(params, data): t2: Template[dict] = Template("{{shouty name}}", client=client) assert t2.format({"name": "hello"}) == "HELLO?" + @client.helper + def shout3(params, data): + return params[0].upper() + "?" + + t3: Template[dict] = Template("{{shout3 name}}", client=client) + assert t3.format({"name": "hello"}) == "HELLO?" + + +def test_helper_without_client(): + + @PyHandlebars.helper + def shout(params, data): + return params[0].upper() + "!" + + t1: Template[dict] = Template("{{shout name}}") + assert t1.format({"name": "hello"}) == "HELLO!" + + @PyHandlebars.helper() + def shout1(params, data): + return params[0].upper() + "!" + + t2: Template[dict] = Template("{{shout1 name}}") + assert t2.format({"name": "hello"}) == "HELLO!" + + @PyHandlebars.helper(name="aliasshout") + def shout2(params, data): + return params[0].upper() + "!" + + t3: Template[dict] = Template("{{aliasshout name}}") + assert t3.format({"name": "hello"}) == "HELLO!" + def test_benchmark_format_dict(): n = 1_000