From 6b97a3188948839495c88a452a8b27d1d86f45d9 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Tue, 21 Jan 2025 16:54:46 +0200 Subject: [PATCH 1/3] message.data: Use True instead of None for attributes with no value --- moz/l10n/formats/android/parse.py | 3 ++- moz/l10n/formats/mf2/from_json.py | 4 ++-- moz/l10n/formats/mf2/message_parser.py | 6 +++--- moz/l10n/formats/mf2/serialize.py | 5 +++-- moz/l10n/formats/mf2/to_json.py | 4 ++-- moz/l10n/formats/mf2/validate.py | 5 +++-- moz/l10n/formats/xliff/parse.py | 2 +- moz/l10n/formats/xliff/parse_xcode.py | 6 +++--- moz/l10n/formats/xliff/serialize.py | 4 +--- moz/l10n/message/data.py | 4 ++-- tests/formats/test_mf2.py | 8 ++++---- tests/formats/test_mf2_validate.py | 4 ++-- tests/formats/test_xliff1.py | 9 +++------ 13 files changed, 31 insertions(+), 33 deletions(-) diff --git a/moz/l10n/formats/android/parse.py b/moz/l10n/formats/android/parse.py index bc7e939..d6aa059 100644 --- a/moz/l10n/formats/android/parse.py +++ b/moz/l10n/formats/android/parse.py @@ -16,6 +16,7 @@ from collections.abc import Callable, Iterable, Iterator from re import compile +from typing import Literal from lxml import etree @@ -277,7 +278,7 @@ def flatten(el: etree._Element) -> Iterator[str | Expression | Markup]: for gc in body: if isinstance(gc, str): options: dict[str, str | VariableRef] = dict(child.attrib) - attr: dict[str, str | None] = {"translate": "no"} + attr: dict[str, str | Literal[True]] = {"translate": "no"} arg: str | VariableRef | None if id: arg = VariableRef(get_var_name(id)) diff --git a/moz/l10n/formats/mf2/from_json.py b/moz/l10n/formats/mf2/from_json.py index b7f88b7..1a0774c 100644 --- a/moz/l10n/formats/mf2/from_json.py +++ b/moz/l10n/formats/mf2/from_json.py @@ -111,9 +111,9 @@ def _options(json: dict[str, Any]) -> dict[str, str | msg.VariableRef]: return {name: _value(json_value) for name, json_value in json.items()} -def _attributes(json: dict[str, Any]) -> dict[str, str | None]: +def _attributes(json: dict[str, Any]) -> dict[str, str | Literal[True]]: return { - name: None if json_value is True else _literal(json_value) + name: True if json_value is True else _literal(json_value) for name, json_value in json.items() } diff --git a/moz/l10n/formats/mf2/message_parser.py b/moz/l10n/formats/mf2/message_parser.py index b64e475..4374b6c 100644 --- a/moz/l10n/formats/mf2/message_parser.py +++ b/moz/l10n/formats/mf2/message_parser.py @@ -308,8 +308,8 @@ def options(self) -> dict[str, str | msg.VariableRef]: opt_end = self.pos return options - def attributes(self) -> dict[str, str | None]: - attributes: dict[str, str | None] = {} + def attributes(self) -> dict[str, str | Literal[True]]: + attributes: dict[str, str | Literal[True]] = {} attr_end = self.pos while self.req_space(): ch = self.char() @@ -326,7 +326,7 @@ def attributes(self) -> dict[str, str | None]: attributes[id] = self.literal() else: self.pos = id_end - attributes[id] = None + attributes[id] = True attr_end = self.pos return attributes diff --git a/moz/l10n/formats/mf2/serialize.py b/moz/l10n/formats/mf2/serialize.py index 5ef9d3c..c9303db 100644 --- a/moz/l10n/formats/mf2/serialize.py +++ b/moz/l10n/formats/mf2/serialize.py @@ -16,6 +16,7 @@ from collections.abc import Iterator from re import compile +from typing import Literal from ...message import data as msg from .validate import name_re, number_re @@ -111,9 +112,9 @@ def _options(options: dict[str, str | msg.VariableRef]) -> Iterator[str]: yield f" {name}={_value(value)}" -def _attributes(attributes: dict[str, str | None]) -> Iterator[str]: +def _attributes(attributes: dict[str, str | Literal[True]]) -> Iterator[str]: for name, value in attributes.items(): - yield f" @{name}" if value is None else f" @{name}={_literal(value)}" + yield f" @{name}" if value is True else f" @{name}={_literal(value)}" def _value(value: str | msg.VariableRef) -> str: diff --git a/moz/l10n/formats/mf2/to_json.py b/moz/l10n/formats/mf2/to_json.py index 005f84a..928b5bb 100644 --- a/moz/l10n/formats/mf2/to_json.py +++ b/moz/l10n/formats/mf2/to_json.py @@ -100,10 +100,10 @@ def _options(options: dict[str, str | msg.VariableRef]) -> dict[str, dict[str, s def _attributes( - attributes: dict[str, str | None], + attributes: dict[str, str | Literal[True]], ) -> dict[str, dict[str, str] | Literal[True]]: return { - name: True if value is None else _literal(value) + name: True if value is True else _literal(value) for name, value in attributes.items() } diff --git a/moz/l10n/formats/mf2/validate.py b/moz/l10n/formats/mf2/validate.py index 88babe6..c91fea9 100644 --- a/moz/l10n/formats/mf2/validate.py +++ b/moz/l10n/formats/mf2/validate.py @@ -17,6 +17,7 @@ from collections.abc import Iterable, Mapping from functools import cmp_to_key from re import compile +from typing import Literal from ...message.data import ( CatchallKey, @@ -200,13 +201,13 @@ def _validate_options(options: dict[str, str | VariableRef]) -> None: raise MF2ValidationError(f"Invalid option value: {value}") -def _validate_attributes(attributes: dict[str, str | None]) -> None: +def _validate_attributes(attributes: dict[str, str | Literal[True]]) -> None: if not isinstance(attributes, Mapping): raise MF2ValidationError(f"Invalid attributes: {attributes}") for name, value in attributes.items(): if not isinstance(name, str) or not identifier_re.fullmatch(name): raise MF2ValidationError(f"Invalid attribute name: {name}") - elif value is not None and not isinstance(value, str): + elif value is not True and not isinstance(value, str): raise MF2ValidationError(f"Invalid option value: {value}") diff --git a/moz/l10n/formats/xliff/parse.py b/moz/l10n/formats/xliff/parse.py index 335dc8b..12f4230 100644 --- a/moz/l10n/formats/xliff/parse.py +++ b/moz/l10n/formats/xliff/parse.py @@ -176,7 +176,7 @@ def parse_bin_unit(unit: etree._Element) -> Entry[Message, str]: raise ValueError(f'Missing "id" attribute for : {unit}') meta = attrib_as_metadata(unit, None, ("id",)) meta += element_as_metadata(unit, "", False) - msg = PatternMessage([Expression(None, attributes={"bin-unit": None})]) + msg = PatternMessage([Expression(None, attributes={"bin-unit": True})]) return Entry((id,), msg, meta=meta) diff --git a/moz/l10n/formats/xliff/parse_xcode.py b/moz/l10n/formats/xliff/parse_xcode.py index bba7136..e13ec3b 100644 --- a/moz/l10n/formats/xliff/parse_xcode.py +++ b/moz/l10n/formats/xliff/parse_xcode.py @@ -75,9 +75,9 @@ def parse_xliff_stringsdict( selector = Expression( VariableRef(plural.var_name), "number", - attributes={ - "source": plural.format_key.source.text if plural.format_key else None - }, + attributes={"source": plural.format_key.source.text} + if plural.format_key and plural.format_key.source.text + else {}, ) meta: list[Metadata[str]] = [] if plural.format_key: diff --git a/moz/l10n/formats/xliff/serialize.py b/moz/l10n/formats/xliff/serialize.py index f9c707a..003ccc7 100644 --- a/moz/l10n/formats/xliff/serialize.py +++ b/moz/l10n/formats/xliff/serialize.py @@ -188,11 +188,9 @@ def add_xliff_stringsdict_plural( var_name = sel.arg.name sel_source = sel.attributes.get("source", None) - if isinstance(sel_source, VariableRef): - raise ValueError(f"Unsupported format key source for {id}: {sel_source}") meta_base = "format/" meta = [m for m in entry.meta if m.key.startswith(meta_base)] - if sel_source: + if isinstance(sel_source, str): xcode_id = f"{id_base}/NSStringLocalizedFormatKey:dict/:string" unit = etree.SubElement(parent, "trans-unit", {"id": xcode_id}) assign_metadata(unit, meta, trim_comments, meta_base) diff --git a/moz/l10n/message/data.py b/moz/l10n/message/data.py index b13dfa1..aadcd84 100644 --- a/moz/l10n/message/data.py +++ b/moz/l10n/message/data.py @@ -45,7 +45,7 @@ class Expression: arg: str | VariableRef | None function: str | None = None options: dict[str, str | VariableRef] = field(default_factory=dict) - attributes: dict[str, str | None] = field(default_factory=dict) + attributes: dict[str, str | Literal[True]] = field(default_factory=dict) @dataclass @@ -53,7 +53,7 @@ class Markup: kind: Literal["open", "standalone", "close"] name: str options: dict[str, str | VariableRef] = field(default_factory=dict) - attributes: dict[str, str | None] = field(default_factory=dict) + attributes: dict[str, str | Literal[True]] = field(default_factory=dict) Pattern = List[Union[str, Expression, Markup]] diff --git a/tests/formats/test_mf2.py b/tests/formats/test_mf2.py index d927538..f0d72d5 100644 --- a/tests/formats/test_mf2.py +++ b/tests/formats/test_mf2.py @@ -120,7 +120,7 @@ def test_placeholder(): def test_placeholder_attributes(): fail("{@foo}") - ok("{42 @foo}", PatternMessage([Expression("42", attributes={"foo": None})])) + ok("{42 @foo}", PatternMessage([Expression("42", attributes={"foo": True})])) ok( "{42 @foo = 13 }", PatternMessage([Expression("42", attributes={"foo": "13"})]), @@ -134,7 +134,7 @@ def test_placeholder_attributes(): ok( "{$var @foo @bar=baz}", PatternMessage( - [Expression(VariableRef("var"), attributes={"foo": None, "bar": "baz"})] + [Expression(VariableRef("var"), attributes={"foo": True, "bar": "baz"})] ), ) fail("{$var@foo}") @@ -186,7 +186,7 @@ def test_placeholder_with_function(): VariableRef("var"), "test:string", {"opt-a": "42", "opt:b": VariableRef("var")}, - attributes={"foo": None, "bar": "baz"}, + attributes={"foo": True, "bar": "baz"}, ), ] ), @@ -236,7 +236,7 @@ def test_markup(): "{#aa @attr}{/bb @attr=42}{#cc @ns:attr=|42|/}", PatternMessage( [ - Markup("open", "aa", attributes={"attr": None}), + Markup("open", "aa", attributes={"attr": True}), Markup("close", "bb", attributes={"attr": "42"}), Markup("standalone", "cc", attributes={"ns:attr": "42"}), ] diff --git a/tests/formats/test_mf2_validate.py b/tests/formats/test_mf2_validate.py index fdbb38d..d7f8d7f 100644 --- a/tests/formats/test_mf2_validate.py +++ b/tests/formats/test_mf2_validate.py @@ -72,11 +72,11 @@ def test_validate_expression(): fail(Expression(None, "func", {"opt": 42})) fail(Expression(None, "func", {42: "opt"})) - ok(Expression("42", attributes={"attr": None})) + ok(Expression("42", attributes={"attr": True})) ok(Expression("42", attributes={"attr": "some attr value"})) fail(Expression(None, attributes="attr")) fail(Expression(None, attributes=["attr"])) - fail(Expression(None, attributes={"attr": None})) + fail(Expression(None, attributes={"attr": True})) fail(Expression("42", None, attributes={"attr": 42})) fail(Expression("42", None, attributes={"attr": VariableRef("var")})) fail(Expression("42", None, attributes={42: "attr"})) diff --git a/tests/formats/test_xliff1.py b/tests/formats/test_xliff1.py index f26b870..ca94932 100644 --- a/tests/formats/test_xliff1.py +++ b/tests/formats/test_xliff1.py @@ -261,7 +261,7 @@ def test_parse_icu_docs(self): Entry( id=("logo",), value=PatternMessage( - [Expression(None, attributes={"bin-unit": None})] + [Expression(None, attributes={"bin-unit": True})] ), meta=[ Metadata("@resname", "logo"), @@ -278,7 +278,7 @@ def test_parse_icu_docs(self): Entry( id=("md5_sum",), value=PatternMessage( - [Expression(None, attributes={"bin-unit": None})] + [Expression(None, attributes={"bin-unit": True})] ), meta=[ Metadata("@resname", "md5_sum"), @@ -540,7 +540,6 @@ def test_parse_xcode(self): "GenericCountEntriesSelected": Expression( VariableRef("GenericCountEntriesSelected"), "number", - attributes={"source": None}, ) }, selectors=(VariableRef("GenericCountEntriesSelected"),), @@ -575,9 +574,7 @@ def test_parse_xcode(self): value=SelectMessage( declarations={ "GenericCountThreads": Expression( - VariableRef("GenericCountThreads"), - "number", - attributes={"source": None}, + VariableRef("GenericCountThreads"), "number" ) }, selectors=(VariableRef("GenericCountThreads"),), From 5130f44e14617b99707176e22d371a331e41e348 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Tue, 21 Jan 2025 20:04:20 +0200 Subject: [PATCH 2/3] message: Add message_to_json() and message_from_json() converters --- moz/l10n/message/__init__.py | 4 ++ moz/l10n/message/from_json.py | 113 ++++++++++++++++++++++++++++++ moz/l10n/message/schema.json | 126 ++++++++++++++++++++++++++++++++++ moz/l10n/message/to_json.py | 100 +++++++++++++++++++++++++++ tests/formats/test_mf2.py | 22 ++++-- 5 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 moz/l10n/message/from_json.py create mode 100644 moz/l10n/message/schema.json create mode 100644 moz/l10n/message/to_json.py diff --git a/moz/l10n/message/__init__.py b/moz/l10n/message/__init__.py index e69de29..ddf14c67 100644 --- a/moz/l10n/message/__init__.py +++ b/moz/l10n/message/__init__.py @@ -0,0 +1,4 @@ +from .from_json import message_from_json +from .to_json import message_to_json + +__all__ = ["message_from_json", "message_to_json"] diff --git a/moz/l10n/message/from_json.py b/moz/l10n/message/from_json.py new file mode 100644 index 0000000..58623a8 --- /dev/null +++ b/moz/l10n/message/from_json.py @@ -0,0 +1,113 @@ +# Copyright Mozilla Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal + +from moz.l10n.message.data import ( + CatchallKey, + Expression, + Markup, + Pattern, + PatternMessage, + SelectMessage, + VariableRef, +) + + +def message_from_json( + json: list[Any] | dict[str, Any], +) -> PatternMessage | SelectMessage: + if isinstance(json, Mapping) and "sel" in json: + return SelectMessage( + declarations={ + name: _expression_from_json(value) + for name, value in json["decl"].items() + }, + selectors=tuple(VariableRef(sel) for sel in json["sel"]), + variants={ + tuple( + key if isinstance(key, str) else CatchallKey(key["*"] or None) + for key in variant["keys"] + ): _pattern_from_json(variant["pat"]) + for variant in json["alt"] + }, + ) + else: + declarations = {} + if isinstance(json, Mapping): + if "decl" in json: + declarations = { + name: _expression_from_json(value) + for name, value in json["decl"].items() + } + pattern = _pattern_from_json(json["msg"]) + else: + pattern = _pattern_from_json(json) + return PatternMessage(pattern, declarations) + + +def _pattern_from_json(json: list[str | dict[str, Any]]) -> Pattern: + return [ + part + if isinstance(part, str) + else _expression_from_json(part) + if "_" in part or "$" in part or "fn" in part + else _markup_from_json(part) + for part in json + ] + + +def _expression_from_json(json: dict[str, Any]) -> Expression: + if "_" in json: + arg = json["_"] + elif "$" in json: + arg = VariableRef(json["$"]) + else: + arg = None + function = json.get("fn", None) + options = ( + _options_from_json(json["opt"]) + if function is not None and "opt" in json + else {} + ) + return Expression(arg, function, options, json.get("attr", {})) + + +def _markup_from_json(json: dict[str, Any]) -> Markup: + kind: Literal["open", "standalone", "close"] + if "open" in json: + kind = "open" + name = json["open"] + elif "close" in json: + kind = "close" + name = json["close"] + else: + kind = "standalone" + name = json["elem"] + return Markup( + kind, + name, + _options_from_json(json.get("opt", {})), + json.get("attr", {}), + ) + + +def _options_from_json(json: dict[str, Any]) -> dict[str, str | VariableRef]: + return { + name: value if isinstance(value, str) else VariableRef(value["$"]) + for name, value in json.items() + } diff --git a/moz/l10n/message/schema.json b/moz/l10n/message/schema.json new file mode 100644 index 0000000..91271c1 --- /dev/null +++ b/moz/l10n/message/schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + + "oneOf": [ + { "$ref": "#/$defs/pattern" }, + { "$ref": "#/$defs/message" }, + { "$ref": "#/$defs/select" } + ], + + "$defs": { + "options": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "$": { "type": "string" } + }, + "required": ["$"] + } + ] + } + }, + "attributes": { + "type": "object", + "additionalProperties": { + "oneOf": [{ "type": "string" }, { "const": true }] + } + }, + + "expression": { + "type": "object", + "properties": { + "_": { "type": "string" }, + "$": { "type": "string" }, + "fn": { "type": "string" }, + "opt": { "$ref": "#/$defs/options" }, + "attr": { "$ref": "#/$defs/attributes" } + }, + "anyOf": [ + { "required": ["_"], "not": { "required": ["$"] } }, + { "required": ["$"], "not": { "required": ["_"] } }, + { "required": ["fn"] } + ] + }, + + "markup": { + "type": "object", + "properties": { + "open": { "type": "string" }, + "close": { "type": "string" }, + "elem": { "type": "string" }, + "opt": { "$ref": "#/$defs/options" }, + "attr": { "$ref": "#/$defs/attributes" } + }, + "oneOf": [ + { "required": ["open"] }, + { "required": ["close"] }, + { "required": ["elem"] } + ] + }, + + "pattern": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "$ref": "#/$defs/expression" }, + { "$ref": "#/$defs/markup" } + ] + } + }, + + "declarations": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/expression" } + }, + + "message": { + "type": "object", + "properties": { + "decl": { "$ref": "#/$defs/declarations" }, + "msg": { "$ref": "#/$defs/pattern" } + }, + "required": ["decl", "msg"] + }, + "select": { + "type": "object", + "properties": { + "decl": { "$ref": "#/$defs/declarations" }, + "sel": { + "type": "array", + "items": { "type": "string" } + }, + "alt": { + "type": "array", + "items": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "*": { "type": "string" } + }, + "required": ["*"] + } + ] + } + }, + "pat": { "$ref": "#/$defs/pattern" } + }, + "required": ["keys", "pat"] + } + } + }, + "required": ["decl", "sel", "alt"] + } + } +} diff --git a/moz/l10n/message/to_json.py b/moz/l10n/message/to_json.py new file mode 100644 index 0000000..9521eee --- /dev/null +++ b/moz/l10n/message/to_json.py @@ -0,0 +1,100 @@ +# Copyright Mozilla Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any + +from moz.l10n.message.data import ( + Expression, + Markup, + Message, + Pattern, + PatternMessage, + SelectMessage, + VariableRef, +) + + +def message_to_json(msg: Message) -> list[Any] | dict[str, Any]: + json_declarations = { + name: _expression_to_json(expr) for name, expr in msg.declarations.items() + } + if isinstance(msg, PatternMessage): + if not json_declarations: + return _pattern_to_json(msg.pattern) + return { + "decl": json_declarations, + "msg": _pattern_to_json(msg.pattern), + } + else: + assert isinstance(msg, SelectMessage) + return { + "decl": json_declarations, + "sel": [sel.name for sel in msg.selectors], + "alt": [ + { + "keys": [ + key if isinstance(key, str) else {"*": key.value or ""} + for key in keys + ], + "pat": _pattern_to_json(pattern), + } + for keys, pattern in msg.variants.items() + ], + } + + +def _pattern_to_json(pattern: Pattern) -> list[str | dict[str, Any]]: + return [ + part + if isinstance(part, str) + else _markup_to_json(part) + if isinstance(part, Markup) + else _expression_to_json(part) + for part in pattern + ] + + +def _expression_to_json(expr: Expression) -> dict[str, Any]: + json: dict[str, Any] = {} + if isinstance(expr.arg, str): + json["_"] = expr.arg + elif isinstance(expr.arg, VariableRef): + json["$"] = expr.arg.name + if expr.function: + json["fn"] = expr.function + if expr.options: + json["opt"] = _options_to_json(expr.options) + if expr.attributes: + json["attr"] = expr.attributes + return json + + +def _markup_to_json(markup: Markup) -> dict[str, Any]: + json: dict[str, Any] = { + "elem" if markup.kind == "standalone" else markup.kind: markup.name + } + if markup.options: + json["opt"] = _options_to_json(markup.options) + if markup.attributes: + json["attr"] = markup.attributes + return json + + +def _options_to_json(options: dict[str, str | VariableRef]) -> dict[str, Any]: + return { + name: value if isinstance(value, str) else {"$": value.name} + for name, value in options.items() + } diff --git a/tests/formats/test_mf2.py b/tests/formats/test_mf2.py index f0d72d5..55cdb36 100644 --- a/tests/formats/test_mf2.py +++ b/tests/formats/test_mf2.py @@ -28,6 +28,7 @@ mf2_to_json, ) from moz.l10n.formats.mf2.from_json import mf2_from_json +from moz.l10n.message import message_from_json, message_to_json from moz.l10n.message.data import ( CatchallKey, Expression, @@ -38,21 +39,32 @@ VariableRef, ) -schema_src = ( +mf2_schema_src = ( files("tests.formats.data").joinpath("mf2-message-schema.json").read_bytes() ) -schema: dict[str, dict[str, Any]] = loads(schema_src) +mf2_schema: dict[str, dict[str, Any]] = loads(mf2_schema_src) + +moz_schema_src = files("moz.l10n.message").joinpath("schema.json").read_bytes() +moz_schema: dict[str, dict[str, Any]] = loads(moz_schema_src) def ok(src: str, exp_msg: Message, exp_str: str | None = None): msg = mf2_parse_message(src) assert msg == exp_msg assert msg_str(msg) == exp_str or src - json = mf2_to_json(msg) - validate(json, schema) - msg2 = mf2_from_json(json) + + mf2_json = mf2_to_json(msg) + validate(mf2_json, mf2_schema) + msg2 = mf2_from_json(mf2_json) assert msg2 == msg + # These tests are for the moz.l10n.message converters rather than any MF2 code, + # included here to avoid duplicating this test suite. + moz_json = message_to_json(msg) + validate(moz_json, moz_schema) + msg3 = message_from_json(moz_json) + assert msg3 == msg + def fail(src: str) -> str: with pytest.raises(MF2ParseError) as err_info: From 5461605614a735dbe2c7f10986efdfc39ae52e85 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Wed, 22 Jan 2025 10:56:21 +0200 Subject: [PATCH 3/3] docs: Update README & message/ docstrings --- README.md | 31 +++++++++++++++++++++++++++++++ moz/l10n/message/from_json.py | 11 ++++++++--- moz/l10n/message/to_json.py | 5 +++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 75308f9..1fa2de4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,37 @@ and/or contents. Returns a `Format` enum value, or `None` if the input is not recognized. +### moz.l10n.message.data + +```python +from moz.l10n.message.data import ( + CatchallKey, + Expression, + Markup, + Message, # type alias for PatternMessage | SelectMessage + Pattern, # type alias for list[str | Expression | Markup] + PatternMessage, + SelectMessage, + VariableRef +) +``` + +Dataclasses defining the library's representation of a single message, +either as a single-pattern `PatternMessage`, +or as a `SelectMessage` with one or more selectors and multiple variant patterns. + +### moz.l10n.message: from_json() and to_json() + +```python +from moz.l10n.message import from_json, to_json + +def message_from_json(json: list[Any] | dict[str, Any]) -> Message: ... +def message_to_json(msg: Message) -> list[Any] | dict[str, Any]: ... +``` + +Converters to and from a JSON-serializable representation of a `Message`. +The format of the output is defined by the [`schema.json`](./moz/l10n/message/schema.json) JSON Schema. + ### moz.l10n.paths.L10nConfigPaths Wrapper for localization config files. diff --git a/moz/l10n/message/from_json.py b/moz/l10n/message/from_json.py index 58623a8..1e11a98 100644 --- a/moz/l10n/message/from_json.py +++ b/moz/l10n/message/from_json.py @@ -21,6 +21,7 @@ CatchallKey, Expression, Markup, + Message, Pattern, PatternMessage, SelectMessage, @@ -28,9 +29,13 @@ ) -def message_from_json( - json: list[Any] | dict[str, Any], -) -> PatternMessage | SelectMessage: +def message_from_json(json: list[Any] | dict[str, Any]) -> Message: + """ + Marshal the JSON output of `moz.l10n.message.to_json()` + back into a parsed `moz.l10n.message.data.Message`. + + May raise `MF2ValidationError`. + """ if isinstance(json, Mapping) and "sel" in json: return SelectMessage( declarations={ diff --git a/moz/l10n/message/to_json.py b/moz/l10n/message/to_json.py index 9521eee..be4197a 100644 --- a/moz/l10n/message/to_json.py +++ b/moz/l10n/message/to_json.py @@ -28,6 +28,11 @@ def message_to_json(msg: Message) -> list[Any] | dict[str, Any]: + """ + Represent a Message as a JSON-serializable value. + + The JSON Schema of the output is provided as [schema.json](./schema.json). + """ json_declarations = { name: _expression_to_json(expr) for name, expr in msg.declarations.items() }