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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- **Breaking:** Simplify `Bundle` constructor to accept a single file path instead of a list. Change `ftl_filenames` parameter to `ftl_filename`.
- Add support for Fluent message attributes via dot notation (e.g., `bundle.get_translation("message.attribute")`).

## [0.1.0a8] - 2025-10-01
Expand Down
28 changes: 9 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,7 @@ pip install rustfluent
import rustfluent

# First load a bundle
bundle = rustfluent.Bundle(
"en",
[
# Multiple FTL files can be specified. Entries in later
# files overwrite earlier ones.
"en.ftl",
],
)
bundle = rustfluent.Bundle("en", "en.ftl")

# Fetch a translation
assert bundle.get_translation("hello-world") == "Hello World"
Expand All @@ -61,25 +54,22 @@ import rustfluent

bundle = rustfluent.Bundle(
language="en-US",
ftl_files=[
"/path/to/messages.ftl",
pathlib.Path("/path/to/more/messages.ftl"),
],
ftl_filename="/path/to/messages.ftl", # Also accepts pathlib.Path
)
```

#### Parameters

| Name | Type | Description |
|-------------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `language` | `str` | [Unicode Language Identifier](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier) for the language. |
| `ftl_files` | `list[str | pathlib.Path]` | Full paths to the FTL files containing the translations. Entries in later files overwrite earlier ones. |
| `strict` | `bool`, optional | In strict mode, a `ParserError` will be raised if there are any errors in the file. In non-strict mode, invalid Fluent messages will be excluded from the Bundle. |
| Name | Type | Description |
|----------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `language` | `str` | [Unicode Language Identifier](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier) for the language. |
| `ftl_filename` | `str \| pathlib.Path` | Full path to the FTL file containing the translations. |
| `strict` | `bool`, optional | In strict mode, a `ParserError` will be raised if there are any errors in the file. In non-strict mode, invalid Fluent messages will be excluded from the Bundle. |

#### Raises

- `FileNotFoundError` if any of the FTL files could not be found.
- `rustfluent.ParserError` if any of the FTL files contain errors (strict mode only).
- `FileNotFoundError` if the FTL file could not be found.
- `rustfluent.ParserError` if the FTL file contains errors (strict mode only).

### `Bundle.get_translation`

Expand Down
44 changes: 21 additions & 23 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ mod rustfluent {
#[pymethods]
impl Bundle {
#[new]
#[pyo3(signature = (language, ftl_filenames, strict=false))]
fn new(language: &str, ftl_filenames: Vec<PathBuf>, strict: bool) -> PyResult<Self> {
#[pyo3(signature = (language, ftl_filename, strict=false))]
fn new(language: &str, ftl_filename: PathBuf, strict: bool) -> PyResult<Self> {
let langid: LanguageIdentifier = match language.parse() {
Ok(langid) => langid,
Err(_) => {
Expand All @@ -41,29 +41,27 @@ mod rustfluent {
};
let mut bundle = FluentBundle::new_concurrent(vec![langid]);

for file_path in ftl_filenames.iter() {
let contents = fs::read_to_string(file_path)
.map_err(|_| PyFileNotFoundError::new_err(file_path.clone()))?;
let contents = fs::read_to_string(&ftl_filename)
.map_err(|_| PyFileNotFoundError::new_err(ftl_filename.clone()))?;

let resource = match FluentResource::try_new(contents) {
Ok(resource) => resource,
Err((resource, errors)) if strict => {
let mut labels = Vec::with_capacity(errors.len());
for error in errors {
labels.push(LabeledSpan::at(error.pos, format!("{}", error.kind)))
}
let error = miette!(
labels = labels,
"Error when parsing {}",
file_path.to_string_lossy()
)
.with_source_code(resource.source().to_string());
return Err(ParserError::new_err(format!("{error:?}")));
let resource = match FluentResource::try_new(contents) {
Ok(resource) => resource,
Err((resource, errors)) if strict => {
let mut labels = Vec::with_capacity(errors.len());
for error in errors {
labels.push(LabeledSpan::at(error.pos, format!("{}", error.kind)))
}
Err((resource, _errors)) => resource,
};
bundle.add_resource_overriding(resource);
}
let error = miette!(
labels = labels,
"Error when parsing {}",
ftl_filename.to_string_lossy()
)
.with_source_code(resource.source().to_string());
return Err(ParserError::new_err(format!("{error:?}")));
}
Err((resource, _errors)) => resource,
};
bundle.add_resource_overriding(resource);

Ok(Self { bundle })
}
Expand Down
4 changes: 1 addition & 3 deletions src/rustfluent.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ from pathlib import Path
Variable = str | int | date

class Bundle:
def __init__(
self, language: str, ftl_filenames: list[str | Path], strict: bool = False
) -> None: ...
def __init__(self, language: str, ftl_filename: str | Path, strict: bool = False) -> None: ...
def get_translation(
self,
identifier: str,
Expand Down
64 changes: 26 additions & 38 deletions tests/test_python_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,33 @@


def test_en_basic():
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")
assert bundle.get_translation("hello-world") == "Hello World"


def test_en_basic_str_path():
bundle = fluent.Bundle("en", [str(data_dir / "en.ftl")])
bundle = fluent.Bundle("en", str(data_dir / "en.ftl"))
assert bundle.get_translation("hello-world") == "Hello World"


def test_en_basic_with_named_arguments():
bundle = fluent.Bundle(
language="en",
ftl_filenames=[data_dir / "en.ftl"],
ftl_filename=data_dir / "en.ftl",
)
assert bundle.get_translation("hello-world") == "Hello World"


def test_en_with_variables():
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")
assert (
bundle.get_translation("hello-user", variables={"user": "Bob"})
== f"Hello, {BIDI_OPEN}Bob{BIDI_CLOSE}"
)


def test_en_with_variables_use_isolating_off():
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")
assert (
bundle.get_translation(
"hello-user",
Expand All @@ -66,7 +66,7 @@ def test_en_with_variables_use_isolating_off():
),
)
def test_variables_of_different_types(description, identifier, variables, expected):
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")

result = bundle.get_translation(identifier, variables=variables)

Expand All @@ -75,7 +75,7 @@ def test_variables_of_different_types(description, identifier, variables, expect

def test_invalid_language():
with pytest.raises(ValueError) as exc_info:
fluent.Bundle("$", [])
fluent.Bundle("$", "")

assert str(exc_info.value) == "Invalid language: '$'"

Expand All @@ -89,7 +89,7 @@ def test_invalid_language():
),
)
def test_invalid_variable_keys_raise_type_error(key):
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")

with pytest.raises(TypeError, match="Variable key not a str, got"):
bundle.get_translation("hello-user", variables={key: "Bob"})
Expand All @@ -104,20 +104,20 @@ def test_invalid_variable_keys_raise_type_error(key):
),
)
def test_invalid_variable_values_use_key_instead(value):
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")

result = bundle.get_translation("hello-user", variables={"user": value})

assert result == f"Hello, {BIDI_OPEN}user{BIDI_CLOSE}"


def test_fr_basic():
bundle = fluent.Bundle("fr", [data_dir / "fr.ftl"])
bundle = fluent.Bundle("fr", data_dir / "fr.ftl")
assert bundle.get_translation("hello-world") == "Bonjour le monde!"


def test_fr_with_args():
bundle = fluent.Bundle("fr", [data_dir / "fr.ftl"])
bundle = fluent.Bundle("fr", data_dir / "fr.ftl")
assert (
bundle.get_translation("hello-user", variables={"user": "Bob"})
== f"Bonjour, {BIDI_OPEN}Bob{BIDI_CLOSE}!"
Expand All @@ -135,34 +135,22 @@ def test_fr_with_args():
),
)
def test_selector(number, expected):
bundle = fluent.Bundle("en", [data_dir / "en.ftl"])
bundle = fluent.Bundle("en", data_dir / "en.ftl")

result = bundle.get_translation("with-selector", variables={"number": number})

assert result == expected


def test_new_overwrites_old():
bundle = fluent.Bundle(
"en",
[data_dir / "fr.ftl", data_dir / "en_hello.ftl"],
)
assert bundle.get_translation("hello-world") == "Hello World"
assert (
bundle.get_translation("hello-user", variables={"user": "Bob"})
== f"Bonjour, {BIDI_OPEN}Bob{BIDI_CLOSE}!"
)


def test_id_not_found():
bundle = fluent.Bundle("fr", [data_dir / "fr.ftl"])
bundle = fluent.Bundle("fr", data_dir / "fr.ftl")
with pytest.raises(ValueError):
bundle.get_translation("missing", variables={"user": "Bob"})


def test_file_not_found():
with pytest.raises(FileNotFoundError):
fluent.Bundle("fr", [data_dir / "none.ftl"])
fluent.Bundle("fr", data_dir / "none.ftl")


@pytest.mark.parametrize("pass_strict_argument_explicitly", (True, False))
Expand All @@ -171,7 +159,7 @@ def test_parses_other_parts_of_file_that_contains_errors_in_non_strict_mode(
):
kwargs = dict(strict=False) if pass_strict_argument_explicitly else {}

bundle = fluent.Bundle("fr", [data_dir / "errors.ftl"], **kwargs)
bundle = fluent.Bundle("fr", data_dir / "errors.ftl", **kwargs)
translation = bundle.get_translation("valid-message")

assert translation == "I'm valid."
Expand All @@ -181,7 +169,7 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode():
filename = data_dir / "errors.ftl"

with pytest.raises(fluent.ParserError) as exc_info:
fluent.Bundle("fr", [filename], strict=True)
fluent.Bundle("fr", filename, strict=True)

message = str(exc_info.value)

Expand Down Expand Up @@ -213,31 +201,31 @@ def test_parser_error_str():


def test_basic_attribute_access():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
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"])
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"])
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"])
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"])
bundle = fluent.Bundle("en", data_dir / "attributes.ftl")
result = bundle.get_translation(
"greeting.informal",
variables={"name": "Bob"},
Expand All @@ -247,27 +235,27 @@ def test_attribute_with_variables_use_isolating_off():


def test_attribute_on_message_without_main_value():
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
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"])
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"])
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"])
bundle = fluent.Bundle("en", data_dir / "attributes.ftl")
with pytest.raises(
ValueError,
match="welcome-message.nonexistent - Attribute 'nonexistent' not found on message 'welcome-message'",
Expand All @@ -286,5 +274,5 @@ def test_missing_attribute_raises_error():
),
)
def test_attribute_and_message_access_parameterized(identifier, expected):
bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"])
bundle = fluent.Bundle("en", data_dir / "attributes.ftl")
assert bundle.get_translation(identifier) == expected