From 75884bbfb686e1166e22c246488053e6a3817a04 Mon Sep 17 00:00:00 2001 From: Lucas Gruber Date: Sun, 27 Apr 2025 14:39:11 +0200 Subject: [PATCH 1/6] Implement upper filter --- src/filters.rs | 4 ++ src/parse.rs | 5 +++ src/render/filters.rs | 74 ++++++++++++++++++++++++++++++++++++- tests/filters/test_upper.py | 30 +++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/filters/test_upper.py diff --git a/src/filters.rs b/src/filters.rs index ca68a515..8eac6e3c 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -15,6 +15,7 @@ pub enum FilterType { Lower(LowerFilter), Safe(SafeFilter), Slugify(SlugifyFilter), + Upper(UpperFilter), } #[derive(Clone, Debug, PartialEq)] @@ -81,3 +82,6 @@ pub struct SafeFilter; #[derive(Clone, Debug, PartialEq)] pub struct SlugifyFilter; + +#[derive(Clone, Debug, PartialEq)] +pub struct UpperFilter; \ No newline at end of file diff --git a/src/parse.rs b/src/parse.rs index 5daf01e4..4aa99014 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -18,6 +18,7 @@ use crate::filters::FilterType; use crate::filters::LowerFilter; use crate::filters::SafeFilter; use crate::filters::SlugifyFilter; +use crate::filters::UpperFilter; use crate::lex::START_TAG_LEN; use crate::lex::autoescape::{AutoescapeEnabled, AutoescapeError, lex_autoescape_argument}; use crate::lex::common::LexerError; @@ -123,6 +124,10 @@ impl Filter { Some(right) => return Err(unexpected_argument("slugify", right)), None => FilterType::Slugify(SlugifyFilter), }, + "upper" => match right { + Some(right) => return Err(unexpected_argument("upper", right)), + None => FilterType::Upper(UpperFilter), + }, external => { let external = match parser.external_filters.get(external) { Some(external) => external.clone().unbind(), diff --git a/src/render/filters.rs b/src/render/filters.rs index 6721a94a..9fbe6333 100644 --- a/src/render/filters.rs +++ b/src/render/filters.rs @@ -8,7 +8,7 @@ use pyo3::types::PyType; use crate::filters::{ AddFilter, AddSlashesFilter, CapfirstFilter, DefaultFilter, EscapeFilter, ExternalFilter, - FilterType, LowerFilter, SafeFilter, SlugifyFilter, + FilterType, LowerFilter, SafeFilter, SlugifyFilter, UpperFilter, }; use crate::parse::Filter; use crate::render::types::{Content, Context}; @@ -72,6 +72,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::Upper(filter) => filter.resolve(left, py, template, context), }; result } @@ -322,10 +323,30 @@ impl ResolveFilter for SlugifyFilter { } } +impl ResolveFilter for UpperFilter { + fn resolve<'t, 'py>( + &self, + variable: Option>, + _py: Python<'py>, + _template: TemplateString<'t>, + context: &mut Context, + ) -> ResolveResult<'t, 'py> { + let content = match variable { + Some(content) => Some( + content + .resolve_string(context)? + .map_content(|content| Cow::Owned(content.to_uppercase())), + ), + None => "".as_content(), + }; + Ok(content) + } +} + #[cfg(test)] mod tests { use super::*; - use crate::filters::{AddSlashesFilter, DefaultFilter, LowerFilter}; + use crate::filters::{AddSlashesFilter, DefaultFilter, LowerFilter, UpperFilter}; use crate::parse::TagElement; use crate::render::Render; use crate::template::django_rusty_templates::{EngineData, Template}; @@ -800,4 +821,53 @@ mod tests { assert_eq!(rendered, "bryony"); }) } + + #[test] + fn test_render_filter_upper() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let name = PyString::new(py, "Foo").into_any(); + let context = HashMap::from([("name".to_string(), name.unbind())]); + let mut context = Context { + context, + request: None, + autoescape: false, + }; + let template = TemplateString("{{ name|upper }}"); + let variable = Variable::new((3, 4)); + let filter = Filter { + at: (8, 5), + left: TagElement::Variable(variable), + filter: FilterType::Upper(UpperFilter), + }; + + let rendered = filter.render(py, template, &mut context).unwrap(); + assert_eq!(rendered, "FOO"); + }) + } + + #[test] + fn test_render_filter_upper_missing_left() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let context = HashMap::new(); + let mut context = Context { + context, + request: None, + autoescape: false, + }; + let template = TemplateString("{{ name|upper }}"); + let variable = Variable::new((3, 4)); + let filter = Filter { + at: (8, 5), + left: TagElement::Variable(variable), + filter: FilterType::Upper(UpperFilter), + }; + + let rendered = filter.render(py, template, &mut context).unwrap(); + assert_eq!(rendered, ""); + }) + } } diff --git a/tests/filters/test_upper.py b/tests/filters/test_upper.py new file mode 100644 index 00000000..171611c7 --- /dev/null +++ b/tests/filters/test_upper.py @@ -0,0 +1,30 @@ +from django.template import engines + + +def test_upper_string(): + template = "{{ var|upper }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "foo" + uppered = "FOO" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered + +def test_upper_undefined(): + template = "{{ var|upper }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + assert django_template.render() == "" + assert rust_template.render() == "" + +def test_upper_integer(): + template = "{{ var|upper }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "3" + uppered = "3" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered From 8d7e898f83bea965ab6927c00fbc6017138f73cb Mon Sep 17 00:00:00 2001 From: Lucas Gruber Date: Sun, 4 May 2025 19:16:01 +0200 Subject: [PATCH 2/6] Add some test cases --- src/filters.rs | 2 +- tests/filters/test_upper.py | 45 ++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/filters.rs b/src/filters.rs index 8eac6e3c..2e6beb1a 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -84,4 +84,4 @@ pub struct SafeFilter; pub struct SlugifyFilter; #[derive(Clone, Debug, PartialEq)] -pub struct UpperFilter; \ No newline at end of file +pub struct UpperFilter; diff --git a/tests/filters/test_upper.py b/tests/filters/test_upper.py index 171611c7..90fc436a 100644 --- a/tests/filters/test_upper.py +++ b/tests/filters/test_upper.py @@ -1,4 +1,5 @@ -from django.template import engines +import pytest +from django.template import engines, TemplateSyntaxError def test_upper_string(): @@ -28,3 +29,45 @@ def test_upper_integer(): uppered = "3" assert django_template.render({"var": var}) == uppered assert rust_template.render({"var": var}) == uppered + +def test_upper_with_argument(): + template = "{{ var|upper:arg }}" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["django"].from_string(template) + + assert str(exc_info.value) == "upper requires 1 arguments, 2 provided" + + with pytest.raises(TemplateSyntaxError) as exc_info: + engines["rusty"].from_string(template) + + expected = """\ + × upper filter does not take an argument + ╭──── + 1 │ {{ var|upper:arg }} + · ─┬─ + · ╰── unexpected argument + ╰──── +""" + assert str(exc_info.value) == expected + +def test_upper_unicode(): + template = "{{ var|upper }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "\xeb" + uppered = "\xcb" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered + + +def test_upper_html(): + template = "{{ var|upper }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "foo" + uppered = "<B>FOO</B>" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered From ec0592248b7095151bf9f3466beefec9544bc2ba Mon Sep 17 00:00:00 2001 From: Lily Foote Date: Sun, 4 May 2025 22:00:10 +0100 Subject: [PATCH 3/6] Add HtmlUnsafe variant to Content and ContentString I had hoped we only needed two variants, but I now think it's conceptually simpler to have `String` for contexts where escaping behaviour is irrelevant and `HtmlSafe` and `HtmlUnsafe` where it matters. --- src/render/filters.rs | 16 +++++++++------- src/render/tags.rs | 9 +++++++-- src/render/types.rs | 25 +++++++++++++++++++------ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/render/filters.rs b/src/render/filters.rs index 9fbe6333..a070b0b6 100644 --- a/src/render/filters.rs +++ b/src/render/filters.rs @@ -193,7 +193,7 @@ impl ResolveFilter for EscapeFilter { Ok(match variable { Some(content) => match content { Content::HtmlSafe(content) => Some(Content::HtmlSafe(content)), - Content::String(content) => { + Content::String(content) | Content::HtmlUnsafe(content) => { let mut encoded = String::new(); encode_quoted_attribute_to_string(&content, &mut encoded); Some(Content::HtmlSafe(Cow::Owned(encoded))) @@ -264,7 +264,9 @@ impl ResolveFilter for SafeFilter { let content = match variable { Some(content) => match content { Content::HtmlSafe(content) => Some(Content::HtmlSafe(content)), - Content::String(content) => Some(Content::HtmlSafe(content)), + Content::String(content) | Content::HtmlUnsafe(content) => { + Some(Content::HtmlSafe(content)) + } Content::Int(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), Content::Float(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), Content::Py(object) => { @@ -316,6 +318,7 @@ impl ResolveFilter for SlugifyFilter { Content::Float(content) => Some(Content::String(Cow::Owned(content.to_string()))), Content::String(content) => Some(Content::String(slugify(content))), Content::HtmlSafe(content) => Some(Content::HtmlSafe(slugify(content))), + Content::HtmlUnsafe(content) => Some(Content::HtmlUnsafe(slugify(content))), }, None => "".as_content(), }; @@ -332,11 +335,10 @@ impl ResolveFilter for UpperFilter { context: &mut Context, ) -> ResolveResult<'t, 'py> { let content = match variable { - Some(content) => Some( - content - .resolve_string(context)? - .map_content(|content| Cow::Owned(content.to_uppercase())), - ), + Some(content) => { + let content = content.resolve_string(context)?; + Some(content.map_content(|content| Cow::Owned(content.to_uppercase()))) + } None => "".as_content(), }; Ok(content) diff --git a/src/render/tags.rs b/src/render/tags.rs index 80c3351f..139921a7 100644 --- a/src/render/tags.rs +++ b/src/render/tags.rs @@ -94,6 +94,7 @@ impl Evaluate for Content<'_, '_> { Self::Py(obj) => obj.is_truthy().unwrap_or(false), Self::String(s) => !s.is_empty(), Self::HtmlSafe(s) => !s.is_empty(), + Self::HtmlUnsafe(s) => !s.is_empty(), Self::Float(f) => *f != 0.0, Self::Int(n) => *n != BigInt::ZERO, }) @@ -446,8 +447,12 @@ impl Contains>> for Content<'_, '_> { let obj = self.to_py(other.py()).ok()?; obj.contains(other).ok() } - Some(Content::String(other)) | Some(Content::HtmlSafe(other)) => match self { - Self::String(obj) | Self::HtmlSafe(obj) => Some(obj.contains(other.as_ref())), + Some(Content::String(other)) + | Some(Content::HtmlSafe(other)) + | Some(Content::HtmlUnsafe(other)) => match self { + Self::String(obj) | Self::HtmlSafe(obj) | Self::HtmlUnsafe(obj) => { + Some(obj.contains(other.as_ref())) + } Self::Int(_) | Self::Float(_) => None, Self::Py(obj) => obj.contains(other).ok(), }, diff --git a/src/render/types.rs b/src/render/types.rs index c6f6343b..25414712 100644 --- a/src/render/types.rs +++ b/src/render/types.rs @@ -20,6 +20,7 @@ pub struct Context { pub enum ContentString<'t> { String(Cow<'t, str>), HtmlSafe(Cow<'t, str>), + HtmlUnsafe(Cow<'t, str>), } #[allow(clippy::needless_lifetimes)] // https://github.com/rust-lang/rust-clippy/issues/13923 @@ -28,6 +29,7 @@ impl<'t, 'py> ContentString<'t> { match self { Self::String(content) => content, Self::HtmlSafe(content) => content, + Self::HtmlUnsafe(content) => Cow::Owned(encode_quoted_attribute(&content).to_string()), } } @@ -35,6 +37,7 @@ impl<'t, 'py> ContentString<'t> { match self { Self::String(content) => Content::String(f(content)), Self::HtmlSafe(content) => Content::HtmlSafe(f(content)), + Self::HtmlUnsafe(content) => Content::HtmlUnsafe(f(content)), } } } @@ -51,16 +54,15 @@ fn resolve_python<'t>(value: Bound<'_, PyAny>, context: &Context) -> PyResult value, false => value.str()?.into_any(), }; - Ok(ContentString::HtmlSafe( + Ok( match value .getattr(intern!(py, "__html__")) .ok_or_isinstance_of::(py)? { - Ok(html) => html.call0()?.extract::()?, - Err(_) => encode_quoted_attribute(&value.str()?.extract::()?).to_string(), - } - .into(), - )) + Ok(html) => ContentString::HtmlSafe(html.call0()?.extract::()?.into()), + Err(_) => ContentString::HtmlUnsafe(value.str()?.extract::()?.into()), + }, + ) } #[derive(Debug, IntoPyObject)] @@ -68,6 +70,7 @@ pub enum Content<'t, 'py> { Py(Bound<'py, PyAny>), String(Cow<'t, str>), HtmlSafe(Cow<'t, str>), + HtmlUnsafe(Cow<'t, str>), Float(f64), Int(BigInt), } @@ -78,6 +81,7 @@ impl<'t, 'py> Content<'t, 'py> { Self::Py(content) => resolve_python(content, context)?.content(), Self::String(content) => content, Self::HtmlSafe(content) => content, + Self::HtmlUnsafe(content) => Cow::Owned(encode_quoted_attribute(&content).to_string()), Self::Float(content) => content.to_string().into(), Self::Int(content) => content.to_string().into(), }) @@ -87,6 +91,7 @@ impl<'t, 'py> Content<'t, 'py> { Ok(match self { Self::String(content) => ContentString::String(content), Self::HtmlSafe(content) => ContentString::HtmlSafe(content), + Self::HtmlUnsafe(content) => ContentString::HtmlUnsafe(content), Self::Float(content) => ContentString::String(content.to_string().into()), Self::Int(content) => ContentString::String(content.to_string().into()), Self::Py(content) => return resolve_python(content, context), @@ -104,6 +109,10 @@ impl<'t, 'py> Content<'t, 'py> { Ok(left) => Some(left), Err(_) => None, }, + Self::HtmlUnsafe(left) => match left.parse::() { + Ok(left) => Some(left), + Err(_) => None, + }, Self::Float(left) => left.trunc().to_bigint(), Self::Py(left) => match left.extract::() { Ok(left) => Some(left), @@ -144,6 +153,10 @@ impl<'t, 'py> Content<'t, 'py> { let mark_safe = safestring.getattr(intern!(py, "mark_safe"))?; mark_safe.call1((string,))? } + Self::HtmlUnsafe(s) => s + .into_pyobject(py) + .expect("A string can always be converted to a Python str.") + .into_any(), }) } } From 41ed1a47ee71c66da782217fa8c5fa52dc5ec32a Mon Sep 17 00:00:00 2001 From: Lily Foote Date: Sun, 4 May 2025 22:46:39 +0100 Subject: [PATCH 4/6] Refactor Content::String to wrap ContentString This reduces some boilerplate when it doesn't matter which exact `ContentString` variant is present. --- src/render/common.rs | 10 ++--- src/render/filters.rs | 88 ++++++++++++++++++++++--------------------- src/render/tags.rs | 71 ++++++++++------------------------ src/render/types.rs | 80 ++++++++++++++++++++------------------- 4 files changed, 112 insertions(+), 137 deletions(-) diff --git a/src/render/common.rs b/src/render/common.rs index 141b50c5..4b6667a5 100644 --- a/src/render/common.rs +++ b/src/render/common.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use pyo3::prelude::*; -use super::types::{Content, Context}; +use super::types::{Content, ContentString, Context}; use super::{Evaluate, Render, RenderResult, Resolve, ResolveFailures, ResolveResult}; use crate::error::RenderError; use crate::parse::{TagElement, TokenTree}; @@ -73,10 +73,10 @@ impl Resolve for Text { _failures: ResolveFailures, ) -> ResolveResult<'t, 'py> { let resolved = Cow::Borrowed(template.content(self.at)); - Ok(Some(match context.autoescape { - false => Content::String(resolved), - true => Content::HtmlSafe(resolved), - })) + Ok(Some(Content::String(match context.autoescape { + false => ContentString::String(resolved), + true => ContentString::HtmlSafe(resolved), + }))) } } diff --git a/src/render/filters.rs b/src/render/filters.rs index a070b0b6..bb00e0e1 100644 --- a/src/render/filters.rs +++ b/src/render/filters.rs @@ -11,7 +11,7 @@ use crate::filters::{ FilterType, LowerFilter, SafeFilter, SlugifyFilter, UpperFilter, }; use crate::parse::Filter; -use crate::render::types::{Content, Context}; +use crate::render::types::{Content, ContentString, Context}; use crate::render::{Resolve, ResolveFailures, ResolveResult}; use crate::types::TemplateString; use regex::Regex; @@ -43,13 +43,13 @@ where 'a: 't, { fn as_content(&'a self) -> Option> { - Some(Content::String(Cow::Borrowed(self))) + Some(Content::String(ContentString::String(Cow::Borrowed(self)))) } } impl<'t, 'py> IntoOwnedContent<'t, 'py> for String { fn into_content(self) -> Option> { - Some(Content::String(Cow::Owned(self))) + Some(Content::String(ContentString::String(Cow::Owned(self)))) } } @@ -190,25 +190,27 @@ impl ResolveFilter for EscapeFilter { _template: TemplateString<'t>, _context: &mut Context, ) -> ResolveResult<'t, 'py> { - Ok(match variable { - Some(content) => match content { - Content::HtmlSafe(content) => Some(Content::HtmlSafe(content)), - Content::String(content) | Content::HtmlUnsafe(content) => { - let mut encoded = String::new(); - encode_quoted_attribute_to_string(&content, &mut encoded); - Some(Content::HtmlSafe(Cow::Owned(encoded))) - } - Content::Int(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), - Content::Float(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), - Content::Py(object) => { - let content = object.str()?.extract::()?; - let mut encoded = String::new(); - encode_quoted_attribute_to_string(&content, &mut encoded); - Some(Content::HtmlSafe(Cow::Owned(encoded))) - } + Ok(Some(Content::String(ContentString::HtmlSafe( + match variable { + Some(content) => match content { + Content::String(ContentString::HtmlSafe(content)) => content, + Content::String(content) => { + let mut encoded = String::new(); + encode_quoted_attribute_to_string(content.as_raw(), &mut encoded); + Cow::Owned(encoded) + } + Content::Int(n) => Cow::Owned(n.to_string()), + Content::Float(n) => Cow::Owned(n.to_string()), + Content::Py(object) => { + let content = object.str()?.extract::()?; + let mut encoded = String::new(); + encode_quoted_attribute_to_string(&content, &mut encoded); + Cow::Owned(encoded) + } + }, + None => Cow::Borrowed(""), }, - None => Some(Content::HtmlSafe(Cow::Borrowed(""))), - }) + )))) } } @@ -261,22 +263,20 @@ impl ResolveFilter for SafeFilter { _template: TemplateString<'t>, _context: &mut Context, ) -> ResolveResult<'t, 'py> { - let content = match variable { - Some(content) => match content { - Content::HtmlSafe(content) => Some(Content::HtmlSafe(content)), - Content::String(content) | Content::HtmlUnsafe(content) => { - Some(Content::HtmlSafe(content)) - } - Content::Int(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), - Content::Float(n) => Some(Content::HtmlSafe(Cow::Owned(n.to_string()))), - Content::Py(object) => { - let content = object.str()?.extract::()?; - Some(Content::HtmlSafe(Cow::Owned(content))) - } + Ok(Some(Content::String(ContentString::HtmlSafe( + match variable { + Some(content) => match content { + Content::String(content) => content.into_raw(), + Content::Int(n) => Cow::Owned(n.to_string()), + Content::Float(n) => Cow::Owned(n.to_string()), + Content::Py(object) => { + let content = object.str()?.extract::()?; + Cow::Owned(content) + } + }, + None => Cow::Borrowed(""), }, - None => Some(Content::HtmlSafe(Cow::Borrowed(""))), - }; - Ok(content) + )))) } } @@ -309,16 +309,18 @@ impl ResolveFilter for SlugifyFilter { #[allow(non_snake_case)] let SafeData = SAFEDATA.import(py, "django.utils.safestring", "SafeData")?; match content.is_instance(SafeData)? { - true => Some(Content::HtmlSafe(slug)), - false => Some(Content::String(slug)), + true => Some(Content::String(ContentString::HtmlSafe(slug))), + false => Some(Content::String(ContentString::HtmlUnsafe(slug))), } } // Int and Float requires no slugify, we only need to turn it into a string. - Content::Int(content) => Some(Content::String(Cow::Owned(content.to_string()))), - Content::Float(content) => Some(Content::String(Cow::Owned(content.to_string()))), - Content::String(content) => Some(Content::String(slugify(content))), - Content::HtmlSafe(content) => Some(Content::HtmlSafe(slugify(content))), - Content::HtmlUnsafe(content) => Some(Content::HtmlUnsafe(slugify(content))), + Content::Int(content) => Some(Content::String(ContentString::String(Cow::Owned( + content.to_string(), + )))), + Content::Float(content) => Some(Content::String(ContentString::String( + Cow::Owned(content.to_string()), + ))), + Content::String(content) => Some(content.map_content(slugify)), }, None => "".as_content(), }; diff --git a/src/render/tags.rs b/src/render/tags.rs index 139921a7..195b64ec 100644 --- a/src/render/tags.rs +++ b/src/render/tags.rs @@ -6,7 +6,7 @@ use pyo3::exceptions::PyAttributeError; use pyo3::prelude::*; use pyo3::types::{PyBool, PyDict, PyList, PyNone}; -use super::types::{Content, Context}; +use super::types::{Content, ContentString, Context}; use super::{Evaluate, Render, RenderResult, Resolve, ResolveFailures, ResolveResult}; use crate::error::PyRenderError; use crate::parse::{IfCondition, Tag, Url}; @@ -45,7 +45,7 @@ impl Resolve for Url { ) -> ResolveResult<'t, 'py> { let view_name = match self.view_name.resolve(py, template, context, failures)? { Some(view_name) => view_name, - None => Content::String(Cow::Borrowed("")), + None => Content::String(ContentString::String(Cow::Borrowed(""))), }; let urls = py.import("django.urls")?; let reverse = urls.getattr("reverse")?; @@ -92,9 +92,7 @@ impl Evaluate for Content<'_, '_> { ) -> Option { Some(match self { Self::Py(obj) => obj.is_truthy().unwrap_or(false), - Self::String(s) => !s.is_empty(), - Self::HtmlSafe(s) => !s.is_empty(), - Self::HtmlUnsafe(s) => !s.is_empty(), + Self::String(s) => !s.as_raw().is_empty(), Self::Float(f) => *f != 0.0, Self::Int(n) => *n != BigInt::ZERO, }) @@ -123,12 +121,10 @@ impl PyCmp> for Content<'_, '_> { (Self::Py(obj), Content::Py(other)) => obj.eq(other).unwrap_or(false), (Self::Py(obj), Content::Float(other)) => obj.eq(other).unwrap_or(false), (Self::Py(obj), Content::Int(other)) => obj.eq(other).unwrap_or(false), - (Self::Py(obj), Content::String(other)) => obj.eq(other).unwrap_or(false), - (Self::Py(obj), Content::HtmlSafe(other)) => obj.eq(other).unwrap_or(false), + (Self::Py(obj), Content::String(other)) => obj.eq(other.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Py(other)) => other.eq(obj).unwrap_or(false), (Self::Int(obj), Content::Py(other)) => other.eq(obj).unwrap_or(false), - (Self::String(obj), Content::Py(other)) => other.eq(obj).unwrap_or(false), - (Self::HtmlSafe(obj), Content::Py(other)) => other.eq(obj).unwrap_or(false), + (Self::String(obj), Content::Py(other)) => other.eq(obj.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Float(other)) => obj == other, (Self::Int(obj), Content::Int(other)) => obj == other, (Self::Float(obj), Content::Int(other)) => { @@ -145,10 +141,7 @@ impl PyCmp> for Content<'_, '_> { obj => obj == *other, } } - (Self::String(obj), Content::String(other)) => obj == other, - (Self::HtmlSafe(obj), Content::HtmlSafe(other)) => obj == other, - (Self::String(obj), Content::HtmlSafe(other)) => obj == other, - (Self::HtmlSafe(obj), Content::String(other)) => obj == other, + (Self::String(obj), Content::String(other)) => obj.as_raw() == other.as_raw(), _ => false, } } @@ -158,12 +151,10 @@ impl PyCmp> for Content<'_, '_> { (Self::Py(obj), Content::Py(other)) => obj.lt(other).unwrap_or(false), (Self::Py(obj), Content::Float(other)) => obj.lt(other).unwrap_or(false), (Self::Py(obj), Content::Int(other)) => obj.lt(other).unwrap_or(false), - (Self::Py(obj), Content::String(other)) => obj.lt(other).unwrap_or(false), - (Self::Py(obj), Content::HtmlSafe(other)) => obj.lt(other).unwrap_or(false), + (Self::Py(obj), Content::String(other)) => obj.lt(other.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Py(other)) => other.gt(obj).unwrap_or(false), (Self::Int(obj), Content::Py(other)) => other.gt(obj).unwrap_or(false), - (Self::String(obj), Content::Py(other)) => other.gt(obj).unwrap_or(false), - (Self::HtmlSafe(obj), Content::Py(other)) => other.gt(obj).unwrap_or(false), + (Self::String(obj), Content::Py(other)) => other.gt(obj.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Float(other)) => obj < other, (Self::Int(obj), Content::Int(other)) => obj < other, (Self::Float(obj), Content::Int(other)) => { @@ -180,10 +171,7 @@ impl PyCmp> for Content<'_, '_> { obj => obj < *other, } } - (Self::String(obj), Content::String(other)) => obj < other, - (Self::HtmlSafe(obj), Content::HtmlSafe(other)) => obj < other, - (Self::String(obj), Content::HtmlSafe(other)) => obj < other, - (Self::HtmlSafe(obj), Content::String(other)) => obj < other, + (Self::String(obj), Content::String(other)) => obj.as_raw() < other.as_raw(), _ => false, } } @@ -193,12 +181,10 @@ impl PyCmp> for Content<'_, '_> { (Self::Py(obj), Content::Py(other)) => obj.gt(other).unwrap_or(false), (Self::Py(obj), Content::Float(other)) => obj.gt(other).unwrap_or(false), (Self::Py(obj), Content::Int(other)) => obj.gt(other).unwrap_or(false), - (Self::Py(obj), Content::String(other)) => obj.gt(other).unwrap_or(false), - (Self::Py(obj), Content::HtmlSafe(other)) => obj.gt(other).unwrap_or(false), + (Self::Py(obj), Content::String(other)) => obj.gt(other.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Py(other)) => other.lt(obj).unwrap_or(false), (Self::Int(obj), Content::Py(other)) => other.lt(obj).unwrap_or(false), - (Self::String(obj), Content::Py(other)) => other.lt(obj).unwrap_or(false), - (Self::HtmlSafe(obj), Content::Py(other)) => other.lt(obj).unwrap_or(false), + (Self::String(obj), Content::Py(other)) => other.lt(obj.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Float(other)) => obj > other, (Self::Int(obj), Content::Int(other)) => obj > other, (Self::Float(obj), Content::Int(other)) => { @@ -215,10 +201,7 @@ impl PyCmp> for Content<'_, '_> { obj => obj > *other, } } - (Self::String(obj), Content::String(other)) => obj > other, - (Self::HtmlSafe(obj), Content::HtmlSafe(other)) => obj > other, - (Self::String(obj), Content::HtmlSafe(other)) => obj > other, - (Self::HtmlSafe(obj), Content::String(other)) => obj > other, + (Self::String(obj), Content::String(other)) => obj.as_raw() > other.as_raw(), _ => false, } } @@ -228,12 +211,10 @@ impl PyCmp> for Content<'_, '_> { (Self::Py(obj), Content::Py(other)) => obj.le(other).unwrap_or(false), (Self::Py(obj), Content::Float(other)) => obj.le(other).unwrap_or(false), (Self::Py(obj), Content::Int(other)) => obj.le(other).unwrap_or(false), - (Self::Py(obj), Content::String(other)) => obj.le(other).unwrap_or(false), - (Self::Py(obj), Content::HtmlSafe(other)) => obj.le(other).unwrap_or(false), + (Self::Py(obj), Content::String(other)) => obj.le(other.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Py(other)) => other.ge(obj).unwrap_or(false), (Self::Int(obj), Content::Py(other)) => other.ge(obj).unwrap_or(false), - (Self::String(obj), Content::Py(other)) => other.ge(obj).unwrap_or(false), - (Self::HtmlSafe(obj), Content::Py(other)) => other.ge(obj).unwrap_or(false), + (Self::String(obj), Content::Py(other)) => other.ge(obj.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Float(other)) => obj <= other, (Self::Int(obj), Content::Int(other)) => obj <= other, (Self::Float(obj), Content::Int(other)) => { @@ -250,10 +231,7 @@ impl PyCmp> for Content<'_, '_> { obj => obj <= *other, } } - (Self::String(obj), Content::String(other)) => obj <= other, - (Self::HtmlSafe(obj), Content::HtmlSafe(other)) => obj <= other, - (Self::String(obj), Content::HtmlSafe(other)) => obj <= other, - (Self::HtmlSafe(obj), Content::String(other)) => obj <= other, + (Self::String(obj), Content::String(other)) => obj.as_raw() <= other.as_raw(), _ => false, } } @@ -263,12 +241,10 @@ impl PyCmp> for Content<'_, '_> { (Self::Py(obj), Content::Py(other)) => obj.ge(other).unwrap_or(false), (Self::Py(obj), Content::Float(other)) => obj.ge(other).unwrap_or(false), (Self::Py(obj), Content::Int(other)) => obj.ge(other).unwrap_or(false), - (Self::Py(obj), Content::String(other)) => obj.ge(other).unwrap_or(false), - (Self::Py(obj), Content::HtmlSafe(other)) => obj.ge(other).unwrap_or(false), + (Self::Py(obj), Content::String(other)) => obj.ge(other.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Py(other)) => other.le(obj).unwrap_or(false), (Self::Int(obj), Content::Py(other)) => other.le(obj).unwrap_or(false), - (Self::String(obj), Content::Py(other)) => other.le(obj).unwrap_or(false), - (Self::HtmlSafe(obj), Content::Py(other)) => other.le(obj).unwrap_or(false), + (Self::String(obj), Content::Py(other)) => other.le(obj.as_raw()).unwrap_or(false), (Self::Float(obj), Content::Float(other)) => obj >= other, (Self::Int(obj), Content::Int(other)) => obj >= other, (Self::Float(obj), Content::Int(other)) => { @@ -285,10 +261,7 @@ impl PyCmp> for Content<'_, '_> { obj => obj >= *other, } } - (Self::String(obj), Content::String(other)) => obj >= other, - (Self::HtmlSafe(obj), Content::HtmlSafe(other)) => obj >= other, - (Self::String(obj), Content::HtmlSafe(other)) => obj >= other, - (Self::HtmlSafe(obj), Content::String(other)) => obj >= other, + (Self::String(obj), Content::String(other)) => obj.as_raw() >= other.as_raw(), _ => false, } } @@ -447,12 +420,8 @@ impl Contains>> for Content<'_, '_> { let obj = self.to_py(other.py()).ok()?; obj.contains(other).ok() } - Some(Content::String(other)) - | Some(Content::HtmlSafe(other)) - | Some(Content::HtmlUnsafe(other)) => match self { - Self::String(obj) | Self::HtmlSafe(obj) | Self::HtmlUnsafe(obj) => { - Some(obj.contains(other.as_ref())) - } + Some(Content::String(other)) => match self { + Self::String(obj) => Some(obj.as_raw().contains(other.as_raw().as_ref())), Self::Int(_) | Self::Float(_) => None, Self::Py(obj) => obj.contains(other).ok(), }, diff --git a/src/render/types.rs b/src/render/types.rs index 25414712..fcde2d29 100644 --- a/src/render/types.rs +++ b/src/render/types.rs @@ -16,7 +16,7 @@ pub struct Context { pub autoescape: bool, } -#[derive(Debug)] +#[derive(Debug, IntoPyObject)] pub enum ContentString<'t> { String(Cow<'t, str>), HtmlSafe(Cow<'t, str>), @@ -33,13 +33,29 @@ impl<'t, 'py> ContentString<'t> { } } - pub fn map_content(self, f: impl FnOnce(Cow<'t, str>) -> Cow<'t, str>) -> Content<'t, 'py> { + pub fn as_raw(&self) -> &Cow<'t, str> { match self { - Self::String(content) => Content::String(f(content)), - Self::HtmlSafe(content) => Content::HtmlSafe(f(content)), - Self::HtmlUnsafe(content) => Content::HtmlUnsafe(f(content)), + Self::String(content) => content, + Self::HtmlSafe(content) => content, + Self::HtmlUnsafe(content) => content, } } + + pub fn into_raw(self) -> Cow<'t, str> { + match self { + Self::String(content) => content, + Self::HtmlSafe(content) => content, + Self::HtmlUnsafe(content) => content, + } + } + + pub fn map_content(self, f: impl FnOnce(Cow<'t, str>) -> Cow<'t, str>) -> Content<'t, 'py> { + Content::String(match self { + Self::String(content) => Self::String(f(content)), + Self::HtmlSafe(content) => Self::HtmlSafe(f(content)), + Self::HtmlUnsafe(content) => Self::HtmlUnsafe(f(content)), + }) + } } fn resolve_python<'t>(value: Bound<'_, PyAny>, context: &Context) -> PyResult> { @@ -68,9 +84,7 @@ fn resolve_python<'t>(value: Bound<'_, PyAny>, context: &Context) -> PyResult { Py(Bound<'py, PyAny>), - String(Cow<'t, str>), - HtmlSafe(Cow<'t, str>), - HtmlUnsafe(Cow<'t, str>), + String(ContentString<'t>), Float(f64), Int(BigInt), } @@ -79,9 +93,7 @@ impl<'t, 'py> Content<'t, 'py> { pub fn render(self, context: &Context) -> PyResult> { Ok(match self { Self::Py(content) => resolve_python(content, context)?.content(), - Self::String(content) => content, - Self::HtmlSafe(content) => content, - Self::HtmlUnsafe(content) => Cow::Owned(encode_quoted_attribute(&content).to_string()), + Self::String(content) => content.content(), Self::Float(content) => content.to_string().into(), Self::Int(content) => content.to_string().into(), }) @@ -89,9 +101,7 @@ impl<'t, 'py> Content<'t, 'py> { pub fn resolve_string(self, context: &Context) -> PyResult> { Ok(match self { - Self::String(content) => ContentString::String(content), - Self::HtmlSafe(content) => ContentString::HtmlSafe(content), - Self::HtmlUnsafe(content) => ContentString::HtmlUnsafe(content), + Self::String(content) => content, Self::Float(content) => ContentString::String(content.to_string().into()), Self::Int(content) => ContentString::String(content.to_string().into()), Self::Py(content) => return resolve_python(content, context), @@ -101,15 +111,7 @@ impl<'t, 'py> Content<'t, 'py> { pub fn to_bigint(&self) -> Option { match self { Self::Int(left) => Some(left.clone()), - Self::String(left) => match left.parse::() { - Ok(left) => Some(left), - Err(_) => None, - }, - Self::HtmlSafe(left) => match left.parse::() { - Ok(left) => Some(left), - Err(_) => None, - }, - Self::HtmlUnsafe(left) => match left.parse::() { + Self::String(left) => match left.as_raw().parse::() { Ok(left) => Some(left), Err(_) => None, }, @@ -141,22 +143,24 @@ impl<'t, 'py> Content<'t, 'py> { .into_pyobject(py) .expect("An f64 can always be converted to a Python float.") .into_any(), - Self::String(s) => s - .into_pyobject(py) - .expect("A string can always be converted to a Python str.") - .into_any(), - Self::HtmlSafe(s) => { - let string = s + Self::String(s) => match s { + ContentString::String(s) => 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,))? - } - Self::HtmlUnsafe(s) => s - .into_pyobject(py) - .expect("A string can always be converted to a Python str.") - .into_any(), + .expect("A string can always be converted to a Python str.") + .into_any(), + ContentString::HtmlUnsafe(s) => s + .into_pyobject(py) + .expect("A string can always be converted to a Python str.") + .into_any(), + ContentString::HtmlSafe(s) => { + 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,))? + } + }, }) } } From 0577760e6e7a892fcbb1dade086b51503ad9f467 Mon Sep 17 00:00:00 2001 From: Lily Foote Date: Sun, 4 May 2025 23:11:47 +0100 Subject: [PATCH 5/6] Fix usage of ContentString for TranslatedText --- src/render/common.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/render/common.rs b/src/render/common.rs index dd93f5ea..089f070d 100644 --- a/src/render/common.rs +++ b/src/render/common.rs @@ -93,10 +93,10 @@ impl Resolve for TranslatedText { let django_translation = py.import("django.utils.translation")?; let get_text = django_translation.getattr("gettext")?; let resolved = get_text.call1((resolved,))?.extract::()?; - Ok(Some(match context.autoescape { - false => Content::String(Cow::Owned(resolved)), - true => Content::HtmlSafe(Cow::Owned(resolved)), - })) + Ok(Some(Content::String(match context.autoescape { + false => ContentString::String(Cow::Owned(resolved)), + true => ContentString::HtmlSafe(Cow::Owned(resolved)), + }))) } } From 3b89e914d1b94f2bf4e044dc744601f9926defdd Mon Sep 17 00:00:00 2001 From: Lily Foote Date: Mon, 5 May 2025 00:32:02 +0100 Subject: [PATCH 6/6] Test more edge cases for the upper filter --- tests/filters/test_upper.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/filters/test_upper.py b/tests/filters/test_upper.py index 90fc436a..9cb4cc08 100644 --- a/tests/filters/test_upper.py +++ b/tests/filters/test_upper.py @@ -12,6 +12,7 @@ def test_upper_string(): assert django_template.render({"var": var}) == uppered assert rust_template.render({"var": var}) == uppered + def test_upper_undefined(): template = "{{ var|upper }}" django_template = engines["django"].from_string(template) @@ -20,6 +21,7 @@ def test_upper_undefined(): assert django_template.render() == "" assert rust_template.render() == "" + def test_upper_integer(): template = "{{ var|upper }}" django_template = engines["django"].from_string(template) @@ -30,6 +32,7 @@ def test_upper_integer(): assert django_template.render({"var": var}) == uppered assert rust_template.render({"var": var}) == uppered + def test_upper_with_argument(): template = "{{ var|upper:arg }}" @@ -51,6 +54,7 @@ def test_upper_with_argument(): """ assert str(exc_info.value) == expected + def test_upper_unicode(): template = "{{ var|upper }}" django_template = engines["django"].from_string(template) @@ -71,3 +75,36 @@ def test_upper_html(): uppered = "<B>FOO</B>" assert django_template.render({"var": var}) == uppered assert rust_template.render({"var": var}) == uppered + + +def test_upper_html_safe(): + template = "{{ var|upper|safe }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "foo" + uppered = "FOO" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered + + +def test_upper_add_strings(): + template = "{{ var|upper|add:'bar' }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "foo" + uppered = "FOObar" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered + + +def test_upper_add_numbers(): + template = "{{ var|upper|add:4 }}" + django_template = engines["django"].from_string(template) + rust_template = engines["rusty"].from_string(template) + + var = "2" + uppered = "6" + assert django_template.render({"var": var}) == uppered + assert rust_template.render({"var": var}) == uppered