Metadata-Version: 2.5
Name: greyhorse-renders
Version: 0.5.5
Summary: Greyhorse Renders library
Project-URL: Homepage, https://gitlab.com/max-plutonium/greyhorse
Project-URL: Repository, https://gitlab.com/max-plutonium/greyhorse
Author-email: Max Plutonium <plutonium.max@gmail.com>
Maintainer-email: Max Plutonium <plutonium.max@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: greyhorse,jinja,rendering,templates
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Text Processing :: Markup
Classifier: Typing :: Typed
Requires-Python: >=3.14
Requires-Dist: greyhorse~=0.5.5
Requires-Dist: pydantic>=2.13
Requires-Dist: pyyaml~=6.0
Requires-Dist: tomlkit>=0.15
Provides-Extra: jinja
Requires-Dist: jinja2~=3.1.6; extra == 'jinja'
Description-Content-Type: text/markdown

Greyhorse renders library
==========================

Rendering and configuration-parsing support: a small `Render` engine
abstraction (verbatim copy, or Jinja2 with the `jinja` extra), a
render-backed YAML/TOML config loader, and a `greyhorse.strand`
`Module`/`Fragment` pair for wiring both into an application.

Two engines, one interface
---------------------------

* the **simple** engine (key `''`) copies a template file verbatim. No
  third-party dependency -- it is always present, even without the `jinja`
  extra;
* the **jinja** engine (key `'jinja'`) renders with Jinja2: expressions,
  loops, the `b64encode`/`b64decode`/`toYaml`/`toJson` filters and a
  `readBinary()` global. Only present when the `jinja` extra is installed --
  an unknown or unavailable key falls back to the simple engine, so calling
  code never has to branch on what happens to be installed. Runs inside a
  `jinja2.sandbox.ImmutableSandboxedEnvironment`: templates can READ values
  from their context but not mutate them in place (`{{ some_list.append(1)
  }}` is refused, same as an unsafe attribute access), and autoescape keys
  off the template NAME's final suffix only (`.html`/`.htm`/`.xml`) -- a
  template producing HTML must be named accordingly to get it.

Both come in a sync and an async flavour, handed out by
`SyncRenderFactoryImpl`/`AsyncRenderFactoryImpl` for an engine key and a list
of template search directories. A render call returns a `Result` -- `Ok(text)`
or an error case that names the file -- never an exception for a missing
template, an unreadable one, or one that is not valid UTF-8. For the Jinja
engine this extends to render-TIME failures too: an undefined variable, the
sandbox refusing an unsafe attribute access, or an ordinary Python exception
raised from inside an expression (`{{ 1 / 0 }}`) all come back as `Err(...)`
rather than propagating.

Installation
------------

```bash
uv add greyhorse-renders
```

Add the `jinja` extra for the Jinja2 engine:

```bash
uv add "greyhorse-renders[jinja]"
```

Without the extra the package still imports and still renders through the
simple engine; it simply reports one fewer engine in
`SyncRenderFactoryImpl().keys`.

Usage
-----

Every snippet below matches the real API (checked against the test suite as
it was written). Full, runnable programs live in [`examples/`](examples/)
and are executed by the test suite, so they cannot rot silently.

### Direct rendering

```python
from pathlib import Path

from greyhorse_renders.factory import DEFAULT_ENGINE, SyncRenderFactoryImpl

TEMPLATES = Path(__file__).parent / 'templates'

factory = SyncRenderFactoryImpl()
render = factory(DEFAULT_ENGINE, [TEMPLATES])

result = render('greeting.txt')
print(result.unwrap().strip())
```

`render` never reads outside `TEMPLATES`, even given a `..`-laden or
absolute template name -- every candidate is confined to the search path it
was found in. Pass `'jinja'` instead of `DEFAULT_ENGINE` for the Jinja2
engine (falls back to the simple one if the `jinja` extra is not installed).

### Loading config files that are also templates

`conf.loader` renders a file THROUGH an engine before parsing it, so a YAML
or TOML config file may carry template expressions -- but only if the loader
is told which engine to render with: the engine key defaults to `''` (the
verbatim/simple engine, which copies its input through unchanged), so
rendering `{{ ... }}` expressions inside a config file needs `'jinja'`
passed explicitly, either as `default_render_key` at construction or as
`render_key` on the individual `load_yaml`/`load_toml` call:

```python
from pathlib import Path

from pydantic import BaseModel

from greyhorse_renders.conf.loader import SyncPydanticLoader
from greyhorse_renders.factory import SyncRenderFactoryImpl


class Route(BaseModel):
    module: str
    method: str


loader = SyncPydanticLoader(
    doc_schema=Route,
    root_dir=Path('config'),
    render_factory=SyncRenderFactoryImpl(),
    default_render_key='jinja',
)
route = loader.load_yaml(Path('route.yml')).unwrap()
```

`SyncDictLoader`/`AsyncDictLoader` hand back plain `dict`s instead of a
pydantic model; all four accept `load_yaml`, `load_yaml_list` (a multi-
document stream) and `load_toml`. Malformed input -- an empty document, a
top-level list where a mapping was expected -- comes back as `Err(...)`,
never an uncaught exception.

Two things about the loaders that are easy to assume wrong:

* a value fixed at construction (`values={...}` on the loader) OVERRIDES the
  same key passed as a `**kwargs` on the individual `load_yaml`/`load_toml`
  call, not the other way around -- `deep_update(dict(kwargs),
  dict(self._values))` layers the constructor values on top;
* `root_dir` is a second template search directory, not a confinement
  boundary -- a `conf_path` argument pointing outside `root_dir` still loads
  fine, because the render engine searches `[conf_path.parent, root_dir]`.
  This is unlike `_ConfinedFileSystemLoader`'s own guarantee elsewhere in
  this package (see `private/jinja.py`), which really does refuse to resolve
  outside its search paths -- `root_dir`'s name invites the same assumption
  but does not enforce it.

### Wiring into an application

`RendersModule` is a ready-made floor owning both render factories; a
consumer takes one as a constructor parameter and knows nothing about this
library:

```python
from pathlib import Path
from typing import ClassVar

from greyhorse.strand import Resource, running

from greyhorse_renders.abc import SyncRenderFactory
from greyhorse_renders.module import RendersModule

TEMPLATES = Path(__file__).parent / 'templates'


class Greeter:
    def __init__(self, render_factory: SyncRenderFactory) -> None:
        render = render_factory('', [TEMPLATES])
        print(render('greeting.txt').unwrap().strip())


class App(RendersModule):
    name = 'my-app'
    resources: ClassVar = (Resource(SyncRenderFactory), Resource(Greeter))


with running(App):
    pass
```

An application that already has its own `Module` lists `RendersFragment`
and `Resource(SyncRenderFactory)`/`Resource(AsyncRenderFactory)` there
directly instead of subclassing `RendersModule` -- see
`examples/03_module.py`.

Runnable examples live in [`examples/`](examples/) and are executed by the
test suite, so they cannot rot:

```bash
uv run python examples/01_render_a_template.py     # direct rendering
uv run python examples/02_optional_engine.py        # simple vs. jinja fallback
uv run python examples/03_module.py                 # strand Module/Fragment integration
```

Development
-----------

```bash
uv sync
uv run pytest tests -q
uv run mypy greyhorse_renders
```

Linting and formatting run from the REPOSITORY ROOT, where the shared ruff
configuration lives:

```bash
ruff check exec/renders
ruff format exec/renders
```
