diff --git a/src/parse.rs b/src/parse.rs index ff23d9fd..4ac5e22c 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::iter::Peekable; use std::sync::Arc; @@ -454,6 +455,33 @@ impl PartialEq for SimpleTag { } } +#[derive(Clone, Debug)] +pub struct SimpleBlockTag { + pub func: Arc>, + pub nodes: Vec, + pub at: (usize, usize), + pub takes_context: bool, + pub args: Vec, + pub kwargs: Vec<(String, TagElement)>, + pub target_var: Option, +} + +impl PartialEq for SimpleBlockTag { + fn eq(&self, other: &Self) -> bool { + // We use `Arc::ptr_eq` here to avoid needing the `py` token for true + // equality comparison between two `Py` smart pointers. + // + // We only use `eq` in tests, so this concession is acceptable here. + self.at == other.at + && self.takes_context == other.takes_context + && self.args == other.args + && self.kwargs == other.kwargs + && self.target_var == other.target_var + && self.nodes == other.nodes + && Arc::ptr_eq(&self.func, &other.func) + } +} + #[derive(Clone, Debug, PartialEq)] pub enum Tag { Autoescape { @@ -468,6 +496,7 @@ pub enum Tag { For(For), Load, SimpleTag(SimpleTag), + SimpleBlockTag(SimpleBlockTag), Url(Url), } @@ -480,11 +509,12 @@ enum EndTagType { Empty, EndFor, Verbatim, + Custom(String), } impl EndTagType { - fn as_str(&self) -> &'static str { - match self { + fn as_cow(&self) -> Cow<'static, str> { + let end_tag = match self { Self::Autoescape => "endautoescape", Self::Elif => "elif", Self::Else => "else", @@ -492,7 +522,9 @@ impl EndTagType { Self::Empty => "empty", Self::EndFor => "endfor", Self::Verbatim => "endverbatim", - } + Self::Custom(s) => return Cow::Owned(s.clone()), + }; + Cow::Borrowed(end_tag) } } @@ -504,8 +536,8 @@ struct EndTag { } impl EndTag { - fn as_str(&self) -> &'static str { - self.end.as_str() + fn as_cow(&self) -> Cow<'static, str> { + self.end.as_cow() } } @@ -645,7 +677,7 @@ pub enum ParseError { }, #[error("Unclosed '{start}' tag. Looking for one of: {expected}")] MissingEndTag { - start: &'static str, + start: Cow<'static, str>, expected: String, #[label("started here")] at: SourceSpan, @@ -677,6 +709,12 @@ pub enum ParseError { #[label("here")] at: SourceSpan, }, + #[error("'{name}' must have a first argument of 'content'")] + RequiresContent { + name: String, + #[label("loaded here")] + at: SourceSpan, + }, #[error( "'{name}' is decorated with takes_context=True so it must have a first argument of 'context'" )] @@ -685,6 +723,14 @@ pub enum ParseError { #[label("loaded here")] at: SourceSpan, }, + #[error( + "'{name}' is decorated with takes_context=True so it must have a first argument of 'context' and a second argument of 'content'" + )] + RequiresContextAndContent { + name: String, + #[label("loaded here")] + at: SourceSpan, + }, #[error("'{tag_name}' did not receive value(s) for the argument(s): {missing}")] MissingArguments { tag_name: String, @@ -731,7 +777,7 @@ pub enum ParseError { }, #[error("Unexpected tag {unexpected}")] UnexpectedEndTag { - unexpected: &'static str, + unexpected: Cow<'static, str>, #[label("unexpected tag")] at: SourceSpan, }, @@ -748,7 +794,7 @@ pub enum ParseError { }, #[error("Unexpected tag {unexpected}, expected {expected}")] WrongEndTag { - unexpected: &'static str, + unexpected: Cow<'static, str>, expected: String, #[label("unexpected tag")] at: SourceSpan, @@ -806,7 +852,7 @@ impl LoadToken { } } -#[derive(Clone)] +#[derive(Debug, Clone)] struct SimpleTagContext<'py> { func: Bound<'py, PyAny>, function_name: String, @@ -821,7 +867,12 @@ struct SimpleTagContext<'py> { #[derive(Clone)] enum TagContext<'py> { - SimpleTag(SimpleTagContext<'py>), + Simple(SimpleTagContext<'py>), + SimpleBlock { + end_tag_name: String, + context: SimpleTagContext<'py>, + }, + EndSimpleBlock, } pub struct Parser<'t, 'l, 'py> { @@ -887,7 +938,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { Either::Right(end_tag) => { return Err(ParseError::UnexpectedEndTag { at: end_tag.at.into(), - unexpected: end_tag.as_str(), + unexpected: end_tag.as_cow(), } .into()); } @@ -901,7 +952,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { fn parse_until( &mut self, until: Vec, - start: &'static str, + start: Cow<'static, str>, start_at: (usize, usize), ) -> Result<(Vec, EndTag), PyParseError> { let mut nodes = Vec::new(); @@ -925,10 +976,10 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { return Err(ParseError::WrongEndTag { expected: until .iter() - .map(|u| u.as_str()) + .map(|u| u.as_cow()) .collect::>() .join(", "), - unexpected: end_tag.as_str(), + unexpected: end_tag.as_cow(), at: end_tag.at.into(), start_at: start_at.into(), } @@ -943,7 +994,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { start, expected: until .iter() - .map(|u| u.as_str()) + .map(|u| u.as_cow()) .collect::>() .join(", "), at: start_at.into(), @@ -1080,9 +1131,24 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { parts, }), tag_name => match self.external_tags.get(tag_name) { - Some(TagContext::SimpleTag(context)) => { + Some(TagContext::Simple(context)) => { Either::Left(self.parse_simple_tag(context, at, parts)?) } + Some(TagContext::SimpleBlock { + context, + end_tag_name, + }) => Either::Left(self.parse_simple_block_tag( + context.clone(), + tag_name.to_string(), + end_tag_name.clone(), + at, + parts, + )?), + Some(TagContext::EndSimpleBlock) => Either::Right(EndTag { + end: EndTagType::Custom(tag_name.to_string()), + at, + parts, + }), None => todo!("{tag_name}"), }, }) @@ -1206,6 +1272,32 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { Ok(TokenTree::Tag(Tag::SimpleTag(tag))) } + fn parse_simple_block_tag( + &mut self, + context: SimpleTagContext, + tag_name: String, + end_tag_name: String, + at: (usize, usize), + parts: TagParts, + ) -> Result { + let (args, kwargs, target_var) = self.parse_custom_tag_parts(parts, &context)?; + let (nodes, _) = self.parse_until( + vec![EndTagType::Custom(end_tag_name)], + Cow::Owned(tag_name), + at, + )?; + let tag = SimpleBlockTag { + func: context.func.clone().unbind().into(), + nodes, + at, + takes_context: context.takes_context, + args, + kwargs, + target_var, + }; + Ok(TokenTree::Tag(Tag::SimpleBlockTag(tag))) + } + fn parse_load( &mut self, at: (usize, usize), @@ -1266,27 +1358,92 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { .try_iter()? .map(|v| v?.getattr("cell_contents")) .collect::, _>>()?; + + fn get_defaults_count(defaults: &Bound<'_, PyAny>) -> Result { + match defaults.is_none() { + true => Ok(0), + false => defaults.len(), + } + } + + fn get_kwonly_defaults( + kwonly_defaults: &Bound<'_, PyAny>, + ) -> Result, PyErr> { + match kwonly_defaults.is_none() { + true => Ok(HashSet::new()), + false => kwonly_defaults + .try_iter()? + .map(|item| item?.extract()) + .collect::>(), + } + } + if closure_names.contains(&"filename".to_string()) { todo!("Inclusion tag") } else if closure_names.contains(&"end_name".to_string()) { - todo!("Simple block tag") - } else { - let defaults = &closure_values[0]; - let defaults_count = match defaults.is_none() { - true => 0, - false => defaults.len()?, + let defaults_count = get_defaults_count(&closure_values[0])?; + let end_tag_name: String = closure_values[1].extract()?; + let func = closure_values[2].clone(); + let function_name = closure_values[3].extract()?; + let kwonly = closure_values[4].extract()?; + let kwonly_defaults = get_kwonly_defaults(&closure_values[5])?; + let params: Vec = closure_values[6].extract()?; + let takes_context = closure_values[7].is_truthy()?; + let varargs = !closure_values[8].is_none(); + let varkw = !closure_values[9].is_none(); + + let params = match takes_context { + false => { + if let Some(param) = params.first() + && param == "content" + { + params.iter().skip(1).cloned().collect() + } else { + return Err(ParseError::RequiresContent { + name: function_name, + at: at.into(), + } + .into()); + } + } + true => { + if let Some([context, content]) = params.first_chunk::<2>() + && context == "context" + && content == "content" + { + params.iter().skip(2).cloned().collect() + } else { + return Err(ParseError::RequiresContextAndContent { + name: function_name, + at: at.into(), + } + .into()); + } + } }; + // TODO: `end_tag_name already present? + self.external_tags + .insert(end_tag_name.clone(), TagContext::EndSimpleBlock); + TagContext::SimpleBlock { + end_tag_name, + context: SimpleTagContext { + func, + function_name, + takes_context, + params, + defaults_count, + varargs, + kwonly, + kwonly_defaults, + varkw, + }, + } + } else { + let defaults_count = get_defaults_count(&closure_values[0])?; let func = closure_values[1].clone(); let function_name = closure_values[2].extract()?; let kwonly = closure_values[3].extract()?; - let kwonly_defaults = &closure_values[4]; - let kwonly_defaults = match kwonly_defaults.is_none() { - true => HashSet::new(), - false => kwonly_defaults - .try_iter()? - .map(|item| item?.extract()) - .collect::>()?, - }; + let kwonly_defaults = get_kwonly_defaults(&closure_values[4])?; let params: Vec = closure_values[5].extract()?; let takes_context = closure_values[6].is_truthy()?; let varargs = !closure_values[7].is_none(); @@ -1308,7 +1465,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { } } }; - TagContext::SimpleTag(SimpleTagContext { + TagContext::Simple(SimpleTagContext { func, function_name, takes_context, @@ -1406,7 +1563,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { parts: TagParts, ) -> Result { let token = lex_autoescape_argument(self.template, parts).map_err(ParseError::from)?; - let (nodes, _) = self.parse_until(vec![EndTagType::Autoescape], "autoescape", at)?; + let (nodes, _) = self.parse_until(vec![EndTagType::Autoescape], "autoescape".into(), at)?; Ok(TokenTree::Tag(Tag::Autoescape { enabled: token.enabled, nodes, @@ -1422,7 +1579,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { let condition = parse_if_condition(self, parts, at)?; let (nodes, end_tag) = self.parse_until( vec![EndTagType::Elif, EndTagType::Else, EndTagType::EndIf], - start, + start.into(), at, )?; let falsey = match end_tag { @@ -1436,7 +1593,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { end: EndTagType::Else, parts: _parts, } => { - let (nodes, _) = self.parse_until(vec![EndTagType::EndIf], "else", at)?; + let (nodes, _) = self.parse_until(vec![EndTagType::EndIf], "else".into(), at)?; Some(nodes) } EndTag { @@ -1460,8 +1617,11 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { ) -> Result { self.forloop_depth += 1; let (iterable, variables, reversed) = parse_for_loop(self, parts, at)?; - let (nodes, end_tag) = - self.parse_until(vec![EndTagType::Empty, EndTagType::EndFor], "for", at)?; + let (nodes, end_tag) = self.parse_until( + vec![EndTagType::Empty, EndTagType::EndFor], + "for".into(), + at, + )?; self.forloop_depth -= 1; let empty = match end_tag { EndTag { @@ -1469,7 +1629,7 @@ impl<'t, 'l, 'py> Parser<'t, 'l, 'py> { end: EndTagType::Empty, parts: _parts, } => { - let (nodes, _) = self.parse_until(vec![EndTagType::EndFor], "empty", at)?; + let (nodes, _) = self.parse_until(vec![EndTagType::EndFor], "empty".into(), at)?; Some(nodes) } EndTag { @@ -2371,4 +2531,35 @@ mod tests { ); }) } + + #[test] + fn test_simple_block_tag_partial_eq() { + Python::initialize(); + + Python::attach(|py| { + let func: Arc> = PyDict::new(py).into_any().unbind().into(); + let at = (0, 1); + let takes_context = true; + assert_eq!( + SimpleBlockTag { + func: func.clone(), + at, + takes_context, + args: Vec::new(), + kwargs: Vec::new(), + nodes: Vec::new(), + target_var: Some("foo".to_string()), + }, + SimpleBlockTag { + func, + at, + takes_context, + args: Vec::new(), + kwargs: Vec::new(), + nodes: Vec::new(), + target_var: Some("foo".to_string()), + }, + ); + }) + } } diff --git a/src/render/filters.rs b/src/render/filters.rs index 8c3aee1e..37105f50 100644 --- a/src/render/filters.rs +++ b/src/render/filters.rs @@ -104,8 +104,8 @@ impl ResolveFilter for AddFilter { Ok(match (variable.to_bigint(), right.to_bigint()) { (Some(variable), Some(right)) => Some(Content::Int(variable + right)), _ => { - let variable = variable.to_py(py)?; - let right = right.to_py(py)?; + let variable = variable.to_py(py); + let right = right.to_py(py); variable.add(right).ok().map(Content::Py) } }) diff --git a/src/render/tags.rs b/src/render/tags.rs index 045ee1dc..839c4c6b 100644 --- a/src/render/tags.rs +++ b/src/render/tags.rs @@ -12,7 +12,7 @@ use pyo3::types::{PyBool, PyDict, PyList, PyNone, PyString, PyTuple}; use super::types::{AsBorrowedContent, Content, Context, PyContext}; use super::{Evaluate, Render, RenderResult, Resolve, ResolveFailures, ResolveResult}; use crate::error::{AnnotatePyErr, PyRenderError, RenderError}; -use crate::parse::{For, IfCondition, SimpleTag, Tag, Url}; +use crate::parse::{For, IfCondition, SimpleBlockTag, SimpleTag, Tag, TagElement, Url}; use crate::template::django_rusty_templates::NoReverseMatch; use crate::types::TemplateString; use crate::utils::PyResultMethods; @@ -420,7 +420,7 @@ impl Contains>> for Content<'_, '_> { _ => None, }, Some(Content::Py(other)) => { - let obj = self.to_py(other.py()).ok()?; + let obj = self.to_py(other.py()); obj.contains(other).ok() } Some(Content::String(other)) => match self { @@ -646,6 +646,7 @@ impl Render for Tag { Self::For(for_tag) => for_tag.render(py, template, context)?, Self::Load => Cow::Borrowed(""), Self::SimpleTag(simple_tag) => simple_tag.render(py, template, context)?, + Self::SimpleBlockTag(simple_tag) => simple_tag.render(py, template, context)?, Self::Url(url) => url.render(py, template, context)?, }) } @@ -756,86 +757,175 @@ impl Render for For { } } -impl SimpleTag { - fn call_tag<'t>( +fn call_tag<'t>( + py: Python<'_>, + func: &Arc>, + at: (usize, usize), + template: TemplateString<'t>, + args: VecDeque>, + kwargs: Bound<'_, PyDict>, +) -> RenderResult<'t> { + let func = func.bind(py); + match func.call( + PyTuple::new(py, args).expect("All arguments should be valid Python objects"), + Some(&kwargs), + ) { + Ok(content) => Ok(Cow::Owned(content.to_string())), + Err(error) => Err(error.annotate(py, at, "here", template).into()), + } +} + +fn add_context_to_args<'py>( + py: Python<'py>, + args: &mut VecDeque>, + context: &mut Context, +) -> Result, PyErr> { + // Take ownership of `context` so we can pass it to Python. + // The `context` variable now points to an empty `Context` instance which will not be + // used except as a placeholder. + let swapped_context = std::mem::take(context); + + // Wrap the context as a Python object and add it to the call args + let py_context = Bound::new(py, PyContext::new(swapped_context))?.into_any(); + args.push_front(py_context.clone()); + + Ok(py_context) +} + +fn retrieve_context<'py>(py: Python<'py>, py_context: Bound<'py, PyAny>, context: &mut Context) { + // Retrieve the PyContext wrapper from Python + let extracted_context: PyContext = py_context + .extract() + .expect("The type of py_context should not have changed"); + // Ensure we only hold one reference in Rust by dropping the Python object. + drop(py_context); + + // Try to remove the Context from the PyContext + let inner_context = match Arc::try_unwrap(extracted_context.context) { + // Fast path when we have the only reference in the Arc. + Ok(inner_context) => inner_context + .into_inner() + .expect("Mutex should be unlocked because Arc refcount is one."), + // Slow path when Python has held on to the context for some reason. + // We can still do the right thing by cloning. + Err(inner_context) => { + let guard = inner_context + .lock_py_attached(py) + .expect("Mutex should not be poisoned"); + guard.clone_ref(py) + } + }; + // Put the Context back in `context` + let _ = std::mem::replace(context, inner_context); +} + +fn build_arg<'py>( + py: Python<'py>, + template: TemplateString<'_>, + context: &mut Context, + arg: &TagElement, +) -> Result, PyRenderError> { + let arg = match arg.resolve(py, template, context, ResolveFailures::Raise)? { + Some(arg) => arg.to_py(py), + None => PyString::intern(py, "").into_any(), + }; + Ok(arg) +} + +fn build_args<'py>( + py: Python<'py>, + template: TemplateString<'_>, + context: &mut Context, + args: &[TagElement], +) -> Result>, PyRenderError> { + args.iter() + .map(|arg| build_arg(py, template, context, arg)) + .collect() +} + +fn build_kwargs<'py>( + py: Python<'py>, + template: TemplateString<'_>, + context: &mut Context, + kwargs: &Vec<(String, TagElement)>, +) -> Result, PyRenderError> { + let _kwargs = PyDict::new(py); + for (key, value) in kwargs { + let value = value.resolve(py, template, context, ResolveFailures::Raise)?; + _kwargs.set_item(key, value)?; + } + Ok(_kwargs) +} + +fn store_target_var<'t>( + py: Python<'_>, + context: &mut Context, + content: Cow<'t, str>, + target_var: &Option, +) -> Cow<'t, str> { + match target_var { + None => content, + Some(target_var) => { + let content = PyString::new(py, &content).into_any(); + context.insert(target_var.clone(), content); + Cow::Borrowed("") + } + } +} + +impl Render for SimpleTag { + fn render<'t>( &self, py: Python<'_>, template: TemplateString<'t>, - args: VecDeque>, - kwargs: Bound<'_, PyDict>, + context: &mut Context, ) -> RenderResult<'t> { - let func = self.func.bind(py); - match func.call( - PyTuple::new(py, args).expect("All arguments should be valid Python objects"), - Some(&kwargs), - ) { - Ok(content) => Ok(Cow::Owned(content.to_string())), - Err(error) => Err(error.annotate(py, self.at, "here", template).into()), - } + let mut args = build_args(py, template, context, &self.args)?; + let kwargs = build_kwargs(py, template, context, &self.kwargs)?; + let content = if self.takes_context { + let py_context = add_context_to_args(py, &mut args, context)?; + + // Actually call the tag + let result = call_tag(py, &self.func, self.at, template, args, kwargs); + + retrieve_context(py, py_context, context); + + // Return the result of calling the tag + result? + } else { + call_tag(py, &self.func, self.at, template, args, kwargs)? + }; + Ok(store_target_var(py, context, content, &self.target_var)) } } -impl Render for SimpleTag { +impl Render for SimpleBlockTag { fn render<'t>( &self, py: Python<'_>, template: TemplateString<'t>, context: &mut Context, ) -> RenderResult<'t> { - let mut args = VecDeque::new(); - for arg in &self.args { - match arg.resolve(py, template, context, ResolveFailures::Raise)? { - None => return Ok(Cow::Borrowed("")), - Some(arg) => args.push_back(arg.to_py(py)?), - } - } - let kwargs = PyDict::new(py); - for (key, value) in &self.kwargs { - let value = value.resolve(py, template, context, ResolveFailures::Raise)?; - kwargs.set_item(key, value)?; - } - if self.takes_context { - // Take ownership of `context` so we can pass it to Python. - // The `context` variable now points to an empty `Context` instance which will not be - // used except as a placeholder. - let swapped_context = std::mem::take(context); + let mut args = build_args(py, template, context, &self.args)?; + let kwargs = build_kwargs(py, template, context, &self.kwargs)?; + + let content = self.nodes.render(py, template, context)?; + let content = PyString::new(py, &content).into_any(); + args.push_front(content); - // Wrap the context as a Python object and add it to the call args - let py_context = Bound::new(py, PyContext::new(swapped_context))?.into_any(); - args.push_front(py_context.clone()); + let content = if self.takes_context { + let py_context = add_context_to_args(py, &mut args, context)?; // Actually call the tag - let result = self.call_tag(py, template, args, kwargs); - - // Retrieve the PyContext wrapper from Python - let extracted_context: PyContext = py_context - .extract() - .expect("The type of py_context should not have changed"); - // Ensure we only hold one reference in Rust by dropping the Python object. - drop(py_context); - - // Try to remove the Context from the PyContext - let inner_context = match Arc::try_unwrap(extracted_context.context) { - // Fast path when we have the only reference in the Arc. - Ok(inner_context) => inner_context - .into_inner() - .expect("Mutex should be unlocked because Arc refcount is one."), - // Slow path when Python has held on to the context for some reason. - // We can still do the right thing by cloning. - Err(inner_context) => { - let guard = inner_context - .lock_py_attached(py) - .expect("Mutex should not be poisoned"); - guard.clone_ref(py) - } - }; - // Put the Context back in `context` - let _ = std::mem::replace(context, inner_context); + let result = call_tag(py, &self.func, self.at, template, args, kwargs); + + retrieve_context(py, py_context, context); // Return the result of calling the tag - result + result? } else { - self.call_tag(py, template, args, kwargs) - } + call_tag(py, &self.func, self.at, template, args, kwargs)? + }; + Ok(store_target_var(py, context, content, &self.target_var)) } } diff --git a/src/render/types.rs b/src/render/types.rs index b854331f..f1eb036e 100644 --- a/src/render/types.rs +++ b/src/render/types.rs @@ -448,8 +448,8 @@ impl<'t, 'py> Content<'t, 'py> { } } - pub fn to_py(&self, py: Python<'py>) -> PyResult> { - Ok(match self { + pub fn to_py(&self, py: Python<'py>) -> Bound<'py, PyAny> { + match self { Self::Py(object) => object.clone(), Self::Int(i) => i .into_pyobject(py) @@ -472,13 +472,19 @@ impl<'t, 'py> Content<'t, 'py> { let string = s .into_pyobject(py) .expect("A string can always be converted to a Python str."); - let safestring = py.import(intern!(py, "django.utils.safestring"))?; - let mark_safe = safestring.getattr(intern!(py, "mark_safe"))?; - mark_safe.call1((string,))? + let safestring = py + .import(intern!(py, "django.utils.safestring")) + .expect("Should be able to import `django.utils.safestring`"); + let mark_safe = safestring + .getattr(intern!(py, "mark_safe")) + .expect("`safestring` should have a `mark_safe` function"); + mark_safe + .call1((string,)) + .expect("`mark_safe` should not raise if given a string") } }, Self::Bool(b) => PyBool::new(py, *b).to_owned().into_any(), - }) + } } } diff --git a/tests/tags/test_custom.py b/tests/tags/test_custom_simple.py similarity index 95% rename from tests/tags/test_custom.py rename to tests/tags/test_custom_simple.py index c18155d0..60c4b943 100644 --- a/tests/tags/test_custom.py +++ b/tests/tags/test_custom_simple.py @@ -38,6 +38,35 @@ def test_simple_tag_double_missing_variable(): assert rust_template.render({}) == "" +def test_simple_tag_multiply_missing_variables(): + template = "{% load multiply from custom_tags %}{% multiply foo bar eggs %}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + with pytest.raises(TypeError) as exc_info: + django_template.render({}) + + error = str(exc_info.value) + assert error == "can't multiply sequence by non-int of type 'str'" + + with pytest.raises(TypeError) as exc_info: + rust_template.render({}) + + error = str(exc_info.value) + assert ( + error + == """\ + × can't multiply sequence by non-int of type 'str' + ╭──── + 1 │ {% load multiply from custom_tags %}{% multiply foo bar eggs %} + · ─────────────┬───────────── + · ╰── here + ╰──── +""" + ) + + def test_simple_tag_kwargs(): template = "{% load table from custom_tags %}{% table foo='bar' spam=1 %}" @@ -59,13 +88,13 @@ def test_simple_tag_positional_and_kwargs(): def test_simple_tag_double_as_variable(): - template = "{% load double from custom_tags %}{% double 3 as foo %}{{ foo }}" + template = "{% load double from custom_tags %}{% double 3 as foo %}{{ foo }}{{ foo }}" django_template = engines["django"].from_string(template) rust_template = engines["rusty"].from_string(template) - assert django_template.render({}) == "6" - assert rust_template.render({}) == "6" + assert django_template.render({}) == "66" + assert rust_template.render({}) == "66" def test_simple_tag_double_kwarg_as_variable(): diff --git a/tests/tags/test_custom_simple_block.py b/tests/tags/test_custom_simple_block.py new file mode 100644 index 00000000..f8de8e59 --- /dev/null +++ b/tests/tags/test_custom_simple_block.py @@ -0,0 +1,288 @@ +import pytest +from django.template import engines +from django.template.base import VariableDoesNotExist +from django.template.exceptions import TemplateSyntaxError + + +def test_simple_block_tag_repeat(): + template = "{% load repeat from custom_tags %}{% repeat 5 %}foo{% endrepeat %}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + expected = "foofoofoofoofoo" + assert django_template.render({}) == expected + assert rust_template.render({}) == expected + + +def test_simple_block_tag_repeat_as(): + template = "{% load repeat from custom_tags %}{% repeat 2 as bar %}foo{% endrepeat %}{{ bar }}{{ bar|upper }}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + expected = "foofooFOOFOO" + assert django_template.render({}) == expected + assert rust_template.render({}) == expected + + +def test_with_block(): + template = "{% load with_block from custom_tags %}{% with_block var='name' %}{{ user }}{% end_with_block %}{{ name|lower }}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + context = {"user": "Lily"} + expected = "lily" + assert django_template.render(context) == expected + assert rust_template.render(context) == expected + + +def test_simple_block_tag_missing_context(): + template = "{% load missing_context_block from invalid_tags %}{% missing_context_block %}{% end_missing_context_block %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "'missing_context_block' is decorated with takes_context=True so it must have a first argument of 'context' and a second argument of 'content'" + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × 'missing_context_block' is decorated with takes_context=True so it must + │ have a first argument of 'context' and a second argument of 'content' + ╭──── + 1 │ {% load missing_context_block from invalid_tags %}{% missing_context_block %}{% end_missing_context_block %} + · ──────────┬────────── + · ╰── loaded here + ╰──── +""" + ) + + +def test_simple_block_tag_missing_content(): + template = "{% load missing_content_block from invalid_tags %}{% missing_content_block %}{% end_missing_content_block %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "'missing_content_block' must have a first argument of 'content'" + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × 'missing_content_block' must have a first argument of 'content' + ╭──── + 1 │ {% load missing_content_block from invalid_tags %}{% missing_content_block %}{% end_missing_content_block %} + · ──────────┬────────── + · ╰── loaded here + ╰──── +""" + ) + + +def test_simple_block_tag_missing_content_takes_context(): + template = "{% load missing_content_block_with_context from invalid_tags %}{% missing_content_block_with_context %}{% end_missing_content_block_with_context %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "'missing_content_block_with_context' is decorated with takes_context=True so it must have a first argument of 'context' and a second argument of 'content'" + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × 'missing_content_block_with_context' is decorated with takes_context=True + │ so it must have a first argument of 'context' and a second argument of + │ 'content' + ╭──── + 1 │ {% load missing_content_block_with_context from invalid_tags %}{% missing_content_block_with_context %}{% end_missing_content_block_with_context %} + · ─────────────────┬──────────────── + · ╰── loaded here + ╰──── +""" + ) + + +def test_simple_block_tag_missing_end_tag(): + template = "{% load repeat from custom_tags %}{% repeat 3 %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "Unclosed tag on line 1: 'repeat'. Looking for one of: endrepeat." + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × Unclosed 'repeat' tag. Looking for one of: endrepeat + ╭──── + 1 │ {% load repeat from custom_tags %}{% repeat 3 %} + · ───────┬────── + · ╰── started here + ╰──── +""" + ) + + +def test_simple_block_tag_end_tag_only(): + template = "{% load repeat from custom_tags %}{% endrepeat %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "Invalid block tag on line 1: 'endrepeat'. Did you forget to register or load this tag?" + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × Unexpected tag endrepeat + ╭──── + 1 │ {% load repeat from custom_tags %}{% endrepeat %} + · ───────┬─────── + · ╰── unexpected tag + ╰──── +""" + ) + + +def test_simple_block_tag_missing_argument(): + template = "{% load repeat from custom_tags %}{% repeat five %}{% endrepeat %}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + with pytest.raises(TypeError) as exc_info: + django_template.render({}) + + assert str(exc_info.value) == "can't multiply sequence by non-int of type 'str'" + + with pytest.raises(TypeError) as exc_info: + rust_template.render({}) + + assert ( + str(exc_info.value) + == """\ + × can't multiply sequence by non-int of type 'str' + ╭──── + 1 │ {% load repeat from custom_tags %}{% repeat five %}{% endrepeat %} + · ────────┬──────── + · ╰── here + ╰──── +""" + ) + + +def test_simple_block_tag_invalid_argument(): + template = "{% load repeat from custom_tags %}{% repeat five|default:five %}{% endrepeat %}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + with pytest.raises(VariableDoesNotExist) as exc_info: + django_template.render({}) + + assert ( + str(exc_info.value) + == "Failed lookup for key [five] in [{'True': True, 'False': False, 'None': None}, {}]" + ) + + with pytest.raises(VariableDoesNotExist) as exc_info: + rust_template.render({}) + + assert ( + str(exc_info.value) + == """\ + × Failed lookup for key [five] in {"False": False, "None": None, "True": + │ True} + ╭──── + 1 │ {% load repeat from custom_tags %}{% repeat five|default:five %}{% endrepeat %} + · ──┬─ + · ╰── key + ╰──── +""" + ) + + +def test_simple_block_tag_argument_syntax_error(): + template = "{% load repeat from custom_tags %}{% repeat a= %}{% endrepeat %}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert ( + str(exc_info.value) + == "Could not parse the remainder: '=' from 'a='" + ) + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + assert ( + str(exc_info.value) + == """\ + × Incomplete keyword argument + ╭──── + 1 │ {% load repeat from custom_tags %}{% repeat a= %}{% endrepeat %} + · ─┬ + · ╰── here + ╰──── +""" + ) + + +def test_simple_block_tag_content_render_error(): + template = "{% load repeat from custom_tags %}{% repeat 2 %}{{ foo|default:bar }}{% endrepeat %}" + + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + with pytest.raises(VariableDoesNotExist) as exc_info: + django_template.render({}) + + error = "Failed lookup for key [bar] in [{'True': True, 'False': False, 'None': None}, {}]" + assert str(exc_info.value) == error + + with pytest.raises(VariableDoesNotExist) as exc_info: + rust_template.render({}) + + error = """\ + × Failed lookup for key [bar] in {"False": False, "None": None, "True": + │ True} + ╭──── + 1 │ {% load repeat from custom_tags %}{% repeat 2 %}{{ foo|default:bar }}{% endrepeat %} + · ─┬─ + · ╰── key + ╰──── +""" + assert str(exc_info.value) == error diff --git a/tests/templatetags/custom_tags.py b/tests/templatetags/custom_tags.py index 7b46a249..7a909a7d 100644 --- a/tests/templatetags/custom_tags.py +++ b/tests/templatetags/custom_tags.py @@ -72,11 +72,17 @@ def counter(context): return "" -# @register.simple_block_tag -# def repeat(content, count): -# return content * count -# -# +@register.simple_block_tag +def repeat(content, count): + return content * count + + +@register.simple_block_tag(takes_context=True, end_name="end_with_block") +def with_block(context, content, *, var): + context[var] = content + return "" + + # @register.inclusion_tag("results.html") # def results(poll): # return {"choices": poll.choices} diff --git a/tests/templatetags/invalid_tags.py b/tests/templatetags/invalid_tags.py index 79d1c66a..4cc3c966 100644 --- a/tests/templatetags/invalid_tags.py +++ b/tests/templatetags/invalid_tags.py @@ -8,6 +8,20 @@ def missing_context(request): ... +@register.simple_block_tag(takes_context=True) +def missing_context_block(content): ... + + +@register.simple_block_tag( + takes_context=True, end_name="end_missing_content_block_with_context" +) +def missing_content_block_with_context(context): ... + + +@register.simple_block_tag(takes_context=False) +def missing_content_block(context): ... + + @register.simple_tag(takes_context=True) def request_path(context): global smuggled_context