diff --git a/src/error.rs b/src/error.rs index b8427fa6..536e258c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -68,6 +68,12 @@ pub enum RenderError { #[label("{object}")] object_at: Option, }, + #[error("'{type_name}' object is not subscriptable")] + NotSubscriptable { + type_name: String, + #[label("filter applied here")] + at: SourceSpan, + }, } pub trait AnnotatePyErr { diff --git a/src/filters.rs b/src/filters.rs index b0fd885c..7fb8f8df 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use miette::SourceSpan; use pyo3::prelude::*; use crate::types::Argument; @@ -13,6 +14,7 @@ pub enum FilterType { Default(DefaultFilter), Escape(EscapeFilter), External(ExternalFilter), + First(FirstFilter), Lower(LowerFilter), Safe(SafeFilter), Slugify(SlugifyFilter), @@ -67,6 +69,17 @@ pub struct ExternalFilter { pub argument: Option, } +#[derive(Clone, Debug, PartialEq)] +pub struct FirstFilter { + pub at: SourceSpan, +} + +impl FirstFilter { + pub fn new(at: SourceSpan) -> Self { + Self { at } + } +} + impl ExternalFilter { pub fn new(filter: Py, argument: Option) -> Self { Self { diff --git a/src/parse.rs b/src/parse.rs index 54804616..d42c2ecc 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -16,6 +16,7 @@ use crate::filters::DefaultFilter; use crate::filters::EscapeFilter; use crate::filters::ExternalFilter; use crate::filters::FilterType; +use crate::filters::FirstFilter; use crate::filters::LowerFilter; use crate::filters::SafeFilter; use crate::filters::SlugifyFilter; @@ -125,6 +126,10 @@ impl Filter { Some(right) => return Err(unexpected_argument("escape", right)), None => FilterType::Escape(EscapeFilter), }, + "first" => match right { + Some(right) => return Err(unexpected_argument("first", right)), + None => FilterType::First(FirstFilter::new(at.into())), + }, "lower" => match right { Some(right) => return Err(unexpected_argument("lower", right)), None => FilterType::Lower(LowerFilter), diff --git a/src/render/filters.rs b/src/render/filters.rs index 3067699a..29d4a561 100644 --- a/src/render/filters.rs +++ b/src/render/filters.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use html_escape::encode_quoted_attribute_to_string; use num_bigint::{BigInt, ToBigInt}; use num_traits::ToPrimitive; +use pyo3::exceptions::PyIndexError; use pyo3::prelude::*; use pyo3::sync::GILOnceCell; use pyo3::types::PyType; @@ -11,7 +12,7 @@ use pyo3::types::PyType; use crate::error::RenderError; use crate::filters::{ AddFilter, AddSlashesFilter, CapfirstFilter, CenterFilter, DefaultFilter, EscapeFilter, - ExternalFilter, FilterType, LowerFilter, SafeFilter, SlugifyFilter, UpperFilter, + ExternalFilter, FilterType, FirstFilter, LowerFilter, SafeFilter, SlugifyFilter, UpperFilter, }; use crate::parse::Filter; use crate::render::types::{AsBorrowedContent, Content, ContentString, Context, IntoOwnedContent}; @@ -47,6 +48,7 @@ impl Resolve for Filter { FilterType::Default(filter) => filter.resolve(left, py, template, context), FilterType::Escape(filter) => filter.resolve(left, py, template, context), FilterType::External(filter) => filter.resolve(left, py, template, context), + FilterType::First(filter) => filter.resolve(left, py, template, context), 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), @@ -308,6 +310,89 @@ impl ResolveFilter for ExternalFilter { } } +impl ResolveFilter for FirstFilter { + 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) => content, + None => return Ok(Some("".as_content())), + }; + + match content { + Content::Py(obj) => { + // Try to get the first item using Python's indexing + // Django only catches IndexError, not TypeError + match obj.get_item(0) { + Ok(first) => Ok(Some(Content::Py(first))), + Err(e) if e.is_instance_of::(py) => Ok(Some("".as_content())), + Err(e) if e.is_instance_of::(py) => { + // Check if this is the standard "'type' object is not subscriptable" error + let error_msg = e.to_string(); + if error_msg.contains("is not subscriptable") { + // Standard not subscriptable error - provide better formatting + let type_name = obj + .get_type() + .name() + .map(|n| n.to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + Err(RenderError::NotSubscriptable { + type_name, + at: self.at, + } + .into()) + } else { + // Custom TypeError - preserve the original message + Err(e.into()) + } + } + Err(e) => Err(e.into()), + } + } + Content::String(s) => { + // For strings, get the first character + // Skip the empty string always present at the start after splitting + let mut chars = s.as_raw().split("").skip(1); + match chars.next() { + None => Ok(Some("".as_content())), + Some(c) => Ok(Some(c.to_string().into_content())), + } + } + Content::Int(_) => { + // Numbers are not sequences, should raise TypeError + // Match Django's behavior exactly + Err(RenderError::NotSubscriptable { + type_name: "int".to_string(), + at: self.at, + } + .into()) + } + Content::Float(_) => { + // Floats are not sequences, should raise TypeError + // Match Django's behavior exactly + Err(RenderError::NotSubscriptable { + type_name: "float".to_string(), + at: self.at, + } + .into()) + } + Content::Bool(_) => { + // Booleans are not sequences, should raise TypeError + // Match Django's behavior exactly + Err(RenderError::NotSubscriptable { + type_name: "bool".to_string(), + at: self.at, + } + .into()) + } + } + } +} + impl ResolveFilter for LowerFilter { fn resolve<'t, 'py>( &self, @@ -981,4 +1066,84 @@ mod tests { assert_eq!(rendered, ""); }) } + + #[test] + fn test_render_filter_first_list() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let engine = EngineData::empty(); + let template_string = "{{ items|first }}".to_string(); + let context = PyDict::new(py); + let items = vec!["a", "b", "c"]; + context.set_item("items", items).unwrap(); + let template = Template::new_from_string(py, template_string, &engine).unwrap(); + let result = template.render(py, Some(context), None).unwrap(); + + assert_eq!(result, "a"); + }) + } + + #[test] + fn test_render_filter_first_empty_list() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let engine = EngineData::empty(); + let template_string = "{{ items|first }}".to_string(); + let context = PyDict::new(py); + let items: Vec<&str> = vec![]; + context.set_item("items", items).unwrap(); + let template = Template::new_from_string(py, template_string, &engine).unwrap(); + let result = template.render(py, Some(context), None).unwrap(); + + assert_eq!(result, ""); + }) + } + + #[test] + fn test_render_filter_first_string() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let engine = EngineData::empty(); + let template_string = "{{ text|first }}".to_string(); + let context = PyDict::new(py); + context.set_item("text", "hello").unwrap(); + let template = Template::new_from_string(py, template_string, &engine).unwrap(); + let result = template.render(py, Some(context), None).unwrap(); + + assert_eq!(result, "h"); + }) + } + + #[test] + fn test_render_filter_first_empty_string() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let engine = EngineData::empty(); + let template_string = "{{ text|first }}".to_string(); + let context = PyDict::new(py); + context.set_item("text", "").unwrap(); + let template = Template::new_from_string(py, template_string, &engine).unwrap(); + let result = template.render(py, Some(context), None).unwrap(); + + assert_eq!(result, ""); + }) + } + + #[test] + fn test_render_filter_first_no_argument() { + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let engine = EngineData::empty(); + let template_string = "{{ items|first:'arg' }}".to_string(); + let error = Template::new_from_string(py, template_string, &engine).unwrap_err(); + + let error_string = format!("{error}"); + assert!(error_string.contains("first filter does not take an argument")); + }) + } } diff --git a/src/template.rs b/src/template.rs index d3dbc1af..3d1e2b8c 100644 --- a/src/template.rs +++ b/src/template.rs @@ -6,7 +6,7 @@ pub mod django_rusty_templates { use std::path::PathBuf; use encoding_rs::Encoding; - use pyo3::exceptions::{PyAttributeError, PyImportError, PyOverflowError, PyValueError}; + use pyo3::exceptions::{PyAttributeError, PyImportError, PyOverflowError, PyTypeError, PyValueError}; use pyo3::import_exception_bound; use pyo3::intern; use pyo3::prelude::*; @@ -77,6 +77,16 @@ pub mod django_rusty_templates { } } + impl WithSourceCode for PyTypeError { + fn with_source_code( + err: miette::Report, + source: impl miette::SourceCode + 'static, + ) -> PyErr { + let miette_err = err.with_source_code(source); + Self::new_err(format!("{miette_err:?}")) + } + } + pub struct EngineData { autoescape: bool, libraries: HashMap>, @@ -315,6 +325,12 @@ pub mod django_rusty_templates { Err(err) => { let err = err.try_into_render_error()?; match err { + RenderError::NotSubscriptable { .. } => { + return Err(PyTypeError::with_source_code( + err.into(), + self.template.clone(), + )); + } RenderError::VariableDoesNotExist { .. } | RenderError::ArgumentDoesNotExist { .. } => { return Err(VariableDoesNotExist::with_source_code( diff --git a/tests/filters/test_first.py b/tests/filters/test_first.py new file mode 100644 index 00000000..22b74861 --- /dev/null +++ b/tests/filters/test_first.py @@ -0,0 +1,276 @@ +import pytest +from django.template import engines +from django.template.exceptions import TemplateSyntaxError +from django.utils.safestring import mark_safe + + +def test_first_with_list(): + """Test first filter with a list.""" + template = "{{ items|first }}" + context = {"items": ["a", "b", "c"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "a" + assert rusty_template.render(context) == "a" + + +def test_first_with_list_falsy_value(): + """Test first filter returns falsy values correctly (Django's test_list).""" + template = "{{ items|first }}" + context = {"items": [0, 1, 2]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "0" + assert rusty_template.render(context) == "0" + + +def test_first_with_empty_list(): + """Test first filter with an empty list.""" + template = "{{ items|first }}" + context = {"items": []} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "" + assert rusty_template.render(context) == "" + + +def test_first_with_string(): + """Test first filter with a string.""" + template = "{{ text|first }}" + context = {"text": "hello"} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "h" + assert rusty_template.render(context) == "h" + + +def test_first_with_empty_string(): + """Test first filter with an empty string.""" + template = "{{ text|first }}" + context = {"text": ""} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "" + assert rusty_template.render(context) == "" + + +def test_first_with_none(): + """Test first filter with None value.""" + # Django's first filter raises TypeError when given None + # We match this behavior for 1:1 compatibility + template = "{{ items|first }}" + context = {"items": None} + + # Both should raise TypeError + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + # Check that error messages match + assert "'NoneType' object is not subscriptable" in str(django_exc.value) + assert "'NoneType' object is not subscriptable" in str(rusty_exc.value) + + +def test_first_with_tuple(): + """Test first filter with a tuple.""" + template = "{{ items|first }}" + context = {"items": (1, 2, 3)} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "1" + assert rusty_template.render(context) == "1" + + +def test_first_autoescape_on(): + """Test first filter with autoescape on (default).""" + template = "{{ items|first }}" + context = {"items": ["bold", "text"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "<b>bold</b>" + assert rusty_template.render(context) == "<b>bold</b>" + + +def test_first_escaping_with_safe(): + """Test first filter with both escaped and safe strings (Django's first01).""" + # Match Django's test exactly + template = "{{ a|first }} {{ b|first }}" + context = {"a": ["a&b", "x"], "b": [mark_safe("a&b"), "x"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "a&b a&b" + assert rusty_template.render(context) == "a&b a&b" + + +def test_first_autoescape_off(): + """Test first filter with autoescape off.""" + template = "{% autoescape off %}{{ items|first }}{% endautoescape %}" + context = {"items": ["bold", "text"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "bold" + assert rusty_template.render(context) == "bold" + + +def test_first_autoescape_off_with_safe(): + """Test with autoescape off for both escaped and safe strings (Django's first02).""" + # Match Django's test exactly + template = "{% autoescape off %}{{ a|first }} {{ b|first }}{% endautoescape %}" + context = {"a": ["a&b", "x"], "b": [mark_safe("a&b"), "x"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "a&b a&b" + assert rusty_template.render(context) == "a&b a&b" + + +def test_first_with_safe_string(): + """Test first filter with mark_safe string.""" + template = "{{ items|first }}" + context = {"items": [mark_safe("bold"), "text"]} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + # When the first item is marked safe, it should not be escaped + assert django_template.render(context) == "bold" + assert rusty_template.render(context) == "bold" + + +def test_first_no_argument(): + """Test that first filter doesn't accept arguments.""" + template = "{{ items|first:'arg' }}" + context = {"items": [1, 2, 3]} + + with pytest.raises(TemplateSyntaxError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TemplateSyntaxError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + # Check that both raise errors about arguments + assert "first requires 1 arguments, 2 provided" in str(django_exc.value) + assert "first filter does not take an argument" in str(rusty_exc.value) + + +def test_first_with_missing_variable(): + """Test first filter with missing variable.""" + template = "{{ missing|first }}" + context = {} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "" + assert rusty_template.render(context) == "" + + +def test_first_with_integer(): + """Test first filter with integer value.""" + # Django's first filter raises TypeError for integers + template = "{{ num|first }}" + context = {"num": 42} + + # Both should raise TypeError + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + # Check that error messages match + assert "'int' object is not subscriptable" in str(django_exc.value) + assert "'int' object is not subscriptable" in str(rusty_exc.value) + + +def test_first_with_float(): + """Test first filter with float value.""" + # Django's first filter raises TypeError for floats + template = "{{ num|first }}" + context = {"num": 42.5} + + # Both should raise TypeError + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + # Check that error messages match + assert "'float' object is not subscriptable" in str(django_exc.value) + assert "'float' object is not subscriptable" in str(rusty_exc.value) + + +def test_first_with_boolean(): + """Test first filter with boolean value.""" + template = "{{ value|first }}" + + # Test with True + context = {"value": True} + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + assert "'bool' object is not subscriptable" in str(django_exc.value) + assert "'bool' object is not subscriptable" in str(rusty_exc.value) + + # Test with False + context = {"value": False} + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + assert "'bool' object is not subscriptable" in str(django_exc.value) + assert "'bool' object is not subscriptable" in str(rusty_exc.value) + + +def test_first_with_custom_object_type_error(): + """Test first filter with custom object that raises TypeError on indexing.""" + + class NotSubscriptable: + def __getitem__(self, key): + raise TypeError("custom type error") + + template = "{{ obj|first }}" + context = {"obj": NotSubscriptable()} + + # Both should raise TypeError + with pytest.raises(TypeError) as django_exc: + engines["django"].from_string(template).render(context) + with pytest.raises(TypeError) as rusty_exc: + engines["rusty"].from_string(template).render(context) + + # Check that error messages match + assert "custom type error" in str(django_exc.value) + assert "custom type error" in str(rusty_exc.value) + + +def test_first_with_unicode_string(): + """Test first filter with unicode string.""" + template = "{{ text|first }}" + context = {"text": "🎉🎊🎈"} + + django_template = engines["django"].from_string(template) + rusty_template = engines["rusty"].from_string(template) + + assert django_template.render(context) == "🎉" + assert rusty_template.render(context) == "🎉"