Skip to content

Feat/yesno filter #78

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions src/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub enum FilterType {
Lower(LowerFilter),
Safe(SafeFilter),
Slugify(SlugifyFilter),
Yesno(YesnoFilter),
}

#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -81,3 +82,8 @@ pub struct SafeFilter;

#[derive(Clone, Debug, PartialEq)]
pub struct SlugifyFilter;

#[derive(Clone, Debug, PartialEq)]
pub struct YesnoFilter {
pub argument: Option<Argument>,
}
2 changes: 2 additions & 0 deletions src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::filters::FilterType;
use crate::filters::LowerFilter;
use crate::filters::SafeFilter;
use crate::filters::SlugifyFilter;
use crate::filters::YesnoFilter;
use crate::lex::START_TAG_LEN;
use crate::lex::autoescape::{AutoescapeEnabled, AutoescapeError, lex_autoescape_argument};
use crate::lex::core::{Lexer, TokenType};
Expand Down Expand Up @@ -118,6 +119,7 @@ impl Filter {
Some(right) => return Err(unexpected_argument("slugify", right)),
None => FilterType::Slugify(SlugifyFilter),
},
"yesno" => FilterType::Yesno(YesnoFilter { argument: right }),
external => {
let external = match parser.external_filters.get(external) {
Some(external) => external.clone().unbind(),
Expand Down
148 changes: 147 additions & 1 deletion src/render/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ use pyo3::types::PyType;

use crate::filters::{
AddFilter, AddSlashesFilter, CapfirstFilter, DefaultFilter, EscapeFilter, ExternalFilter,
FilterType, LowerFilter, SafeFilter, SlugifyFilter,
FilterType, LowerFilter, SafeFilter, SlugifyFilter, YesnoFilter,
};
use crate::parse::Filter;
use crate::render::types::{Content, Context};
use crate::render::{Resolve, ResolveResult};
use crate::types::TemplateString;
use regex::Regex;
use unicode_normalization::UnicodeNormalization;
use crate::error::RenderError;
use crate::render::PyRenderError;

// Used for replacing all non-word and non-spaces with an empty string
static NON_WORD_RE: LazyLock<Regex> =
Expand Down Expand Up @@ -71,6 +73,7 @@ impl Resolve for Filter {
FilterType::Lower(filter) => filter.resolve(left, py, template, context),
FilterType::Safe(filter) => filter.resolve(left, py, template, context),
FilterType::Slugify(filter) => filter.resolve(left, py, template, context),
FilterType::Yesno(filter) => filter.resolve(left, py, template, context),
};
result
}
Expand Down Expand Up @@ -319,6 +322,75 @@ impl ResolveFilter for SlugifyFilter {
}
}

impl ResolveFilter for YesnoFilter {
fn resolve<'t, 'py>(
&self,
variable: Option<Content<'t, 'py>>,
py: Python<'py>,
template: TemplateString<'t>,
context: &mut Context,
) -> ResolveResult<'t, 'py> {
let left = match variable {
Some(var) => var,
None => return Err(PyRenderError::RenderError(RenderError::VariableDoesNotExist {
key: "yesno".to_string(),
object: "filter".to_string(),
key_at: (0, 0).into(),
object_at: None,
})),
Comment on lines +335 to +340
Copy link
Owner

Choose a reason for hiding this comment

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

Can we test this code path?

You can find an example of the sort of way I'd test errors here:
https://github.com/LilyFoote/django-rusty-templates/blob/a7e3e9fc524ea65a5fc9c91eeb52ce7f6c815f07/tests/filters/test_add.py#L25-L45

};

// Handle the case with a custom mapping argument
if let Some(arg) = &self.argument {
// Get mapping string from the argument
let arg_content = arg.resolve(py, template, context)?
.expect("missing argument in context should already have raised");
let mapping = match arg_content.to_py(py)?.extract::<String>() {
Ok(s) => s,
Err(_) => {
return Err(PyRenderError::RenderError(RenderError::VariableDoesNotExist {
key: "yesno argument".to_string(),
object: "string".to_string(),
key_at: (0, 0).into(),
object_at: None,
}));
}
};
Comment on lines +348 to +358
Copy link
Owner

Choose a reason for hiding this comment

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

I think we shouldn't need to call to_py here. Can we instead use resolve_string?

https://github.com/LilyFoote/django-rusty-templates/blob/a7e3e9fc524ea65a5fc9c91eeb52ce7f6c815f07/src/render/types.rs#L86

Something like:

Suggested change
let mapping = match arg_content.to_py(py)?.extract::<String>() {
Ok(s) => s,
Err(_) => {
return Err(PyRenderError::RenderError(RenderError::VariableDoesNotExist {
key: "yesno argument".to_string(),
object: "string".to_string(),
key_at: (0, 0).into(),
object_at: None,
}));
}
};
let mapping = arg_content.resolve_string(context)?.content();

Otherwise, we should probably match arg_content and handle each case directly.


let parts: Vec<&str> = mapping.split(',').collect();

// Handle None values
if left.to_py(py)?.is_none() {
Copy link
Owner

Choose a reason for hiding this comment

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

I wonder if we should add a new method to Content returning an enum with three variants: None, Truthy and Falsey, which we can then match on here.

// Return "maybe" if provided, otherwise fallback to "no"
let result = if parts.len() >= 3 {
parts[2].to_string()
} else {
parts.get(1).unwrap_or(&"no").to_string()
};
return Ok(Some(Content::String(Cow::Owned(result))));
Comment on lines +365 to +370
Copy link
Owner

Choose a reason for hiding this comment

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

I think it would be cleaner to match on the length of parts and handle each case explicitly:

Suggested change
let result = if parts.len() >= 3 {
parts[2].to_string()
} else {
parts.get(1).unwrap_or(&"no").to_string()
};
return Ok(Some(Content::String(Cow::Owned(result))));
let result = match parts.len() {
3 => Cow::Owned(parts[2].to_string()),
2 => Cow::Owned(parts[1].to_string()),
1 => Cow::Borrowed("no"),
0 => todo!("Match Django's behaviour"),
_ => todo!("Match Django's behaviour"),
};
return Ok(Some(Content::String(result)));

}

// Handle True/False values
match left.to_py(py)?.is_truthy() {
Ok(true) => Ok(Some(Content::String(Cow::Owned(parts.get(0).unwrap_or(&"yes").to_string())))),
Ok(false) => Ok(Some(Content::String(Cow::Owned(parts.get(1).unwrap_or(&"no").to_string())))),
Err(e) => Err(PyRenderError::PyErr(e)),
}
} else {
// Default mapping without an argument
if left.to_py(py)?.is_none() {
return Ok(Some(Content::String(Cow::Borrowed("maybe"))));
}

match left.to_py(py)?.is_truthy() {
Ok(true) => Ok(Some(Content::String(Cow::Borrowed("yes")))),
Ok(false) => Ok(Some(Content::String(Cow::Borrowed("no")))),
Err(e) => Err(PyRenderError::PyErr(e)),
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -797,4 +869,78 @@ mod tests {
assert_eq!(rendered, "bryony");
})
}

#[test]
fn test_render_filter_yesno_default() {
pyo3::prepare_freethreaded_python();

Python::with_gil(|py| {
let engine = EngineData::empty();

// Test with true value
let template_string = "{{ value|yesno }}".to_string();
let context = PyDict::new(py);
context.set_item("value", true).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "yes");

// Test with false value
let template_string = "{{ value|yesno }}".to_string();
let context = PyDict::new(py);
context.set_item("value", false).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "no");

// Test with None value
let template_string = "{{ value|yesno }}".to_string();
let context = PyDict::new(py);
context.set_item("value", py.None()).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "maybe");
})
}

#[test]
fn test_render_filter_yesno_custom() {
pyo3::prepare_freethreaded_python();

Python::with_gil(|py| {
let engine = EngineData::empty();

// Test with true value
let template_string = "{{ value|yesno:'yeah,no,maybe' }}".to_string();
let context = PyDict::new(py);
context.set_item("value", true).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "yeah");

// Test with false value
let template_string = "{{ value|yesno:'yeah,no,maybe' }}".to_string();
let context = PyDict::new(py);
context.set_item("value", false).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "no");

// Test with None value
let template_string = "{{ value|yesno:'yeah,no,maybe' }}".to_string();
let context = PyDict::new(py);
context.set_item("value", py.None()).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "maybe");

// Test with None value and no "maybe" option
let template_string = "{{ value|yesno:'yeah,no' }}".to_string();
let context = PyDict::new(py);
context.set_item("value", py.None()).unwrap();
let template = Template::new_from_string(py, template_string, &engine).unwrap();
let result = template.render(py, Some(context), None).unwrap();
assert_eq!(result, "no");
})
}
}
88 changes: 88 additions & 0 deletions tests/filters/test_yesno.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from django.template import Template, Context
from django.test import SimpleTestCase


class YesNoTests(SimpleTestCase):
def test_yesno_default(self):
# Test default mapping (yes, no, maybe)
template = Template("{{ var|yesno }}")

# Test with True value
rendered = template.render(Context({"var": True}))
self.assertEqual(rendered, "yes")

# Test with False value
rendered = template.render(Context({"var": False}))
self.assertEqual(rendered, "no")

# Test with None value
rendered = template.render(Context({"var": None}))
self.assertEqual(rendered, "maybe")

def test_yesno_custom(self):
# Test custom mapping
template = Template("{{ var|yesno:'yeah,nope,perhaps' }}")

# Test with True value
rendered = template.render(Context({"var": True}))
self.assertEqual(rendered, "yeah")

# Test with False value
rendered = template.render(Context({"var": False}))
self.assertEqual(rendered, "nope")

# Test with None value
rendered = template.render(Context({"var": None}))
self.assertEqual(rendered, "perhaps")

def test_yesno_two_options(self):
# Test with only two options - None uses the second option
template = Template("{{ var|yesno:'yep,nah' }}")
Copy link
Owner

Choose a reason for hiding this comment

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

We have tests for three and for two mappings. We should also test that we match Django's behaviour for zero, one or more than three mappings.


# Test with True value
rendered = template.render(Context({"var": True}))
self.assertEqual(rendered, "yep")

# Test with False value
rendered = template.render(Context({"var": False}))
self.assertEqual(rendered, "nah")

# Test with None value - should use second option when no third option
rendered = template.render(Context({"var": None}))
self.assertEqual(rendered, "nah")

def test_yesno_empty_string(self):
# Test with empty string (which is falsy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": ""}))
self.assertEqual(rendered, "no")

def test_yesno_empty_list(self):
# Test with empty list (which is falsy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": []}))
self.assertEqual(rendered, "no")

def test_yesno_non_empty_list(self):
# Test with non-empty list (which is truthy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": ["item"]}))
self.assertEqual(rendered, "yes")

def test_yesno_non_empty_string(self):
# Test with non-empty string (which is truthy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": "value"}))
self.assertEqual(rendered, "yes")

def test_yesno_zero(self):
# Test with zero (which is falsy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": 0}))
self.assertEqual(rendered, "no")

def test_yesno_nonzero(self):
# Test with non-zero number (which is truthy)
template = Template("{{ var|yesno }}")
rendered = template.render(Context({"var": 1}))
self.assertEqual(rendered, "yes")