Skip to content

Commit 1ca4395

Browse files
Add support for Fluent message attributes
Previously, Django templates couldn't access Fluent message attributes. For example, `{% translation 'welcome-message.title' %}` would look for a message with ID "welcome-message.title" instead of the "title" attribute on the "welcome-message" message, causing a lookup failure. Now, `get_translation()` detects dot notation and correctly resolves attributes, enabling Django templates to access multiple text variations on a single message (e.g., placeholders, aria-labels, tooltips).
1 parent dfb8433 commit 1ca4395

File tree

4 files changed

+133
-7
lines changed

4 files changed

+133
-7
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
- Add support for Fluent message attributes via dot notation (e.g., `bundle.get_translation("message.attribute")`).
11+
1012
## [0.1.0a8] - 2025-10-01
1113

1214
- Annotate `ftl` file source code when reporting parse errors to allow ergonomic debugging.

src/lib.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,37 @@ mod rustfluent {
7777
) -> PyResult<String> {
7878
self.bundle.set_use_isolating(use_isolating);
7979

80-
let msg = self
81-
.bundle
82-
.get_message(identifier)
83-
.ok_or_else(|| (PyValueError::new_err(format!("{identifier} not found"))))?;
80+
let pattern = if identifier.contains('.') {
81+
let parts: Vec<&str> = identifier.splitn(2, '.').collect();
82+
let message_id = parts[0];
83+
let attribute_name = parts[1];
84+
85+
let msg = self
86+
.bundle
87+
.get_message(message_id)
88+
.ok_or_else(|| PyValueError::new_err(format!("{message_id} not found")))?;
89+
90+
let attribute = msg.get_attribute(attribute_name).ok_or_else(|| {
91+
PyValueError::new_err(format!(
92+
"{identifier} - Attribute '{attribute_name}' not found on message '{message_id}'."
93+
))
94+
})?;
8495

85-
let pattern = msg.value().ok_or_else(|| {
86-
PyValueError::new_err(format!("{identifier} - Message has no value.",))
87-
})?;
96+
// Get the pattern from the attribute
97+
// Note: attribute.value() returns &Pattern directly (not Option)
98+
// because attributes always have values, unlike messages which can
99+
// exist with only attributes (no main value)
100+
attribute.value()
101+
} else {
102+
let msg = self
103+
.bundle
104+
.get_message(identifier)
105+
.ok_or_else(|| PyValueError::new_err(format!("{identifier} not found")))?;
106+
107+
msg.value().ok_or_else(|| {
108+
PyValueError::new_err(format!("{identifier} - Message has no value.",))
109+
})?
110+
};
88111

89112
let mut args = FluentArgs::new();
90113

tests/data/attributes.ftl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Test file for Fluent attributes
2+
welcome-message = Welcome!
3+
.title = Welcome to our site
4+
.aria-label = Welcome greeting
5+
6+
login-input = Email
7+
.placeholder = [email protected]
8+
.aria-label = Login input value
9+
.title = Type your login email
10+
11+
# Message with variables in attributes
12+
greeting = Hello
13+
.formal = Hello, { $name }
14+
.informal = Hi { $name }!
15+
16+
# Message with only attributes (no value)
17+
form-button =
18+
.submit = Submit Form
19+
.cancel = Cancel
20+
.reset = Reset Form

tests/test_python_interface.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,84 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode():
207207

208208
def test_parser_error_str():
209209
assert str(fluent.ParserError) == "<class 'rustfluent.ParserError'>"
210+
211+
212+
# Attribute access tests
213+
214+
215+
def test_basic_attribute_access():
216+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
217+
assert bundle.get_translation("welcome-message.title") == "Welcome to our site"
218+
219+
220+
def test_regular_message_still_works_with_attributes():
221+
"""Test that accessing the main message value still works when it has attributes."""
222+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
223+
assert bundle.get_translation("welcome-message") == "Welcome!"
224+
225+
226+
def test_multiple_attributes_on_same_message():
227+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
228+
assert bundle.get_translation("login-input.placeholder") == "[email protected]"
229+
assert bundle.get_translation("login-input.aria-label") == "Login input value"
230+
assert bundle.get_translation("login-input.title") == "Type your login email"
231+
232+
233+
def test_attribute_with_variables():
234+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
235+
result = bundle.get_translation("greeting.formal", variables={"name": "Alice"})
236+
assert result == f"Hello, {BIDI_OPEN}Alice{BIDI_CLOSE}"
237+
238+
239+
def test_attribute_with_variables_use_isolating_off():
240+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
241+
result = bundle.get_translation(
242+
"greeting.informal",
243+
variables={"name": "Bob"},
244+
use_isolating=False,
245+
)
246+
assert result == "Hi Bob!"
247+
248+
249+
def test_attribute_on_message_without_main_value():
250+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
251+
assert bundle.get_translation("form-button.submit") == "Submit Form"
252+
assert bundle.get_translation("form-button.cancel") == "Cancel"
253+
assert bundle.get_translation("form-button.reset") == "Reset Form"
254+
255+
256+
def test_message_without_value_raises_error():
257+
"""Test that accessing a message without a value (only attributes) raises an error."""
258+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
259+
with pytest.raises(ValueError, match="form-button - Message has no value"):
260+
bundle.get_translation("form-button")
261+
262+
263+
def test_missing_message_with_attribute_syntax_raises_error():
264+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
265+
with pytest.raises(ValueError, match="nonexistent not found"):
266+
bundle.get_translation("nonexistent.title")
267+
268+
269+
def test_missing_attribute_raises_error():
270+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
271+
with pytest.raises(
272+
ValueError,
273+
match="welcome-message.nonexistent - Attribute 'nonexistent' not found on message 'welcome-message'",
274+
):
275+
bundle.get_translation("welcome-message.nonexistent")
276+
277+
278+
@pytest.mark.parametrize(
279+
"identifier,expected",
280+
(
281+
("welcome-message", "Welcome!"),
282+
("welcome-message.title", "Welcome to our site"),
283+
("welcome-message.aria-label", "Welcome greeting"),
284+
("login-input", "Email"),
285+
("login-input.placeholder", "[email protected]"),
286+
),
287+
)
288+
def test_attribute_and_message_access_parameterized(identifier, expected):
289+
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
290+
assert bundle.get_translation(identifier) == expected

0 commit comments

Comments
 (0)