diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b220d..96c5435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ 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 diff --git a/README.md b/README.md index 76d764c..d5720c0 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,14 @@ pip install rustfluent import rustfluent # First load a bundle -bundle = rustfluent.Bundle("en", "en.ftl") +bundle = rustfluent.Bundle( + "en", + [ + # Multiple FTL files can be specified. Entries in later + # files overwrite earlier ones. + "en.ftl", + ], +) # Fetch a translation assert bundle.get_translation("hello-world") == "Hello World" @@ -54,22 +61,25 @@ import rustfluent bundle = rustfluent.Bundle( language="en-US", - ftl_filename="/path/to/messages.ftl", # Also accepts pathlib.Path + ftl_files=[ + "/path/to/messages.ftl", + pathlib.Path("/path/to/more/messages.ftl"), + ], ) ``` #### Parameters -| 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. | +| 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. | #### Raises -- `FileNotFoundError` if the FTL file could not be found. -- `rustfluent.ParserError` if the FTL file contains errors (strict mode only). +- `FileNotFoundError` if any of the FTL files could not be found. +- `rustfluent.ParserError` if any of the FTL files contain errors (strict mode only). ### `Bundle.get_translation` diff --git a/src/lib.rs b/src/lib.rs index 65585d0..4e4b52f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,8 +29,8 @@ mod rustfluent { #[pymethods] impl Bundle { #[new] - #[pyo3(signature = (language, ftl_filename, strict=false))] - fn new(language: &str, ftl_filename: PathBuf, strict: bool) -> PyResult { + #[pyo3(signature = (language, ftl_filenames, strict=false))] + fn new(language: &str, ftl_filenames: Vec, strict: bool) -> PyResult { let langid: LanguageIdentifier = match language.parse() { Ok(langid) => langid, Err(_) => { @@ -41,27 +41,29 @@ mod rustfluent { }; let mut bundle = FluentBundle::new_concurrent(vec![langid]); - let contents = fs::read_to_string(&ftl_filename) - .map_err(|_| PyFileNotFoundError::new_err(ftl_filename.clone()))?; + for file_path in ftl_filenames.iter() { + let contents = fs::read_to_string(file_path) + .map_err(|_| PyFileNotFoundError::new_err(file_path.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 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 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); + Err((resource, _errors)) => resource, + }; + bundle.add_resource_overriding(resource); + } Ok(Self { bundle }) } diff --git a/src/rustfluent.pyi b/src/rustfluent.pyi index 12c1447..d2957b1 100644 --- a/src/rustfluent.pyi +++ b/src/rustfluent.pyi @@ -4,7 +4,9 @@ from pathlib import Path Variable = str | int | date class Bundle: - def __init__(self, language: str, ftl_filename: str | Path, strict: bool = False) -> None: ... + def __init__( + self, language: str, ftl_filenames: list[str | Path], strict: bool = False + ) -> None: ... def get_translation( self, identifier: str, diff --git a/tests/test_python_interface.py b/tests/test_python_interface.py index 449ac4c..9c551be 100644 --- a/tests/test_python_interface.py +++ b/tests/test_python_interface.py @@ -15,25 +15,25 @@ 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_filename=data_dir / "en.ftl", + ftl_filenames=[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}" @@ -41,7 +41,7 @@ def test_en_with_variables(): 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", @@ -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) @@ -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: '$'" @@ -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"}) @@ -104,7 +104,7 @@ 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}) @@ -112,12 +112,12 @@ def test_invalid_variable_values_use_key_instead(value): 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}!" @@ -135,22 +135,34 @@ 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)) @@ -159,7 +171,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." @@ -169,7 +181,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) @@ -201,31 +213,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"}, @@ -235,7 +247,7 @@ 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" @@ -243,19 +255,19 @@ def test_attribute_on_message_without_main_value(): 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'", @@ -274,5 +286,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