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.
- messageformat2/__init__.py +5 -0
- messageformat2/builtins.py +323 -0
- messageformat2/cli.py +51 -0
- messageformat2/datamodel.py +507 -0
- messageformat2/errors.py +151 -0
- messageformat2/message.py +127 -0
- messageformat2/parser.py +557 -0
- messageformat2/runtime.py +301 -0
- messageformat2-0.1.0.dist-info/LICENSE +21 -0
- messageformat2-0.1.0.dist-info/METADATA +148 -0
- messageformat2-0.1.0.dist-info/RECORD +14 -0
- messageformat2-0.1.0.dist-info/WHEEL +5 -0
- messageformat2-0.1.0.dist-info/entry_points.txt +2 -0
- messageformat2-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import datetime as _datetime
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Literal, Protocol, Self
|
|
4
|
+
|
|
5
|
+
from babel import Locale
|
|
6
|
+
from babel.dates import format_date as _format_date
|
|
7
|
+
from babel.dates import format_datetime as _format_datetime
|
|
8
|
+
from babel.dates import format_time as _format_time
|
|
9
|
+
from babel.dates import match_skeleton
|
|
10
|
+
from babel.numbers import format_compact_decimal, format_decimal, format_percent, format_scientific
|
|
11
|
+
|
|
12
|
+
from messageformat2.errors import InvalidExpression
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_skeleton(
|
|
16
|
+
skeleton: str,
|
|
17
|
+
dt: _datetime.datetime,
|
|
18
|
+
locale: Locale,
|
|
19
|
+
) -> str:
|
|
20
|
+
locale = Locale.parse(locale)
|
|
21
|
+
matched = skeleton
|
|
22
|
+
if skeleton not in locale.datetime_skeletons:
|
|
23
|
+
matched = match_skeleton(
|
|
24
|
+
skeleton,
|
|
25
|
+
locale.datetime_skeletons,
|
|
26
|
+
allow_different_fields=True)
|
|
27
|
+
fmt = locale.datetime_skeletons[matched]
|
|
28
|
+
return _format_datetime(dt, format=fmt, locale=locale)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Formatter(Protocol):
|
|
32
|
+
def __call__(self, value: Any, locale: Locale, options: dict[str, Any]) -> Any: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Selector(Protocol):
|
|
36
|
+
def __call__(self, value: Any, locale: Locale, options: dict[str, Any], keys: list[str]) -> list[str]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def string_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str: # noqa: ARG001
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def string_selector(value: Any, locale: Locale, options: dict[str, Any], keys: list[str]) -> list[str]:
|
|
44
|
+
string = string_formatter(value, locale=locale, options=options)
|
|
45
|
+
return [string] if string in keys else []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_number( # noqa: PLR0913
|
|
49
|
+
number: Any,
|
|
50
|
+
*,
|
|
51
|
+
locale: Locale,
|
|
52
|
+
compactDisplay: Literal["short", "long"] = "short",
|
|
53
|
+
notation: Literal["standard", "scientific", "engineering", "compact"] = "standard",
|
|
54
|
+
numberingSystem: str = "default",
|
|
55
|
+
signDisplay: Literal["auto", "always", "exceptZero", "negative", "never"] = "auto", # noqa: ARG001
|
|
56
|
+
style: Literal["decimal", "percent"] = "decimal",
|
|
57
|
+
useGrouping: Literal["auto", "always", "never", "min2"] = "auto",
|
|
58
|
+
minimumFractionDigits: int | None = None, # noqa: ARG001
|
|
59
|
+
maximumFractionDigits: int | None = None, # noqa: ARG001
|
|
60
|
+
**kwargs, # noqa: ARG001
|
|
61
|
+
) -> str:
|
|
62
|
+
if notation == "compact":
|
|
63
|
+
# fraction_digits =
|
|
64
|
+
return format_compact_decimal(
|
|
65
|
+
number, format_type=compactDisplay, locale=locale, numbering_system=numberingSystem
|
|
66
|
+
)
|
|
67
|
+
if notation in ("scientific", "engineering"):
|
|
68
|
+
# babel has 'format_engineering' method..
|
|
69
|
+
return format_scientific(number, locale=locale, numbering_system=numberingSystem)
|
|
70
|
+
group_separator = useGrouping != "never"
|
|
71
|
+
if style == "percent":
|
|
72
|
+
return format_percent(
|
|
73
|
+
number,
|
|
74
|
+
locale=locale,
|
|
75
|
+
group_separator=group_separator,
|
|
76
|
+
numbering_system=numberingSystem,
|
|
77
|
+
)
|
|
78
|
+
return format_decimal(number, locale=locale, group_separator=group_separator, numbering_system=numberingSystem)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def keyword_selector(
|
|
82
|
+
number: float, *, locale: Locale, keys: list[str], select: Literal["exact", "plural", "ordinal"] = "plural"
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
if select == "plural":
|
|
85
|
+
keyword = locale.plural_form(number)
|
|
86
|
+
elif select == "ordinal":
|
|
87
|
+
keyword = locale.ordinal_form(number)
|
|
88
|
+
else:
|
|
89
|
+
msg = f"Unknown select type: {select}"
|
|
90
|
+
raise InvalidExpression(msg)
|
|
91
|
+
|
|
92
|
+
return [keyword] if keyword in keys else []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def number_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str:
|
|
96
|
+
return format_number(value, locale=locale, **options)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def number_selector(value: Any, locale: Locale, options: dict[str, Any], keys: list[str]) -> list[str]:
|
|
100
|
+
select = options.get("select", "plural")
|
|
101
|
+
number = format_number(value, locale=locale, **options)
|
|
102
|
+
|
|
103
|
+
exact_keys = [number] if number in keys else []
|
|
104
|
+
if select == "exact":
|
|
105
|
+
return exact_keys
|
|
106
|
+
keyword_keys = keyword_selector(value, locale=locale, keys=keys, select=select)
|
|
107
|
+
return exact_keys + keyword_keys
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_integer( # noqa: PLR0913
|
|
111
|
+
value: Any,
|
|
112
|
+
*,
|
|
113
|
+
locale: Locale,
|
|
114
|
+
numberingSystem: str = "default",
|
|
115
|
+
signDisplay: Literal["auto", "always", "exceptZero", "negative", "never"] = "auto", # noqa: ARG001
|
|
116
|
+
style: Literal["decimal", "percent"] = "decimal",
|
|
117
|
+
useGrouping: Literal["auto", "always", "min2"] = "auto", # noqa: ARG001
|
|
118
|
+
minimumIntegerDigits: int | None = 1, # noqa: ARG001
|
|
119
|
+
maximumSignificantDigits: int | None = None, # noqa: ARG001
|
|
120
|
+
**kwargs, # noqa: ARG001
|
|
121
|
+
) -> str:
|
|
122
|
+
if style == "percent":
|
|
123
|
+
return format_percent(
|
|
124
|
+
value,
|
|
125
|
+
locale=locale,
|
|
126
|
+
numbering_system=numberingSystem,
|
|
127
|
+
)
|
|
128
|
+
if style == "decimal":
|
|
129
|
+
return format_decimal(
|
|
130
|
+
value,
|
|
131
|
+
locale=locale,
|
|
132
|
+
numbering_system=numberingSystem,
|
|
133
|
+
)
|
|
134
|
+
msg = f"Unknown style: {style}"
|
|
135
|
+
raise InvalidExpression(msg)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def integer_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str: # noqa: ARG001
|
|
139
|
+
return str(int(value))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def integer_selector(value: Any, locale: Locale, options: dict[str, Any], keys: list[str]) -> list[str]:
|
|
143
|
+
select = options.get("select", "plural")
|
|
144
|
+
integer = format_integer(value, locale=locale, **options)
|
|
145
|
+
|
|
146
|
+
exact_keys = [integer] if integer in keys else []
|
|
147
|
+
if select == "exact":
|
|
148
|
+
return exact_keys
|
|
149
|
+
keyword_keys = keyword_selector(value, locale=locale, keys=keys, select=select)
|
|
150
|
+
return exact_keys + keyword_keys
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_datetime( # noqa: PLR0915, PLR0913, PLR0912, C901
|
|
154
|
+
dt: _datetime.datetime,
|
|
155
|
+
locale: Locale,
|
|
156
|
+
dateStyle: Literal["full", "long", "medium", "short"] | None = None,
|
|
157
|
+
timeStyle: Literal["full", "long", "medium", "short"] | None = None, # noqa: ARG001
|
|
158
|
+
weekday: Literal["long", "short", "narrow"] | None = None,
|
|
159
|
+
era: Literal["long", "short", "narrow"] | None = None,
|
|
160
|
+
year: Literal["numeric", "2-digit"] | None = None,
|
|
161
|
+
month: Literal["numeric", "2-digit", "long", "short", "narrow"] | None = None,
|
|
162
|
+
day: Literal["numeric", "2-digit"] | None = None,
|
|
163
|
+
hour: Literal["numeric", "2-digit"] | None = None,
|
|
164
|
+
minute: Literal["numeric", "2-digit"] | None = None,
|
|
165
|
+
second: Literal["numeric", "2-digit"] | None = None,
|
|
166
|
+
fractionalSecondDigits: Literal["1", "2", "3"] | None = None,
|
|
167
|
+
hourCycle: Literal["h11", "h12", "h23", "h24"] | None = None,
|
|
168
|
+
timeZoneName: Literal["long", "short", "shortOffset", "longOffset", "shortGeneric", "longGeneric"] | None = None, # noqa: ARG001
|
|
169
|
+
**kwargs, # noqa: ARG001
|
|
170
|
+
) -> str:
|
|
171
|
+
if dateStyle:
|
|
172
|
+
return _format_datetime(dt, format=dateStyle, locale=locale)
|
|
173
|
+
|
|
174
|
+
skeleton = ""
|
|
175
|
+
if weekday:
|
|
176
|
+
if weekday == "short":
|
|
177
|
+
skeleton += "EEE"
|
|
178
|
+
elif weekday == "long":
|
|
179
|
+
skeleton += "EEEE"
|
|
180
|
+
else:
|
|
181
|
+
skeleton += "EEEEE"
|
|
182
|
+
if era:
|
|
183
|
+
if era == "short":
|
|
184
|
+
skeleton += "GGG"
|
|
185
|
+
elif era == "long":
|
|
186
|
+
skeleton += "GGGG"
|
|
187
|
+
else:
|
|
188
|
+
skeleton += "GGGGG"
|
|
189
|
+
|
|
190
|
+
if year:
|
|
191
|
+
if year == "numeric":
|
|
192
|
+
skeleton += "y"
|
|
193
|
+
else:
|
|
194
|
+
skeleton += "yy"
|
|
195
|
+
|
|
196
|
+
if month:
|
|
197
|
+
if month == "numeric":
|
|
198
|
+
skeleton += "M"
|
|
199
|
+
elif month == "2-digit":
|
|
200
|
+
skeleton += "MM"
|
|
201
|
+
elif month == "short":
|
|
202
|
+
skeleton += "MMM"
|
|
203
|
+
elif month == "narrow":
|
|
204
|
+
skeleton += "MMMMM"
|
|
205
|
+
else:
|
|
206
|
+
skeleton += "MMMM"
|
|
207
|
+
|
|
208
|
+
if day:
|
|
209
|
+
if day == "numeric":
|
|
210
|
+
skeleton += "d"
|
|
211
|
+
else:
|
|
212
|
+
skeleton += "dd"
|
|
213
|
+
|
|
214
|
+
if hour:
|
|
215
|
+
h = "h"
|
|
216
|
+
if hourCycle:
|
|
217
|
+
if hourCycle == "h11":
|
|
218
|
+
h = "K"
|
|
219
|
+
elif hourCycle == "h12":
|
|
220
|
+
h = "h"
|
|
221
|
+
elif hourCycle == "h23":
|
|
222
|
+
h = "H"
|
|
223
|
+
elif hourCycle == "h24":
|
|
224
|
+
h = "k"
|
|
225
|
+
|
|
226
|
+
if hour == "numeric":
|
|
227
|
+
skeleton += f"{h}"
|
|
228
|
+
else:
|
|
229
|
+
skeleton += f"{h}{h}"
|
|
230
|
+
|
|
231
|
+
if minute:
|
|
232
|
+
if minute == "numeric":
|
|
233
|
+
skeleton += "m"
|
|
234
|
+
else:
|
|
235
|
+
skeleton += "mm"
|
|
236
|
+
|
|
237
|
+
if second:
|
|
238
|
+
if second == "numeric":
|
|
239
|
+
skeleton += "s"
|
|
240
|
+
else:
|
|
241
|
+
skeleton += "ss"
|
|
242
|
+
|
|
243
|
+
if fractionalSecondDigits:
|
|
244
|
+
if fractionalSecondDigits == "1":
|
|
245
|
+
skeleton += "S"
|
|
246
|
+
elif fractionalSecondDigits == "2":
|
|
247
|
+
skeleton += "SS"
|
|
248
|
+
else:
|
|
249
|
+
skeleton += "SSS"
|
|
250
|
+
|
|
251
|
+
return format_skeleton(skeleton, dt, locale=locale)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def datetime_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str:
|
|
255
|
+
if isinstance(value, str):
|
|
256
|
+
value = _datetime.datetime.fromisoformat(value)
|
|
257
|
+
return _format_datetime(value, locale=locale, **options)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def format_date(
|
|
261
|
+
dt: _datetime.datetime,
|
|
262
|
+
*,
|
|
263
|
+
locale: Locale,
|
|
264
|
+
style: Literal["full", "long", "medium", "short"] = "short",
|
|
265
|
+
**kwargs, # noqa: ARG001
|
|
266
|
+
) -> str:
|
|
267
|
+
return _format_date(dt, format=style, locale=locale)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def date_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str:
|
|
271
|
+
if isinstance(value, str):
|
|
272
|
+
value = _datetime.datetime.fromisoformat(value)
|
|
273
|
+
return format_date(value, locale=locale, **options)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def format_time(
|
|
277
|
+
time: _datetime.time,
|
|
278
|
+
*,
|
|
279
|
+
locale: Locale,
|
|
280
|
+
style: Literal["full", "long", "medium", "short"] = "short",
|
|
281
|
+
**kwargs, # noqa: ARG001
|
|
282
|
+
) -> str:
|
|
283
|
+
return _format_time(time, format=style, locale=locale)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def time_formatter(value: Any, locale: Locale, options: dict[str, Any]) -> str:
|
|
287
|
+
if isinstance(value, str):
|
|
288
|
+
value = _datetime.datetime.fromisoformat(value)
|
|
289
|
+
if isinstance(value, _datetime.datetime):
|
|
290
|
+
value = value.time()
|
|
291
|
+
return format_time(value, locale=locale, **options)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class Registry:
|
|
296
|
+
formatters: dict[str, Formatter] = field(default_factory=dict)
|
|
297
|
+
selectors: dict[str, Selector] = field(default_factory=dict)
|
|
298
|
+
|
|
299
|
+
def extend(
|
|
300
|
+
self, *, formatters: dict[str, Formatter] | None = None, selectors: dict[str, Selector] | None = None
|
|
301
|
+
) -> Self:
|
|
302
|
+
return type(self)(
|
|
303
|
+
formatters={**self.formatters, **(formatters or {})},
|
|
304
|
+
selectors={**self.selectors, **(selectors or {})},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
default_registry = Registry(
|
|
309
|
+
formatters={
|
|
310
|
+
"string": string_formatter,
|
|
311
|
+
"number": number_formatter,
|
|
312
|
+
"integer": integer_formatter,
|
|
313
|
+
"datetime": datetime_formatter,
|
|
314
|
+
"date": date_formatter,
|
|
315
|
+
"time": time_formatter,
|
|
316
|
+
},
|
|
317
|
+
selectors={
|
|
318
|
+
"string": string_selector,
|
|
319
|
+
"number": number_selector,
|
|
320
|
+
"integer": integer_selector,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
"""The default registry of formatters and selectors."""
|
messageformat2/cli.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
import messageformat2
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(invoke_without_command=True)
|
|
12
|
+
@click.option("--version", is_flag=True)
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def cli(ctx: Any, version: bool) -> None: # noqa: FBT001
|
|
15
|
+
if ctx.invoked_subcommand is None:
|
|
16
|
+
if version:
|
|
17
|
+
click.echo(messageformat2.__version__)
|
|
18
|
+
else:
|
|
19
|
+
click.echo(ctx.get_help())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.argument("filename", type=click.Path(exists=True))
|
|
24
|
+
def extract(filename: Path) -> None:
|
|
25
|
+
"""Extract messages."""
|
|
26
|
+
filename = Path(filename)
|
|
27
|
+
|
|
28
|
+
class MessageExtractor(ast.NodeVisitor):
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.messages = []
|
|
32
|
+
|
|
33
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
34
|
+
match node:
|
|
35
|
+
case ast.Call(func=ast.Name(id="_"), args=[ast.Constant(value=message), *_]):
|
|
36
|
+
self.messages.append(message)
|
|
37
|
+
self.generic_visit(node)
|
|
38
|
+
|
|
39
|
+
files = filename.rglob("*.py") if filename.is_dir() else [filename]
|
|
40
|
+
|
|
41
|
+
extractor = MessageExtractor()
|
|
42
|
+
for f in files:
|
|
43
|
+
source = f.read_text()
|
|
44
|
+
tree = ast.parse(source)
|
|
45
|
+
extractor.visit(tree)
|
|
46
|
+
|
|
47
|
+
output = ""
|
|
48
|
+
for message in extractor.messages:
|
|
49
|
+
msg_id = hashlib.sha256(message.encode()).hexdigest()
|
|
50
|
+
output += f"{msg_id} = {message}\n\n"
|
|
51
|
+
click.echo(output)
|