-
Notifications
You must be signed in to change notification settings - Fork 610
Add endpoint to fetch fluent reference variants #4517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
julen
wants to merge
4
commits into
mozilla:main
Choose a base branch
from
julen:ftl-variants-autocomplete-backend
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| from collections.abc import Iterator | ||
| from typing import TypedDict | ||
|
|
||
| from moz.l10n.formats.fluent import fluent_parse_entry | ||
| from moz.l10n.model import ( | ||
| CatchallKey, | ||
| Entry, | ||
| Expression, | ||
| Message, | ||
| Pattern, | ||
| PatternMessage, | ||
| SelectMessage, | ||
| ) | ||
|
|
||
|
|
||
| def _parse_fluent_entry(source: str) -> Entry[Message] | None: | ||
| """Parse a Fluent entry; returns None if the source is invalid FTL.""" | ||
| try: | ||
| return fluent_parse_entry(source, with_linepos=False) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| def _entry_messages(entry: Entry[Message]) -> Iterator[Message]: | ||
| """Iterate over the entry's value message and its property messages.""" | ||
| if isinstance(entry.value, (PatternMessage, SelectMessage)): | ||
| yield entry.value | ||
| yield from entry.properties.values() | ||
|
|
||
|
|
||
| def get_references(entry_or_str: Entry[Message] | str) -> set[str]: | ||
| """Collect the keys of the messages/terms referenced by a Fluent entry, | ||
| e.g. `-brand-name` in `msg = This is { -brand-name }`. | ||
|
|
||
| Accepts the entry itself or a Fluent source string; | ||
| a string that cannot be parsed yields an empty set. | ||
| """ | ||
| entry = ( | ||
| _parse_fluent_entry(entry_or_str) | ||
| if isinstance(entry_or_str, str) | ||
| else entry_or_str | ||
| ) | ||
| if entry is None: | ||
| return set() | ||
|
|
||
| names: set[str] = set() | ||
|
|
||
| def from_expression(expr: Expression) -> None: | ||
| # Remember message/term references | ||
| if expr.function == "message" and isinstance(expr.arg, str): | ||
| names.add(expr.arg) | ||
|
|
||
| def from_pattern(pattern: Pattern) -> None: | ||
| for part in pattern: | ||
| if isinstance(part, Expression): | ||
| from_expression(part) | ||
|
|
||
| def from_message(msg: Message) -> None: | ||
| for expr in msg.declarations.values(): | ||
| from_expression(expr) | ||
| if isinstance(msg, PatternMessage): | ||
| from_pattern(msg.pattern) | ||
| elif isinstance(msg, SelectMessage): | ||
| for pattern in msg.variants.values(): | ||
| from_pattern(pattern) | ||
|
|
||
| for msg in _entry_messages(entry): | ||
| from_message(msg) | ||
|
|
||
| return names | ||
|
|
||
|
|
||
| class SelectorField(TypedDict): | ||
| """A selector from a Fluent entry, together with its variant keys.""" | ||
|
|
||
| name: str | ||
| values: list[str] | ||
|
|
||
|
|
||
| def get_selector_variants(entry_or_str: Entry[Message] | str) -> list[SelectorField]: | ||
| """Extract the selector fields and their variants from a Fluent entry's | ||
| value and attributes. | ||
|
|
||
| Accepts the entry itself or a Fluent source string; | ||
| a string that cannot be parsed yields an empty list. | ||
|
|
||
| Note default variants always come last, which is the same way as provided by | ||
| moz.l10n.fluent. | ||
| """ | ||
| entry = ( | ||
| _parse_fluent_entry(entry_or_str) | ||
| if isinstance(entry_or_str, str) | ||
| else entry_or_str | ||
| ) | ||
| if entry is None: | ||
| return [] | ||
|
|
||
| fields: dict[str, list[str]] = {} | ||
| for msg in _entry_messages(entry): | ||
| # Only selectors contain variants, so we skip any other message type. | ||
| if not isinstance(msg, SelectMessage): | ||
| continue | ||
|
|
||
| for idx, selector in enumerate(msg.selectors): | ||
| values = fields.setdefault(selector.name, []) | ||
| for keys in msg.variants: | ||
| key = keys[idx] | ||
| value = key.value if isinstance(key, CatchallKey) else key | ||
| if value and value not in values: | ||
| values.append(value) | ||
|
|
||
| return [{"name": name, "values": values} for name, values in fields.items()] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| from textwrap import dedent | ||
|
|
||
| from moz.l10n.formats.fluent import fluent_parse_entry | ||
|
|
||
| from pontoon.base.fluent_utils import ( | ||
| get_references, | ||
| get_selector_variants, | ||
| ) | ||
|
|
||
|
|
||
| def test_get_references_from_source_string(): | ||
| """Test that a Fluent source string is parsed and its references collected.""" | ||
| source = "message = Welcome to { -brand-name }, { $user }!" | ||
|
|
||
| assert get_references(source) == {"-brand-name"} | ||
|
|
||
|
|
||
| def test_get_references_invalid_source_string(): | ||
| """Test unparseable Fluent source strings produce an empty set.""" | ||
| source = "no valid fluent here ][" | ||
|
|
||
| assert get_references(source) == set() | ||
|
|
||
|
|
||
| def test_get_references_from_entry(): | ||
| """Test that references are collected from the parsed entry itself.""" | ||
| source = "message = Welcome to { -brand-name }, { $user }!" | ||
| entry = fluent_parse_entry(source, with_linepos=False) | ||
|
|
||
| assert get_references(entry) == {"-brand-name"} | ||
|
|
||
|
|
||
| def test_get_references_no_placeholders(): | ||
| """Test a string with no terms collects no references.""" | ||
| source = "message = Simple string" | ||
|
|
||
| assert get_references(source) == set() | ||
|
|
||
|
|
||
| def test_get_references_from_attributes(): | ||
| """Test terms can be extracted from attributes.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| .gender = { -brand-name(case: "genitive") } | ||
| .tooltip = { $count } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_references(source) == {"-brand-name"} | ||
|
|
||
|
|
||
| def test_get_references_from_declarations_and_select_variants(): | ||
| """Test references from select variants are picked up.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| { $count -> | ||
| [one] { -brand-short-name } blocked one tracker | ||
| *[other] { -brand-short-name } blocked many trackers | ||
| } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_references(source) == {"-brand-short-name"} | ||
|
|
||
|
|
||
| def test_get_selector_variants_no_selectors(): | ||
| """Test extracting variants from a term with no selector.""" | ||
| source = "message = Welcome to { -brand-name }!" | ||
|
|
||
| assert get_selector_variants(source) == [] | ||
|
|
||
|
|
||
| def test_get_selector_variants_from_value(): | ||
| """Test extracting variants from a selector.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| { $count -> | ||
| [one] One tracker blocked | ||
| *[other] Trackers blocked | ||
| } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_selector_variants(source) == [ | ||
| {"name": "count", "values": ["one", "other"]} | ||
| ] | ||
|
|
||
|
|
||
| def test_get_selector_variants_multiple_selectors(): | ||
| """Test extracting variants from nested selectors.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| { $gender -> | ||
| [masculine] { $count -> | ||
| [one] One | ||
| *[other] Many | ||
| } | ||
| *[feminine] Feminine | ||
| } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_selector_variants(source) == [ | ||
| {"name": "gender", "values": ["masculine", "feminine"]}, | ||
| {"name": "count", "values": ["one", "other"]}, | ||
| ] | ||
|
|
||
|
|
||
| def test_get_selector_variants_from_attributes(): | ||
| """Test extracting variants from attribute selectors.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| .accesskey = value with no selector | ||
| .tooltip = | ||
| { $case -> | ||
| [nominative] Nominative | ||
| [genitive] Genitive | ||
| *[other] Other | ||
| } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_selector_variants(source) == [ | ||
| {"name": "case", "values": ["nominative", "genitive", "other"]} | ||
| ] | ||
|
|
||
|
|
||
| def test_get_selector_variants_sorting(): | ||
| """Test extracting variants from a selector keeps the default at the end.""" | ||
| source = dedent( | ||
| """\ | ||
| message = | ||
| { $count -> | ||
| *[other] Trackers blocked | ||
| [one] One tracker blocked | ||
| } | ||
| """ | ||
| ) | ||
|
|
||
| assert get_selector_variants(source) == [ | ||
| {"name": "count", "values": ["one", "other"]} | ||
| ] | ||
|
|
||
|
|
||
| def test_get_selector_variants_invalid_source_string(): | ||
| """Test unparseable Fluent source strings produce an empty list.""" | ||
| source = "no valid fluent here ][" | ||
|
|
||
| assert get_selector_variants(source) == [] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Messageis a union (not a class), so mypy was complaining. A more future-proof approach would be to place this check behind aTypeIsguard; let me know if you want that or not.