Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- Add support for Fluent message attributes via dot notation (e.g., `bundle.get_translation("message.attribute")`).

## [0.1.0a8] - 2025-10-01

- Annotate `ftl` file source code when reporting parse errors to allow ergonomic debugging.
Expand Down
29 changes: 22 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,29 @@ mod rustfluent {
) -> PyResult<String> {
self.bundle.set_use_isolating(use_isolating);

let msg = self
.bundle
.get_message(identifier)
.ok_or_else(|| (PyValueError::new_err(format!("{identifier} not found"))))?;
let get_message = |id: &str| {
self.bundle
.get_message(id)
.ok_or_else(|| PyValueError::new_err(format!("{id} not found")))
};

let pattern = msg.value().ok_or_else(|| {
PyValueError::new_err(format!("{identifier} - Message has no value.",))
})?;
let pattern = match identifier.split_once('.') {
Some((message_id, attribute_id)) => get_message(message_id)?
.get_attribute(attribute_id)
.ok_or_else(|| {
PyValueError::new_err(format!(
"{identifier} - Attribute '{attribute_id}' not found on message '{message_id}'."
))
})?
.value(),
// Note: attribute.value() returns &Pattern directly (not Option)
// because attributes always have values, unlike messages
None => get_message(identifier)?
.value()
.ok_or_else(|| {
PyValueError::new_err(format!("{identifier} - Message has no value."))
})?
};

let mut args = FluentArgs::new();

Expand Down
20 changes: 20 additions & 0 deletions tests/data/attributes.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Test file for Fluent attributes
welcome-message = Welcome!
.title = Welcome to our site
.aria-label = Welcome greeting

login-input = Email
.placeholder = email@example.com
.aria-label = Login input value
.title = Type your login email

# Message with variables in attributes
greeting = Hello
.formal = Hello, { $name }
.informal = Hi { $name }!

# Message with only attributes (no value)
form-button =
.submit = Submit Form
.cancel = Cancel
.reset = Reset Form
81 changes: 81 additions & 0 deletions tests/test_python_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,84 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode():

def test_parser_error_str():
assert str(fluent.ParserError) == "<class 'rustfluent.ParserError'>"


# Attribute access tests


def test_basic_attribute_access():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
assert bundle.get_translation("welcome-message.title") == "Welcome to our site"


def test_regular_message_still_works_with_attributes():
"""Test that accessing the main message value still works when it has attributes."""
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
assert bundle.get_translation("welcome-message") == "Welcome!"


def test_multiple_attributes_on_same_message():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
assert bundle.get_translation("login-input.placeholder") == "email@example.com"
assert bundle.get_translation("login-input.aria-label") == "Login input value"
assert bundle.get_translation("login-input.title") == "Type your login email"


def test_attribute_with_variables():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
result = bundle.get_translation("greeting.formal", variables={"name": "Alice"})
assert result == f"Hello, {BIDI_OPEN}Alice{BIDI_CLOSE}"


def test_attribute_with_variables_use_isolating_off():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
result = bundle.get_translation(
"greeting.informal",
variables={"name": "Bob"},
use_isolating=False,
)
assert result == "Hi Bob!"


def test_attribute_on_message_without_main_value():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
assert bundle.get_translation("form-button.submit") == "Submit Form"
assert bundle.get_translation("form-button.cancel") == "Cancel"
assert bundle.get_translation("form-button.reset") == "Reset Form"


def test_message_without_value_raises_error():
"""Test that accessing a message without a value (only attributes) raises an error."""
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
with pytest.raises(ValueError, match="form-button - Message has no value"):
bundle.get_translation("form-button")


def test_missing_message_with_attribute_syntax_raises_error():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
with pytest.raises(ValueError, match="nonexistent not found"):
bundle.get_translation("nonexistent.title")


def test_missing_attribute_raises_error():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
with pytest.raises(
ValueError,
match="welcome-message.nonexistent - Attribute 'nonexistent' not found on message 'welcome-message'",
):
bundle.get_translation("welcome-message.nonexistent")


@pytest.mark.parametrize(
"identifier,expected",
(
("welcome-message", "Welcome!"),
("welcome-message.title", "Welcome to our site"),
("welcome-message.aria-label", "Welcome greeting"),
("login-input", "Email"),
("login-input.placeholder", "email@example.com"),
),
)
def test_attribute_and_message_access_parameterized(identifier, expected):
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
assert bundle.get_translation(identifier) == expected