Skip to content
Open
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
7 changes: 7 additions & 0 deletions Cargo.lock

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

7 changes: 7 additions & 0 deletions examples/pycompat-rendering/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "pycompat-rendering"
version = "0.1.0"
edition = "2021"

[dependencies]
minijinja = { path = "../../minijinja" }
37 changes: 37 additions & 0 deletions examples/pycompat-rendering/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Python-Compatible Rendering Example

This example demonstrates MiniJinja's Python-compatible rendering mode.

## Background

By default, MiniJinja renders values in its own style:
- Booleans: `true`/`false`
- None: `none`
- Strings: Rust-style escaping and quoting

When Python compatibility is needed (e.g., for SQL templating where precise output matters), you can enable PyCompat mode to match Python Jinja2's output:
- Booleans: `True`/`False`
- None: `None`
- Strings: Python-style escaping and quoting

## Usage

```rust
use minijinja::Environment;

let mut env = Environment::new();

// Enable Python-compatible rendering
env.set_pycompat_rendering(true);

// Now templates will render values like Python Jinja2
let tmpl = env.template_from_str("{{ [true, false, none, 'hello'] }}")?;
let result = tmpl.render(minijinja::context!{})?;
// Output: [True, False, None, 'hello']
```

## Running this Example

```bash
cargo run --example pycompat-rendering
```
72 changes: 72 additions & 0 deletions examples/pycompat-rendering/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use minijinja::{context, Environment};
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really don't need this example here.


fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== MiniJinja Rendering Comparison ===");

// Create environments for both modes to avoid borrowing issues
let mut env_default = Environment::new();
let mut env_pycompat = Environment::new();

// Add the same template to both environments
let template_str = r#"{{ [true, false, none, 'foo', "bar'baz", '\x13'] }}"#;
env_default.add_template("demo", template_str)?;
env_pycompat.add_template("demo", template_str)?;

// Configure rendering modes
env_default.set_pycompat_rendering(false);
env_pycompat.set_pycompat_rendering(true);

// Test rendering
let result_default = env_default.get_template("demo")?.render(context! {})?;
let result_pycompat = env_pycompat.get_template("demo")?.render(context! {})?;

println!("Default rendering: {}", result_default);
println!("PyCompat rendering: {}", result_pycompat);

println!("\n=== Individual Value Comparison ===");

// Test individual values
env_default.add_template("bool_true", "{{ true }}")?;
env_default.add_template("bool_false", "{{ false }}")?;
env_default.add_template("none_val", "{{ none }}")?;

env_pycompat.add_template("bool_true", "{{ true }}")?;
env_pycompat.add_template("bool_false", "{{ false }}")?;
env_pycompat.add_template("none_val", "{{ none }}")?;

println!("Default mode:");
println!(
" true -> {}",
env_default.get_template("bool_true")?.render(context! {})?
);
println!(
" false -> {}",
env_default
.get_template("bool_false")?
.render(context! {})?
);
println!(
" none -> {}",
env_default.get_template("none_val")?.render(context! {})?
);

println!("PyCompat mode:");
println!(
" true -> {}",
env_pycompat
.get_template("bool_true")?
.render(context! {})?
);
println!(
" false -> {}",
env_pycompat
.get_template("bool_false")?
.render(context! {})?
);
println!(
" none -> {}",
env_pycompat.get_template("none_val")?.render(context! {})?
);

Ok(())
}
21 changes: 21 additions & 0 deletions minijinja/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub struct Environment<'source> {
#[cfg(feature = "fuel")]
fuel: Option<u64>,
recursion_limit: usize,
pycompat_rendering: bool,
}

impl Default for Environment<'_> {
Expand Down Expand Up @@ -111,6 +112,7 @@ impl<'source> Environment<'source> {
#[cfg(feature = "fuel")]
fuel: None,
recursion_limit: MAX_RECURSION,
pycompat_rendering: false,
}
}

Expand All @@ -133,6 +135,7 @@ impl<'source> Environment<'source> {
#[cfg(feature = "fuel")]
fuel: None,
recursion_limit: MAX_RECURSION,
pycompat_rendering: false,
}
}

Expand Down Expand Up @@ -576,6 +579,24 @@ impl<'source> Environment<'source> {
self.debug
}

/// Enables or disables Python-compatible rendering mode.
///
/// When enabled, certain value types will be rendered to match Python Jinja2's output:
/// - `true`/`false` will be rendered as `True`/`False`
/// - `none` will be rendered as `None`
/// - String escaping and quoting will match Python's style
///
/// This is useful when you need template output that's compatible with Python Jinja2.
/// By default this is disabled.
pub fn set_pycompat_rendering(&mut self, enabled: bool) {
self.pycompat_rendering = enabled;
}

/// Returns whether Python-compatible rendering mode is enabled.
pub fn pycompat_rendering(&self) -> bool {
self.pycompat_rendering
}

/// Sets the optional fuel of the engine.
///
/// When MiniJinja is compiled with the `fuel` feature then every
Expand Down
104 changes: 98 additions & 6 deletions minijinja/src/value/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ pub(crate) fn value_map_with_capacity(capacity: usize) -> ValueMap {

thread_local! {
static INTERNAL_SERIALIZATION: Cell<bool> = const { Cell::new(false) };
static PYCOMPAT_RENDERING: Cell<bool> = const { Cell::new(false) };

// This should be an AtomicU64 but sadly 32bit targets do not necessarily have
// AtomicU64 available.
Expand Down Expand Up @@ -286,6 +287,14 @@ pub fn serializing_for_value() -> bool {
INTERNAL_SERIALIZATION.with(|flag| flag.get())
}

/// Function that returns true when Python-compatible rendering is enabled.
///
/// When this returns `true`, values should be rendered in a way that's compatible
/// with Python Jinja2, such as `True`/`False` instead of `true`/`false`.
pub(crate) fn pycompat_rendering() -> bool {
PYCOMPAT_RENDERING.with(|flag| flag.get())
}

fn mark_internal_serialization() -> impl Drop {
let old = INTERNAL_SERIALIZATION.with(|flag| {
let old = flag.get();
Expand All @@ -299,6 +308,17 @@ fn mark_internal_serialization() -> impl Drop {
})
}

pub(crate) fn mark_pycompat_rendering(enabled: bool) -> impl Drop {
let old = PYCOMPAT_RENDERING.with(|flag| {
let old = flag.get();
flag.set(enabled);
old
});
OnDrop::new(move || {
PYCOMPAT_RENDERING.with(|flag| flag.set(old));
})
}

/// Describes the kind of value.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[non_exhaustive]
Expand Down Expand Up @@ -434,20 +454,80 @@ pub(crate) enum ValueRepr {
Object(DynObject),
}

fn python_string_debug_fmt(s: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Python prefers single quotes unless the string contains single quotes
let contains_single = s.contains('\'');
let contains_double = s.contains('"');

let quote_char = if contains_single && !contains_double {
'"'
} else {
'\''
};

write!(f, "{}", quote_char)?;
for ch in s.chars() {
match ch {
'\'' if quote_char == '\'' => write!(f, "\\'")?,
'"' if quote_char == '"' => write!(f, "\\\"")?,
'\\' => write!(f, "\\\\")?,
'\n' => write!(f, "\\n")?,
'\r' => write!(f, "\\r")?,
'\t' => write!(f, "\\t")?,
c if c.is_control() => {
let code = c as u32;
if code <= 0xFF {
write!(f, "\\x{:02x}", code)?;
} else if code <= 0xFFFF {
write!(f, "\\u{:04x}", code)?;
} else {
write!(f, "\\U{:08x}", code)?;
}
}
c => write!(f, "{}", c)?,
}
}
write!(f, "{}", quote_char)
}

impl fmt::Debug for ValueRepr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
ValueRepr::Undefined(_) => f.write_str("undefined"),
ValueRepr::Bool(ref val) => fmt::Debug::fmt(val, f),
ValueRepr::Bool(ref val) => {
if pycompat_rendering() {
write!(f, "{}", if *val { "True" } else { "False" })
} else {
fmt::Debug::fmt(val, f)
}
}
ValueRepr::U64(ref val) => fmt::Debug::fmt(val, f),
ValueRepr::I64(ref val) => fmt::Debug::fmt(val, f),
ValueRepr::F64(ref val) => fmt::Debug::fmt(val, f),
ValueRepr::None => f.write_str("none"),
ValueRepr::None => {
if pycompat_rendering() {
f.write_str("None")
} else {
f.write_str("none")
}
}
ValueRepr::Invalid(ref val) => write!(f, "<invalid value: {}>", val),
ValueRepr::U128(val) => fmt::Debug::fmt(&{ val.0 }, f),
ValueRepr::I128(val) => fmt::Debug::fmt(&{ val.0 }, f),
ValueRepr::String(ref val, _) => fmt::Debug::fmt(val, f),
ValueRepr::SmallStr(ref val) => fmt::Debug::fmt(val.as_str(), f),
ValueRepr::String(ref val, _) => {
if pycompat_rendering() {
python_string_debug_fmt(val, f)
} else {
fmt::Debug::fmt(val, f)
}
}
ValueRepr::SmallStr(ref val) => {
if pycompat_rendering() {
python_string_debug_fmt(val.as_str(), f)
} else {
fmt::Debug::fmt(val.as_str(), f)
}
}
ValueRepr::Bytes(ref val) => {
write!(f, "b'")?;
for &b in val.iter() {
Expand Down Expand Up @@ -661,7 +741,13 @@ impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
ValueRepr::Undefined(_) => Ok(()),
ValueRepr::Bool(val) => val.fmt(f),
ValueRepr::Bool(val) => {
if pycompat_rendering() {
write!(f, "{}", if val { "True" } else { "False" })
} else {
val.fmt(f)
}
}
ValueRepr::U64(val) => val.fmt(f),
ValueRepr::I64(val) => val.fmt(f),
ValueRepr::F64(val) => {
Expand All @@ -677,7 +763,13 @@ impl fmt::Display for Value {
write!(f, "{num}")
}
}
ValueRepr::None => f.write_str("none"),
ValueRepr::None => {
if pycompat_rendering() {
f.write_str("None")
} else {
f.write_str("none")
}
}
ValueRepr::Invalid(ref val) => write!(f, "<invalid value: {}>", val),
ValueRepr::I128(val) => write!(f, "{}", { val.0 }),
ValueRepr::String(ref val, _) => write!(f, "{val}"),
Expand Down
1 change: 1 addition & 0 deletions minijinja/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ impl<'env> Vm<'env> {
out: &mut Output,
auto_escape: AutoEscape,
) -> Result<(Option<Value>, State<'template, 'env>), Error> {
let _pycompat_guard = crate::value::mark_pycompat_rendering(self.env.pycompat_rendering());
let mut state = State::new(
Context::new_with_frame(self.env, ok!(Frame::new_checked(root))),
auto_escape,
Expand Down
Loading
Loading