messageformat2 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from babel import Locale
5
+
6
+ from messageformat2.builtins import Registry
7
+ from messageformat2.errors import (
8
+ InvalidExpression,
9
+ SelectionError,
10
+ UnknownFunction,
11
+ UnresolvedVariable,
12
+ UnsupportedExpression,
13
+ UnsupportedStatement,
14
+ )
15
+ from messageformat2.parser import (
16
+ CatchallKey,
17
+ Expression,
18
+ FunctionAnnotation,
19
+ FunctionExpression,
20
+ InputDeclaration,
21
+ Literal,
22
+ LiteralExpression,
23
+ Markup,
24
+ Message,
25
+ Option,
26
+ Pattern,
27
+ PatternMessage,
28
+ SelectMessage,
29
+ UnsupportedAnnotation,
30
+ VariableExpression,
31
+ VariableRef,
32
+ Variant,
33
+ )
34
+ from messageformat2.parser import (
35
+ UnsupportedExpression as _UnsupportedExpression,
36
+ )
37
+ from messageformat2.parser import UnsupportedStatement as _UnsupportedStatement
38
+
39
+
40
+ @dataclass
41
+ class FormattingContext:
42
+ locale: Locale
43
+ inputs: dict[str, Any]
44
+ registry: Registry
45
+ declarations: dict[str, Any]
46
+ strict: bool = True
47
+
48
+
49
+ class LazyValue:
50
+ def __init__(self, *, fn_name: str, value: Any = None, options: dict[str, Any] | None = None):
51
+ self.fn_name = fn_name
52
+ self.value = value
53
+ self.options = options or {}
54
+
55
+ def format(self, ctx: FormattingContext) -> str:
56
+ if annotation := ctx.registry.formatters.get(self.fn_name):
57
+ try:
58
+ return str(annotation(value=self.value, locale=ctx.locale, options=self.options))
59
+ except InvalidExpression:
60
+ raise
61
+ except Exception as e:
62
+ msg = "Exception raised while evaluating formatter"
63
+ raise InvalidExpression(msg) from e
64
+ msg = f"Unknown function: {self.fn_name}"
65
+ raise UnknownFunction(msg)
66
+
67
+ def select(self, ctx: FormattingContext, *, keys: list[str]) -> list[str]:
68
+ if selector := ctx.registry.selectors.get(self.fn_name):
69
+ try:
70
+ return selector(value=self.value, locale=ctx.locale, options=self.options, keys=keys)
71
+ except SelectionError:
72
+ raise
73
+ except Exception as e:
74
+ msg = "Exception raised while evaluating selector"
75
+ raise SelectionError(msg) from e
76
+ msg = f"Unknown selector: {self.fn_name}"
77
+ raise UnknownFunction(msg)
78
+
79
+ def __repr__(self):
80
+ return f"LazyValue({self.fn_name}({self.value}, {self.options}))"
81
+
82
+
83
+ def format_message(message: Message, locale: Locale, inputs: dict[str, Any], registry: Registry) -> str:
84
+ ctx = FormattingContext(
85
+ locale=locale,
86
+ inputs=inputs,
87
+ registry=registry,
88
+ declarations={},
89
+ )
90
+ match message:
91
+ case PatternMessage():
92
+ return format_pattern_message(message, ctx)
93
+ case _:
94
+ return format_select_message(message, ctx)
95
+
96
+
97
+ def format_pattern_message(message: PatternMessage, ctx: FormattingContext) -> str:
98
+ for decl in message.declarations:
99
+ match decl:
100
+ case _UnsupportedStatement():
101
+ msg = "Unsupported statement"
102
+ raise UnsupportedStatement(msg)
103
+ case _:
104
+ ctx.declarations[decl.name] = decl
105
+ return format_pattern(message.pattern, ctx)
106
+
107
+
108
+ def format_pattern(pattern: Pattern, ctx: FormattingContext) -> str:
109
+ output = ""
110
+ for part in pattern:
111
+ match part:
112
+ case str():
113
+ output += part
114
+ case LiteralExpression() | VariableExpression() | FunctionExpression() | _UnsupportedExpression():
115
+ output += format_expression(part, ctx)
116
+ case Markup():
117
+ output += format_markup(part, ctx)
118
+ return output
119
+
120
+
121
+ def format_select_message(message: SelectMessage, ctx: FormattingContext) -> str:
122
+ for decl in message.declarations:
123
+ match decl:
124
+ case _UnsupportedStatement():
125
+ msg = "Unsupported statement"
126
+ raise UnsupportedStatement(msg)
127
+ case _:
128
+ ctx.declarations[decl.name] = decl
129
+
130
+ selectors = [resolve_selector(selector, ctx) for selector in message.selectors]
131
+
132
+ pref = []
133
+ for i, selector in enumerate(selectors):
134
+ keys = [key.value for v in message.variants if isinstance(key := v.keys[i], Literal)]
135
+ pref.append(selector.select(ctx, keys=keys))
136
+
137
+ variants = filter_variants(pref, message.variants)
138
+ pattern = sort_variants(pref, variants)
139
+ return format_pattern(pattern, ctx)
140
+
141
+
142
+ def filter_variants(pref: list, variants: list[Variant]) -> list[Variant]:
143
+ filtered = []
144
+ for v in variants:
145
+ include = True
146
+ for i, matches in enumerate(pref):
147
+ key = v.keys[i]
148
+ if isinstance(key, CatchallKey):
149
+ continue
150
+ assert isinstance(key, Literal)
151
+ ks = key.value
152
+ if ks not in matches:
153
+ include = False
154
+ if include:
155
+ filtered.append(v)
156
+ return filtered
157
+
158
+
159
+ def sort_variants(pref: list, variants: list[Variant]) -> Pattern:
160
+ sortable = [[-1, v] for v in variants]
161
+
162
+ i = len(pref) - 1
163
+ while i >= 0:
164
+ matches = pref[i]
165
+ minpref = len(matches)
166
+ for s in sortable:
167
+ matchpref = minpref
168
+ key = s[1].keys[i]
169
+ if not isinstance(key, CatchallKey):
170
+ assert isinstance(key, Literal)
171
+ ks = key.value
172
+ matchpref = matches.index(ks)
173
+ s[0] = matchpref
174
+ sortable.sort(key=lambda x: x[0])
175
+ i = i - 1
176
+ return sortable[0][1].value
177
+
178
+
179
+ def resolve_selector(selector: Expression, ctx: FormattingContext) -> Any | LazyValue:
180
+ return resolve_expression(selector, ctx)
181
+
182
+
183
+ def format_expression(expression: Expression, ctx: FormattingContext) -> str:
184
+ resolved = resolve_expression(expression, ctx)
185
+ match resolved:
186
+ case LazyValue() as lazy_value:
187
+ return lazy_value.format(ctx)
188
+ case _:
189
+ return str(resolved)
190
+
191
+
192
+ def format_markup(markup: Markup, ctx: FormattingContext) -> str:
193
+ options = format_options(markup.options, ctx)
194
+ options = f" {options}" if options else ""
195
+
196
+ if markup.kind == "standalone":
197
+ return f"<{markup.name}{options}/>"
198
+ if markup.kind == "open":
199
+ return f"<{markup.name}{options}>"
200
+ return f"</{markup.name}{options}>"
201
+
202
+
203
+ def format_options(options: list[Option], ctx: FormattingContext) -> str:
204
+ return " ".join(format_option(opt, ctx) for opt in options)
205
+
206
+
207
+ def format_option(option: Option, ctx: FormattingContext) -> str:
208
+ value = resolve_option(option, ctx)
209
+ return f"{option.name}={value}"
210
+
211
+
212
+ def resolve_options(options: list[Option], ctx: FormattingContext) -> dict[str, Any]:
213
+ return {opt.name: resolve_option(opt, ctx) for opt in options}
214
+
215
+
216
+ def resolve_option(option: Option, ctx: FormattingContext) -> Any:
217
+ match option.value:
218
+ case Literal(value=value):
219
+ return value
220
+ case VariableRef(name=name):
221
+ resolved = resolve_variable(name, ctx)
222
+ match resolved:
223
+ case LazyValue() as lazy_value:
224
+ return lazy_value.format(ctx)
225
+ case _:
226
+ return resolved
227
+
228
+
229
+ def resolve_expression(expression: Expression, ctx: FormattingContext) -> Any | LazyValue:
230
+ match expression:
231
+ case LiteralExpression(arg=Literal(value), annotation=annotation):
232
+ if not annotation:
233
+ return value
234
+ match annotation:
235
+ case FunctionAnnotation(name=name, options=options):
236
+ options = resolve_options(options, ctx)
237
+ return LazyValue(fn_name=name, value=value, options=options)
238
+ case UnsupportedAnnotation():
239
+ msg = "Unsupported expression"
240
+ raise UnsupportedExpression(msg)
241
+ case VariableExpression(arg=VariableRef(ref), annotation=annotation):
242
+ if not annotation:
243
+ return resolve_variable(ref, ctx)
244
+ match annotation:
245
+ case FunctionAnnotation(name=name, options=options):
246
+ resolved = resolve_variable(ref, ctx)
247
+ options = resolve_options(options, ctx)
248
+ match resolved:
249
+ case LazyValue(fn_name=lazy_function, value=lazy_value, options=lazy_options):
250
+ if lazy_function and lazy_function != name:
251
+ msg = f"Function mismatch: {lazy_function} != {name}"
252
+ raise ValueError(msg)
253
+ return LazyValue(fn_name=name, value=lazy_value, options=(lazy_options or {}) | options)
254
+ case _:
255
+ return LazyValue(fn_name=name, value=resolved, options=options)
256
+ case UnsupportedAnnotation():
257
+ msg = "Unsupported expression"
258
+ raise UnsupportedExpression(msg)
259
+ case FunctionExpression(annotation=FunctionAnnotation(name=name, options=options)):
260
+ options = resolve_options(options, ctx)
261
+ return LazyValue(fn_name=name, value=None, options=options)
262
+ case _UnsupportedExpression(annotation=annotation):
263
+ msg = f"Unsupported expression: {annotation}"
264
+ raise UnsupportedExpression(msg)
265
+
266
+
267
+ def resolve_variable(name: str, ctx: FormattingContext) -> Any:
268
+ if name in ctx.declarations:
269
+ decl = ctx.declarations[name]
270
+ match decl:
271
+ case InputDeclaration(value=VariableExpression(annotation=annotation)):
272
+ if not annotation:
273
+ return resolve_global(name, ctx)
274
+ match annotation:
275
+ case FunctionAnnotation(name=fn_name, options=options):
276
+ resolved = resolve_global(name, ctx)
277
+ options = resolve_options(options, ctx)
278
+ match resolved:
279
+ case LazyValue(fn_name=lazy_fn_name, value=lazy_value, options=lazy_options):
280
+ if lazy_fn_name and lazy_fn_name != fn_name:
281
+ msg = f"Function mismatch: {lazy_fn_name} != {fn_name}"
282
+ raise ValueError(msg)
283
+ return LazyValue(
284
+ fn_name=fn_name, value=lazy_value, options=(lazy_options or {}) | options
285
+ )
286
+ case _:
287
+ return LazyValue(fn_name=fn_name, value=resolved, options=options)
288
+ case UnsupportedAnnotation(annotation=annotation):
289
+ msg = f"Unsupported expression: {annotation}"
290
+ raise UnsupportedExpression(msg)
291
+ case _:
292
+ expression = decl.value
293
+ return resolve_expression(expression, ctx)
294
+ return resolve_global(name, ctx)
295
+
296
+
297
+ def resolve_global(name: str, ctx: FormattingContext) -> Any:
298
+ if name in ctx.inputs:
299
+ return ctx.inputs[name]
300
+ msg = f"Unresolved variable: {name}"
301
+ raise UnresolvedVariable(msg)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) European Organization for Nuclear Research (CERN)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.1
2
+ Name: messageformat2
3
+ Version: 0.1.0
4
+ Summary: Python implementation of Unicode MessageFormat 2.0
5
+ Author-email: Tomas Roun <tomas.roun8@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) European Organization for Nuclear Research (CERN)
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/tomasr8/messageformat2
29
+ Project-URL: Github, https://github.com/tomasr8/messageformat2
30
+ Keywords: i18n,internationalization,localization,messageformat,messageformat 2,cldr,gettext
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Classifier: Topic :: Scientific/Engineering
36
+ Classifier: Topic :: Software Development :: Internationalization
37
+ Classifier: Topic :: Software Development :: Localization
38
+ Requires-Python: >=3.12
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: Babel
42
+ Requires-Dist: click
43
+ Provides-Extra: dev
44
+ Requires-Dist: build ; extra == 'dev'
45
+ Requires-Dist: twine ; extra == 'dev'
46
+ Requires-Dist: uv ; extra == 'dev'
47
+ Requires-Dist: pytest ; extra == 'dev'
48
+ Requires-Dist: pytest-snapshot ; extra == 'dev'
49
+ Requires-Dist: pytest-cov ; extra == 'dev'
50
+ Requires-Dist: ruff ; extra == 'dev'
51
+ Requires-Dist: pyright ; extra == 'dev'
52
+ Requires-Dist: mkdocs ; extra == 'dev'
53
+ Requires-Dist: mkdocstrings[python] ; extra == 'dev'
54
+ Requires-Dist: mkdocs-material ; extra == 'dev'
55
+ Provides-Extra: docs
56
+ Requires-Dist: mkdocs ; extra == 'docs'
57
+ Requires-Dist: mkdocstrings[python] ; extra == 'docs'
58
+ Requires-Dist: mkdocs-material ; extra == 'docs'
59
+
60
+
61
+
62
+ # MessageFormat2 [![Documentation Status](https://readthedocs.org/projects/messageformat2/badge/?version=latest)](https://messageformat2.readthedocs.io/en/latest/?badge=latest) [![CI](https://github.com/tomasr8/messageformat2/workflows/CI/badge.svg)](https://github.com/tomasr8/messageformat2/workflows/CI/badge.svg) [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
63
+
64
+ This is a Python implementation of the [Unicode Message Format 2.0
65
+ specification](https://www.unicode.org/reports/tr35/tr35-72/tr35-messageFormat.html).
66
+
67
+ Message Format 2.0 (MF2) is a new iteration of Message Format which aims to be
68
+ more expressive and flexible than both Message Format 1.0 and, in the context of
69
+ Python, gettext.
70
+
71
+ If you would like to learn more about MF2 itself head over to the [Unicode technical
72
+ specification](https://www.unicode.org/reports/tr35/tr35-72/tr35-messageFormat.html).
73
+
74
+ ## Features
75
+
76
+ The library includes:
77
+
78
+ - A message parser and a formatter
79
+ - Several builtin formatters and selectors described by the
80
+ [spec](https://www.unicode.org/reports/tr35/tr35-72/tr35-messageFormat.html#function-registry)
81
+ and the possibility to write custom formatters/selectors
82
+ - Helper classes to inspect and transform the message [data
83
+ model](https://www.unicode.org/reports/tr35/tr35-72/tr35-messageFormat.html#interchange-data-model)
84
+
85
+ ## [Documentation](https://messageformat2.readthedocs.io/en/latest/)
86
+
87
+ ## Installation
88
+
89
+ Via pip for Python `>= 3.12`
90
+
91
+ ```sh
92
+ pip install messageformat2
93
+ ```
94
+
95
+ ## Examples
96
+
97
+ ### Simple messages
98
+
99
+ ```python
100
+ from messageformat2 import format_message
101
+
102
+ format_message("Hello, {$name}!", {"name": "Alice"}) # -> "Hello, Alice!"
103
+ ```
104
+
105
+ Messages can be reused with different inputs.
106
+
107
+ ```python
108
+ from messageformat2 import Message
109
+
110
+ message = Message("Hello, {$name}!")
111
+ message.format({"name": "Alice"}) # -> "Hello, Alice!"
112
+ message.format({"name": "Bob"}) # -> "Hello, Bob!"
113
+ ```
114
+
115
+ ### Built-in formatters
116
+
117
+ There are several builtin locale-aware formatters available.
118
+
119
+ ```python
120
+ from datetime import datetime
121
+ from messageformat2 import Message
122
+
123
+ message = Message("Today's date is {$now :date}")
124
+ now = datetime.now()
125
+ message.format({"now": now}, locale='en_US')
126
+ # -> Today's date is 5/10/2024
127
+ message.format({"now": now}, locale='en_GB')
128
+ # -> Today's date is 10/05/2024
129
+ ```
130
+
131
+ ### Plural support
132
+
133
+ MF2 supports pluralization using built-in or custom selectors.
134
+
135
+ ```python
136
+ from messageformat2 import Message
137
+
138
+ msg = Message ("""\
139
+ .match {$count :number}
140
+ one {{You have {$count} notification.}}
141
+ * {{You have {$count} notifications.}}
142
+ """)
143
+
144
+ msg.format({"count": 42}, locale='en_US') # -> "You have 42 notifications."
145
+ msg.format({"count": 1}, locale='en_US') # -> "You have 1 notification."
146
+ ```
147
+
148
+ Complete documentation is available [here](https://messageformat2.readthedocs.io/en/latest/).
@@ -0,0 +1,14 @@
1
+ messageformat2/__init__.py,sha256=1A2OfD38uErtOd27xMUDA1BoRAMuvDj95QVPTQuMexI,123
2
+ messageformat2/builtins.py,sha256=lJV-ibYu0kk1TwJxkA4NK_mJjdSV6UnLq6CgldZ0DAg,10612
3
+ messageformat2/cli.py,sha256=n_cR9jOYg9aoC3VZ6_sReZ7UM-i8iTtiMWvI5mqEJyU,1421
4
+ messageformat2/datamodel.py,sha256=3MRPau_An48cxOoPAW2JJCornFbLzj823bXyxLxVUI0,15486
5
+ messageformat2/errors.py,sha256=Tli-83XlHhG1ia5fbLcFLnZUISyWSJ79yHsJNaKlt98,4447
6
+ messageformat2/message.py,sha256=iLwKjzkrfN_8-oVFL8-sPeAoHKh9vwhHZP96ZXhv5YM,4159
7
+ messageformat2/parser.py,sha256=6TxFQma0pODuBCz7ArHpKvh53GfvnK8rLDleqiHupK8,17023
8
+ messageformat2/runtime.py,sha256=Ml9EsmLiAExAN8l8H8EzsMXHHlq7kBWSBYKH-o1uzi0,11123
9
+ messageformat2-0.1.0.dist-info/LICENSE,sha256=yPtRmLQVOw1jZIJ9HeJ6Su2-NA_RAXG53XdfeQ7Vyvk,1101
10
+ messageformat2-0.1.0.dist-info/METADATA,sha256=hzsAE8JBrjPsK3cV0wN4FYRUzS2a3tzd3MlxFzzwE3c,5665
11
+ messageformat2-0.1.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
12
+ messageformat2-0.1.0.dist-info/entry_points.txt,sha256=yQ3aIPQ4zPI188NgLN6F49a7kspObfZFuDBkNpVT0NU,47
13
+ messageformat2-0.1.0.dist-info/top_level.txt,sha256=XlAc7JtB9R1O9vO7viztX1fWAkiucBMmwNY8oG_7poA,15
14
+ messageformat2-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mf2 = messageformat2.cli:cli
@@ -0,0 +1 @@
1
+ messageformat2