diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c5435..21b220d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d5720c0..76d764c 100644 --- a/README.md +++ b/README.md @@ -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" @@ -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` diff --git a/src/lib.rs b/src/lib.rs index 4e4b52f..65585d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,8 +29,8 @@ mod rustfluent { #[pymethods] impl Bundle { #[new] - #[pyo3(signature = (language, ftl_filenames, strict=false))] - fn new(language: &str, ftl_filenames: Vec, strict: bool) -> PyResult { + #[pyo3(signature = (language, ftl_filename, strict=false))] + fn new(language: &str, ftl_filename: PathBuf, strict: bool) -> PyResult { let langid: LanguageIdentifier = match language.parse() { Ok(langid) => langid, Err(_) => { @@ -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 }) } diff --git a/src/rustfluent.pyi b/src/rustfluent.pyi index d2957b1..12c1447 100644 --- a/src/rustfluent.pyi +++ b/src/rustfluent.pyi @@ -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, diff --git a/tests/test_python_interface.py b/tests/test_python_interface.py index 9c551be..449ac4c 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_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}" @@ -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,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)) @@ -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." @@ -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) @@ -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"}, @@ -247,7 +235,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" @@ -255,19 +243,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'", @@ -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