Skip to content

Commit

Permalink
mf2: Add mf2_to_json() and mf2_from_json()
Browse files Browse the repository at this point in the history
  • Loading branch information
eemeli committed Jan 20, 2025
1 parent 6c8413f commit e423d5b
Show file tree
Hide file tree
Showing 9 changed files with 817 additions and 3 deletions.
4 changes: 4 additions & 0 deletions moz/l10n/formats/mf2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from .from_json import mf2_from_json
from .message_parser import MF2ParseError, mf2_parse_message
from .serialize import mf2_serialize_message, mf2_serialize_pattern
from .to_json import mf2_to_json
from .validate import MF2ValidationError, mf2_validate_message

__all__ = [
"MF2ParseError",
"MF2ValidationError",
"mf2_from_json",
"mf2_parse_message",
"mf2_serialize_message",
"mf2_serialize_pattern",
"mf2_to_json",
"mf2_validate_message",
]
151 changes: 151 additions & 0 deletions moz/l10n/formats/mf2/from_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 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, Literal, cast

from ...message import data as msg
from .validate import MF2ValidationError


def mf2_from_json(json: dict[str, Any]) -> msg.Message:
"""
Marshal a MessageFormat 2 data model [JSON Schema](https://github.com/unicode-org/message-format-wg/blob/main/spec/data-model/message.json)
object into a parsed `moz.l10n.message.data.Message`.
"""
try:
msg_type = json["type"]
if msg_type not in {"message", "select"}:
raise MF2ValidationError(f"Invalid JSON message: {json}")

declarations: dict[str, msg.Expression] = {}
for decl in json["declarations"]:
decl_type = decl["type"]
if decl_type not in {"input", "local"}:
raise MF2ValidationError(f"Invalid JSON declaration type: {decl}")
decl_name = _string(decl, "name")
decl_expr = _expression(decl["value"])
if decl_type == "input":
if (
not isinstance(decl_expr.arg, msg.VariableRef)
or decl_expr.arg.name != decl_name
):
raise MF2ValidationError(f"Invalid JSON .input declaration: {decl}")
if decl_name in declarations:
raise MF2ValidationError(f"Duplicate JSON declaration for ${decl_name}")
declarations[decl_name] = decl_expr

if msg_type == "message":
pattern = _pattern(json["pattern"])
return msg.PatternMessage(pattern, declarations)

assert msg_type == "select"
selectors = tuple(_variable(sel) for sel in json["selectors"])
variants = {
tuple(_key(key) for key in vari["keys"]): _pattern(vari["value"])
for vari in json["variants"]
}
return msg.SelectMessage(declarations, selectors, variants)
except (IndexError, KeyError, TypeError) as err:
raise MF2ValidationError(f"Invalid JSON: {err!r}")


def _pattern(json: list[Any]) -> msg.Pattern:
return [
part
if isinstance(part, str)
else _markup(part)
if part["type"] == "markup"
else _expression(part)
for part in json
]


def _expression(json: dict[str, Any]) -> msg.Expression:
if json["type"] != "expression":
raise MF2ValidationError(f"Invalid JSON expression type: {json}")
arg = _value(json["arg"]) if "arg" in json else None
json_func = json.get("function", None)
if json_func:
if json_func["type"] != "function":
raise MF2ValidationError(f"Invalid JSON function type: {json_func}")
function = _string(json_func, "name")
options = _options(json_func["options"]) if "options" in json_func else {}
else:
function = None
options = {}
if arg is None and function is None:
raise MF2ValidationError(
f"Invalid JSON expression with no operand and no function: {json}"
)
attributes = _attributes(json["attributes"]) if "attributes" in json else {}
return msg.Expression(arg, function, options, attributes)


def _markup(json: dict[str, Any]) -> msg.Markup:
assert json["type"] == "markup"
kind = cast(Literal["open", "standalone", "close"], _string(json, "kind"))
if kind not in {"open", "standalone", "close"}:
raise MF2ValidationError(f"Invalid JSON markup kind: {json}")
name = _string(json, "name")
options = _options(json["options"]) if "options" in json else {}
attributes = _attributes(json["attributes"]) if "attributes" in json else {}
return msg.Markup(kind, name, options, attributes)


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]:
return {
name: None if json_value is True else _literal(json_value)
for name, json_value in json.items()
}


def _key(json: dict[str, Any]) -> str | msg.CatchallKey:
type = json["type"]
if type == "literal":
return _string(json, "value")
elif json["type"] == "*":
value = _string(json, "value") if "value" in json else None
return msg.CatchallKey(value)
else:
raise MF2ValidationError(f"Invalid JSON variant key: {json}")


def _value(json: dict[str, Any]) -> str | msg.VariableRef:
return _string(json, "value") if json["type"] == "literal" else _variable(json)


def _literal(json: dict[str, Any]) -> str:
if json["type"] != "literal":
raise MF2ValidationError(f"Invalid JSON literal: {json}")
return _string(json, "value")


def _variable(json: dict[str, Any]) -> msg.VariableRef:
if json["type"] != "variable":
raise MF2ValidationError(f"Invalid JSON variable: {json}")
return msg.VariableRef(_string(json, "name"))


def _string(obj: dict[str, Any], key: str | None = None) -> str:
value = obj if key is None else obj.get(key, None)
if isinstance(value, str):
return value
else:
raise MF2ValidationError(f"Expected a string value for {key} in {obj}")
4 changes: 2 additions & 2 deletions moz/l10n/formats/mf2/message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ def expression_body(self, ch: str) -> msg.Expression:
arg_end = self.pos
ch = self.skip_opt_space()
if ch == ":":
if self.pos == arg_end:
raise MF2ParseError(self, "Espected space")
if arg and self.pos == arg_end:
raise MF2ParseError(self, "Expected space")
function = self.identifier(1)
options = self.options()
else:
Expand Down
130 changes: 130 additions & 0 deletions moz/l10n/formats/mf2/to_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 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, Literal

from ...message import data as msg


def mf2_to_json(message: msg.Message) -> dict[str, Any]:
"""
Represent a message using the MessageFormat 2 data model [JSON Schema](https://github.com/unicode-org/message-format-wg/blob/main/spec/data-model/message.json).
Does not validate the message; for that, use `mf2_validate_message()`.
"""
json_declarations = [
{
"type": (
"input"
if isinstance(expr.arg, msg.VariableRef) and expr.arg.name == name
else "local"
),
"name": name,
"value": _expression(expr),
}
for name, expr in message.declarations.items()
]

if isinstance(message, msg.PatternMessage):
return {
"type": "message",
"declarations": json_declarations,
"pattern": _pattern(message.pattern),
}
else:
assert isinstance(message, msg.SelectMessage)
return {
"type": "select",
"declarations": json_declarations,
"selectors": [_variable(sel) for sel in message.selectors],
"variants": [
{"keys": [_key(key) for key in keys], "value": _pattern(pattern)}
for keys, pattern in message.variants.items()
],
}


def _pattern(pattern: msg.Pattern) -> list[Any]:
return [
part
if isinstance(part, str)
else _markup(part)
if isinstance(part, msg.Markup)
else _expression(part)
for part in pattern
]


def _expression(expr: msg.Expression) -> dict[str, str | dict[str, Any]]:
json: dict[str, Any] = {"type": "expression"}
if expr.arg is not None:
json["arg"] = _value(expr.arg)
if expr.function is not None:
json_func: dict[str, Any] = {"type": "function", "name": expr.function}
if expr.options:
json_func["options"] = _options(expr.options)
json["function"] = json_func
if expr.attributes:
json["attributes"] = _attributes(expr.attributes)
return json


def _markup(markup: msg.Markup) -> dict[str, str | dict[str, Any]]:
json: dict[str, Any] = {
"type": "markup",
"kind": markup.kind,
"name": markup.name,
}
if markup.options:
json["options"] = _options(markup.options)
if markup.attributes:
json["attributes"] = _attributes(markup.attributes)
return json


def _options(options: dict[str, str | msg.VariableRef]) -> dict[str, dict[str, str]]:
return {name: _value(value) for name, value in options.items()}


def _attributes(
attributes: dict[str, str | None],
) -> dict[str, dict[str, str] | Literal[True]]:
return {
name: True if value is None else _literal(value)
for name, value in attributes.items()
}


def _key(key: str | msg.CatchallKey) -> str | dict[str, str]:
if isinstance(key, str):
return _literal(key)
else:
json = {"type": "*"}
if key.value is not None:
json["value"] = key.value
return json


def _value(value: str | msg.VariableRef) -> dict[str, str]:
return _literal(value) if isinstance(value, str) else _variable(value)


def _literal(value: str) -> dict[str, str]:
return {"type": "literal", "value": value}


def _variable(var: msg.VariableRef) -> dict[str, str]:
return {"type": "variable", "name": var.name}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ include = ["moz.l10n*"]
[tool.uv]
dev-dependencies = [
"importlib-resources>=6.4.5",
"jsonschema>=4.23.0",
"mypy>=1.11.2",
"pytest>=8.3.3",
"ruff>=0.6.8",
Expand Down
Loading

0 comments on commit e423d5b

Please sign in to comment.