Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
67 changes: 55 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions pyhandlebars.pyi
Original file line number Diff line number Diff line change
@@ -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): ...
Expand All @@ -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: ...
Expand Down
143 changes: 111 additions & 32 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use std::sync::OnceLock;

use pyo3::pyclass;
use pyo3::types::PyAnyMethods;
use pyo3::{prelude::*, types::PyCFunction};

Expand Down Expand Up @@ -67,6 +70,87 @@ impl HelperDef for PyHelper {
}
}

fn _register(
py: Python<'_>,
target: Py<PyHandlebars>,
name: Option<String>,
func: Bound<'_, PyAny>,
) -> Result<Py<PyAny>, PyErr> {
let func_clone = func.clone();
let resolved_name = match name {
Some(n) => n,
None => func_clone.getattr("__name__")?.extract::<String>()?,
};

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<Py<PyHandlebars>>,
_cls: Option<&Bound<'_, PyAny>>,
) -> PyResult<Bound<'py, PyCFunction>> {
let target: Py<PyHandlebars> = 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<String> = match kwargs {
Some(kw) => kw
.get_item("name")?
.map(|v: Bound<'_, PyAny>| v.extract::<String>())
.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<Py<PyHandlebars>> = OnceLock::new();

fn global_client(py: Python<'_>) -> PyResult<Py<PyHandlebars>> {
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>,
Expand All @@ -87,27 +171,32 @@ impl PyHandlebars {
self.client.register_helper(name, Box::new(PyHelper(func)));
}

#[pyo3(signature=(*, name=None))]
fn helper(
slf: Py<PyHandlebars>,
py: Python<'_>,
name: Option<String>,
) -> PyResult<Bound<'_, PyCFunction>> {
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::<String>()?
};

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<HelperMethod>> {
Py::new(py, HelperMethod)
}

// #[pyo3(signature=(*, name=None))]
// fn helper(
// slf: Py<PyHandlebars>,
// py: Python<'_>,
// name: Option<String>,
// ) -> PyResult<Bound<'_, PyCFunction>> {
// 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::<String>()?,
// };

// 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 {
Expand Down Expand Up @@ -144,12 +233,7 @@ impl Template {
) -> PyResult<Self> {
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
Expand All @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
31 changes: 31 additions & 0 deletions tests/test_pyhandlebars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading