omlish 0.0.0.dev222__py3-none-any.whl → 0.0.0.dev224__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.
- omlish/__about__.py +4 -4
- omlish/asyncs/asyncio/subprocesses.py +15 -23
- omlish/dynamic.py +1 -1
- omlish/funcs/genmachine.py +1 -1
- omlish/http/coro/fdio.py +3 -3
- omlish/http/coro/server.py +1 -1
- omlish/http/handlers.py +65 -0
- omlish/http/parsing.py +13 -0
- omlish/lite/marshal.py +22 -6
- omlish/lite/timing.py +8 -0
- omlish/logs/timing.py +58 -0
- omlish/secrets/tempssl.py +43 -21
- omlish/specs/irc/messages/__init__.py +0 -0
- omlish/specs/irc/messages/base.py +49 -0
- omlish/specs/irc/messages/formats.py +92 -0
- omlish/specs/irc/messages/messages.py +774 -0
- omlish/specs/irc/messages/parsing.py +98 -0
- omlish/specs/irc/numerics/numerics.py +57 -0
- omlish/subprocesses.py +107 -0
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/METADATA +5 -5
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/RECORD +35 -28
- /omlish/specs/irc/{format → protocol}/LICENSE +0 -0
- /omlish/specs/irc/{format → protocol}/__init__.py +0 -0
- /omlish/specs/irc/{format → protocol}/consts.py +0 -0
- /omlish/specs/irc/{format → protocol}/errors.py +0 -0
- /omlish/specs/irc/{format → protocol}/message.py +0 -0
- /omlish/specs/irc/{format → protocol}/nuh.py +0 -0
- /omlish/specs/irc/{format → protocol}/parsing.py +0 -0
- /omlish/specs/irc/{format → protocol}/rendering.py +0 -0
- /omlish/specs/irc/{format → protocol}/tags.py +0 -0
- /omlish/specs/irc/{format → protocol}/utils.py +0 -0
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/LICENSE +0 -0
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/WHEEL +0 -0
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/entry_points.txt +0 -0
- {omlish-0.0.0.dev222.dist-info → omlish-0.0.0.dev224.dist-info}/top_level.txt +0 -0
omlish/__about__.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
__version__ = '0.0.0.
|
2
|
-
__revision__ = '
|
1
|
+
__version__ = '0.0.0.dev224'
|
2
|
+
__revision__ = '0980d15b9ba0f06f68cf7bdb68fbc8626696075f'
|
3
3
|
|
4
4
|
|
5
5
|
#
|
@@ -96,14 +96,14 @@ class Project(ProjectBase):
|
|
96
96
|
# 'mysqlclient ~= 2.2',
|
97
97
|
|
98
98
|
'aiomysql ~= 0.2',
|
99
|
-
'aiosqlite ~= 0.
|
99
|
+
'aiosqlite ~= 0.21',
|
100
100
|
'asyncpg ~= 0.30',
|
101
101
|
|
102
102
|
'apsw ~= 3.47',
|
103
103
|
|
104
104
|
'sqlean.py ~= 3.45',
|
105
105
|
|
106
|
-
'duckdb ~= 1.
|
106
|
+
'duckdb ~= 1.2',
|
107
107
|
],
|
108
108
|
|
109
109
|
'testing': [
|
@@ -3,7 +3,6 @@
|
|
3
3
|
import asyncio.base_subprocess
|
4
4
|
import asyncio.subprocess
|
5
5
|
import contextlib
|
6
|
-
import dataclasses as dc
|
7
6
|
import functools
|
8
7
|
import logging
|
9
8
|
import subprocess
|
@@ -12,6 +11,8 @@ import typing as ta
|
|
12
11
|
|
13
12
|
from ...lite.check import check
|
14
13
|
from ...subprocesses import AbstractAsyncSubprocesses
|
14
|
+
from ...subprocesses import SubprocessRun
|
15
|
+
from ...subprocesses import SubprocessRunOutput
|
15
16
|
from .timeouts import asyncio_maybe_timeout
|
16
17
|
|
17
18
|
|
@@ -178,41 +179,32 @@ class AsyncioSubprocesses(AbstractAsyncSubprocesses):
|
|
178
179
|
|
179
180
|
#
|
180
181
|
|
181
|
-
|
182
|
-
|
183
|
-
proc: asyncio.subprocess.Process
|
184
|
-
stdout: ta.Optional[bytes]
|
185
|
-
stderr: ta.Optional[bytes]
|
182
|
+
async def run_(self, run: SubprocessRun) -> SubprocessRunOutput[asyncio.subprocess.Process]:
|
183
|
+
kwargs = dict(run.kwargs or {})
|
186
184
|
|
187
|
-
|
188
|
-
self,
|
189
|
-
*cmd: str,
|
190
|
-
input: ta.Any = None, # noqa
|
191
|
-
timeout: ta.Optional[float] = None,
|
192
|
-
check: bool = False, # noqa
|
193
|
-
capture_output: ta.Optional[bool] = None,
|
194
|
-
**kwargs: ta.Any,
|
195
|
-
) -> RunOutput:
|
196
|
-
if capture_output:
|
185
|
+
if run.capture_output:
|
197
186
|
kwargs.setdefault('stdout', subprocess.PIPE)
|
198
187
|
kwargs.setdefault('stderr', subprocess.PIPE)
|
199
188
|
|
200
189
|
proc: asyncio.subprocess.Process
|
201
|
-
async with self.popen(*cmd, **kwargs) as proc:
|
202
|
-
stdout, stderr = await self.communicate(proc, input, timeout)
|
190
|
+
async with self.popen(*run.cmd, **kwargs) as proc:
|
191
|
+
stdout, stderr = await self.communicate(proc, run.input, run.timeout)
|
203
192
|
|
204
193
|
if check and proc.returncode:
|
205
194
|
raise subprocess.CalledProcessError(
|
206
195
|
proc.returncode,
|
207
|
-
cmd,
|
196
|
+
run.cmd,
|
208
197
|
output=stdout,
|
209
198
|
stderr=stderr,
|
210
199
|
)
|
211
200
|
|
212
|
-
return
|
213
|
-
proc,
|
214
|
-
|
215
|
-
|
201
|
+
return SubprocessRunOutput(
|
202
|
+
proc=proc,
|
203
|
+
|
204
|
+
returncode=check.isinstance(proc.returncode, int),
|
205
|
+
|
206
|
+
stdout=stdout,
|
207
|
+
stderr=stderr,
|
216
208
|
)
|
217
209
|
|
218
210
|
#
|
omlish/dynamic.py
CHANGED
@@ -170,7 +170,7 @@ class Binding(ta.Generic[T]):
|
|
170
170
|
while lag_frame is not None:
|
171
171
|
for cur_depth in range(_MAX_HOIST_DEPTH + 1):
|
172
172
|
if lag_frame is None:
|
173
|
-
break
|
173
|
+
break
|
174
174
|
try:
|
175
175
|
lag_hoist = _HOISTED_CODE_DEPTH[lag_frame.f_code]
|
176
176
|
except KeyError:
|
omlish/funcs/genmachine.py
CHANGED
omlish/http/coro/fdio.py
CHANGED
@@ -33,7 +33,7 @@ class CoroHttpServerConnectionFdioHandler(SocketFdioHandler):
|
|
33
33
|
self._log_handler = log_handler
|
34
34
|
|
35
35
|
self._read_buf = ReadableListBuffer()
|
36
|
-
self._write_buf: IncrementalWriteBuffer
|
36
|
+
self._write_buf: ta.Optional[IncrementalWriteBuffer] = None
|
37
37
|
|
38
38
|
self._coro_srv = CoroHttpServer(
|
39
39
|
addr,
|
@@ -41,7 +41,7 @@ class CoroHttpServerConnectionFdioHandler(SocketFdioHandler):
|
|
41
41
|
)
|
42
42
|
self._srv_coro: ta.Optional[ta.Generator[CoroHttpServer.Io, ta.Optional[bytes], None]] = self._coro_srv.coro_handle() # noqa
|
43
43
|
|
44
|
-
self._cur_io: CoroHttpServer.Io
|
44
|
+
self._cur_io: ta.Optional[CoroHttpServer.Io] = None
|
45
45
|
self._next_io()
|
46
46
|
|
47
47
|
#
|
@@ -49,7 +49,7 @@ class CoroHttpServerConnectionFdioHandler(SocketFdioHandler):
|
|
49
49
|
def _next_io(self) -> None: # noqa
|
50
50
|
coro = check.not_none(self._srv_coro)
|
51
51
|
|
52
|
-
d: bytes
|
52
|
+
d: ta.Optional[bytes] = None
|
53
53
|
o = self._cur_io
|
54
54
|
while True:
|
55
55
|
if o is None:
|
omlish/http/coro/server.py
CHANGED
@@ -496,7 +496,7 @@ class CoroHttpServer:
|
|
496
496
|
if isinstance(parsed, ParseHttpRequestError):
|
497
497
|
err = self._build_error(
|
498
498
|
parsed.code,
|
499
|
-
*parsed.message,
|
499
|
+
*([parsed.message] if isinstance(parsed.message, str) else parsed.message),
|
500
500
|
version=parsed.version,
|
501
501
|
)
|
502
502
|
yield self.ErrorLogIo(err)
|
omlish/http/handlers.py
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
import abc
|
4
4
|
import dataclasses as dc
|
5
5
|
import http.server
|
6
|
+
import logging
|
6
7
|
import typing as ta
|
7
8
|
|
8
9
|
from ..sockets.addresses import SocketAddress
|
@@ -60,3 +61,67 @@ class HttpHandler_(abc.ABC): # noqa
|
|
60
61
|
@abc.abstractmethod
|
61
62
|
def __call__(self, req: HttpHandlerRequest) -> HttpHandlerResponse:
|
62
63
|
raise NotImplementedError
|
64
|
+
|
65
|
+
|
66
|
+
##
|
67
|
+
|
68
|
+
|
69
|
+
@dc.dataclass(frozen=True)
|
70
|
+
class LoggingHttpHandler(HttpHandler_):
|
71
|
+
handler: HttpHandler
|
72
|
+
log: logging.Logger
|
73
|
+
level: int = logging.DEBUG
|
74
|
+
|
75
|
+
def __call__(self, req: HttpHandlerRequest) -> HttpHandlerResponse:
|
76
|
+
self.log.log(self.level, '%r', req)
|
77
|
+
resp = self.handler(req)
|
78
|
+
self.log.log(self.level, '%r', resp)
|
79
|
+
return resp
|
80
|
+
|
81
|
+
|
82
|
+
##
|
83
|
+
|
84
|
+
|
85
|
+
@dc.dataclass(frozen=True)
|
86
|
+
class BytesResponseHttpHandler(HttpHandler_):
|
87
|
+
data: bytes
|
88
|
+
|
89
|
+
status: ta.Union[http.HTTPStatus, int] = 200
|
90
|
+
content_type: ta.Optional[str] = 'application/octet-stream'
|
91
|
+
headers: ta.Optional[ta.Mapping[str, str]] = None
|
92
|
+
close_connection: bool = True
|
93
|
+
|
94
|
+
def __call__(self, req: HttpHandlerRequest) -> HttpHandlerResponse:
|
95
|
+
return HttpHandlerResponse(
|
96
|
+
status=self.status,
|
97
|
+
headers={
|
98
|
+
**({'Content-Type': self.content_type} if self.content_type else {}),
|
99
|
+
'Content-Length': str(len(self.data)),
|
100
|
+
**(self.headers or {}),
|
101
|
+
},
|
102
|
+
data=self.data,
|
103
|
+
close_connection=self.close_connection,
|
104
|
+
)
|
105
|
+
|
106
|
+
|
107
|
+
@dc.dataclass(frozen=True)
|
108
|
+
class StringResponseHttpHandler(HttpHandler_):
|
109
|
+
data: str
|
110
|
+
|
111
|
+
status: ta.Union[http.HTTPStatus, int] = 200
|
112
|
+
content_type: ta.Optional[str] = 'text/plain; charset=utf-8'
|
113
|
+
headers: ta.Optional[ta.Mapping[str, str]] = None
|
114
|
+
close_connection: bool = True
|
115
|
+
|
116
|
+
def __call__(self, req: HttpHandlerRequest) -> HttpHandlerResponse:
|
117
|
+
data = self.data.encode('utf-8')
|
118
|
+
return HttpHandlerResponse(
|
119
|
+
status=self.status,
|
120
|
+
headers={
|
121
|
+
**({'Content-Type': self.content_type} if self.content_type else {}),
|
122
|
+
'Content-Length': str(len(data)),
|
123
|
+
**(self.headers or {}),
|
124
|
+
},
|
125
|
+
data=data,
|
126
|
+
close_connection=self.close_connection,
|
127
|
+
)
|
omlish/http/parsing.py
CHANGED
@@ -246,6 +246,8 @@ class HttpRequestParser:
|
|
246
246
|
|
247
247
|
#
|
248
248
|
|
249
|
+
_TLS_HANDSHAKE_PREFIX = b'\x16'
|
250
|
+
|
249
251
|
def coro_parse(self) -> ta.Generator[int, bytes, ParseHttpRequestResult]:
|
250
252
|
raw_request_line = yield self._max_line + 1
|
251
253
|
|
@@ -284,6 +286,17 @@ class HttpRequestParser:
|
|
284
286
|
if not raw_request_line:
|
285
287
|
return EmptyParsedHttpResult(**result_kwargs())
|
286
288
|
|
289
|
+
# Detect TLS
|
290
|
+
|
291
|
+
if raw_request_line.startswith(self._TLS_HANDSHAKE_PREFIX):
|
292
|
+
return ParseHttpRequestError(
|
293
|
+
code=http.HTTPStatus.BAD_REQUEST,
|
294
|
+
message='Bad request version (probable TLS handshake)',
|
295
|
+
**result_kwargs(),
|
296
|
+
)
|
297
|
+
|
298
|
+
# Decode line
|
299
|
+
|
287
300
|
request_line = raw_request_line.decode('iso-8859-1').rstrip('\r\n')
|
288
301
|
|
289
302
|
# Split words
|
omlish/lite/marshal.py
CHANGED
@@ -422,18 +422,34 @@ class ObjMarshalerManager:
|
|
422
422
|
return reg
|
423
423
|
|
424
424
|
if abc.ABC in ty.__bases__:
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
425
|
+
tn = ty.__name__
|
426
|
+
impls: ta.List[ta.Tuple[type, str]] = [ # type: ignore[var-annotated]
|
427
|
+
(ity, ity.__name__)
|
428
|
+
for ity in deep_subclasses(ty)
|
429
|
+
if abc.ABC not in ity.__bases__
|
430
|
+
]
|
431
|
+
|
432
|
+
if all(itn.endswith(tn) for _, itn in impls):
|
433
|
+
impls = [
|
434
|
+
(ity, snake_case(itn[:-len(tn)]))
|
435
|
+
for ity, itn in impls
|
436
|
+
]
|
437
|
+
|
438
|
+
dupe_tns = sorted(
|
439
|
+
dn
|
440
|
+
for dn, dc in collections.Counter(itn for _, itn in impls).items()
|
441
|
+
if dc > 1
|
442
|
+
)
|
443
|
+
if dupe_tns:
|
444
|
+
raise KeyError(f'Duplicate impl names for {ty}: {dupe_tns}')
|
445
|
+
|
430
446
|
return PolymorphicObjMarshaler.of([
|
431
447
|
PolymorphicObjMarshaler.Impl(
|
432
448
|
ity,
|
433
449
|
itn,
|
434
450
|
rec(ity),
|
435
451
|
)
|
436
|
-
for ity, itn in
|
452
|
+
for ity, itn in impls
|
437
453
|
])
|
438
454
|
|
439
455
|
if issubclass(ty, enum.Enum):
|
omlish/lite/timing.py
ADDED
omlish/logs/timing.py
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# ruff: noqa: UP006 UP007
|
2
|
+
# @omlish-lite
|
3
|
+
import logging
|
4
|
+
import time
|
5
|
+
import typing as ta
|
6
|
+
|
7
|
+
|
8
|
+
##
|
9
|
+
|
10
|
+
|
11
|
+
class LogTimingContext:
|
12
|
+
DEFAULT_LOG: ta.ClassVar[ta.Optional[logging.Logger]] = None
|
13
|
+
|
14
|
+
class _NOT_SPECIFIED: # noqa
|
15
|
+
def __new__(cls, *args, **kwargs): # noqa
|
16
|
+
raise TypeError
|
17
|
+
|
18
|
+
def __init__(
|
19
|
+
self,
|
20
|
+
description: str,
|
21
|
+
*,
|
22
|
+
log: ta.Union[logging.Logger, ta.Type[_NOT_SPECIFIED], None] = _NOT_SPECIFIED, # noqa
|
23
|
+
level: int = logging.DEBUG,
|
24
|
+
) -> None:
|
25
|
+
super().__init__()
|
26
|
+
|
27
|
+
self._description = description
|
28
|
+
if log is self._NOT_SPECIFIED:
|
29
|
+
log = self.DEFAULT_LOG # noqa
|
30
|
+
self._log: ta.Optional[logging.Logger] = log # type: ignore
|
31
|
+
self._level = level
|
32
|
+
|
33
|
+
def set_description(self, description: str) -> 'LogTimingContext':
|
34
|
+
self._description = description
|
35
|
+
return self
|
36
|
+
|
37
|
+
_begin_time: float
|
38
|
+
_end_time: float
|
39
|
+
|
40
|
+
def __enter__(self) -> 'LogTimingContext':
|
41
|
+
self._begin_time = time.time()
|
42
|
+
|
43
|
+
if self._log is not None:
|
44
|
+
self._log.log(self._level, f'Begin : {self._description}') # noqa
|
45
|
+
|
46
|
+
return self
|
47
|
+
|
48
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
49
|
+
self._end_time = time.time()
|
50
|
+
|
51
|
+
if self._log is not None:
|
52
|
+
self._log.log(
|
53
|
+
self._level,
|
54
|
+
f'End : {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
|
55
|
+
)
|
56
|
+
|
57
|
+
|
58
|
+
log_timing_context = LogTimingContext
|
omlish/secrets/tempssl.py
CHANGED
@@ -1,10 +1,14 @@
|
|
1
1
|
# @omlish-lite
|
2
2
|
# ruff: noqa: UP006 UP007
|
3
|
+
import dataclasses as dc
|
3
4
|
import os.path
|
4
|
-
import subprocess
|
5
5
|
import tempfile
|
6
6
|
import typing as ta
|
7
7
|
|
8
|
+
from ..lite.cached import cached_nullary
|
9
|
+
from ..subprocesses import SubprocessRun
|
10
|
+
from ..subprocesses import SubprocessRunOutput
|
11
|
+
from ..subprocesses import subprocesses
|
8
12
|
from .ssl import SslCert
|
9
13
|
|
10
14
|
|
@@ -13,11 +17,15 @@ class TempSslCert(ta.NamedTuple):
|
|
13
17
|
temp_dir: str
|
14
18
|
|
15
19
|
|
16
|
-
|
17
|
-
|
20
|
+
@dc.dataclass(frozen=True)
|
21
|
+
class TempSslCertGenerator:
|
22
|
+
@cached_nullary
|
23
|
+
def temp_dir(self) -> str:
|
24
|
+
return tempfile.mkdtemp()
|
18
25
|
|
19
|
-
|
20
|
-
|
26
|
+
@cached_nullary
|
27
|
+
def make_run(self) -> SubprocessRun:
|
28
|
+
return SubprocessRun.of(
|
21
29
|
'openssl',
|
22
30
|
'req',
|
23
31
|
'-x509',
|
@@ -32,19 +40,33 @@ def generate_temp_localhost_ssl_cert() -> TempSslCert:
|
|
32
40
|
|
33
41
|
'-subj', '/CN=localhost',
|
34
42
|
'-addext', 'subjectAltName = DNS:localhost,IP:127.0.0.1',
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
43
|
+
|
44
|
+
cwd=self.temp_dir(),
|
45
|
+
capture_output=True,
|
46
|
+
check=False,
|
47
|
+
)
|
48
|
+
|
49
|
+
def handle_run_output(self, proc: SubprocessRunOutput) -> TempSslCert:
|
50
|
+
if proc.returncode:
|
51
|
+
raise RuntimeError(f'Failed to generate temp ssl cert: {proc.stderr=}')
|
52
|
+
|
53
|
+
key_file = os.path.join(self.temp_dir(), 'key.pem')
|
54
|
+
cert_file = os.path.join(self.temp_dir(), 'cert.pem')
|
55
|
+
for file in [key_file, cert_file]:
|
56
|
+
if not os.path.isfile(file):
|
57
|
+
raise RuntimeError(f'Failed to generate temp ssl cert (file not found): {file}')
|
58
|
+
|
59
|
+
return TempSslCert(
|
60
|
+
SslCert(
|
61
|
+
key_file=key_file,
|
62
|
+
cert_file=cert_file,
|
63
|
+
),
|
64
|
+
temp_dir=self.temp_dir(),
|
65
|
+
)
|
66
|
+
|
67
|
+
def run(self) -> TempSslCert:
|
68
|
+
return self.handle_run_output(subprocesses.run_(self.make_run()))
|
69
|
+
|
70
|
+
|
71
|
+
def generate_temp_localhost_ssl_cert() -> TempSslCert:
|
72
|
+
return TempSslCertGenerator().run()
|
File without changes
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import typing as ta
|
2
|
+
|
3
|
+
from .... import check
|
4
|
+
from .... import dataclasses as dc
|
5
|
+
from ....funcs import pairs as fps
|
6
|
+
from ..numerics import numerics as nr
|
7
|
+
from .formats import MessageFormat
|
8
|
+
from .formats import MessageParamsUnpacker
|
9
|
+
|
10
|
+
|
11
|
+
##
|
12
|
+
|
13
|
+
|
14
|
+
class Message(dc.Case):
|
15
|
+
FORMAT: ta.ClassVar[MessageFormat]
|
16
|
+
REPLIES: ta.ClassVar[ta.Collection[nr.NumericReply]] = ()
|
17
|
+
|
18
|
+
|
19
|
+
##
|
20
|
+
|
21
|
+
|
22
|
+
def list_pair_params_unpacker(
|
23
|
+
kwarg: str,
|
24
|
+
key_param: str,
|
25
|
+
value_param: str,
|
26
|
+
) -> MessageParamsUnpacker:
|
27
|
+
def forward(params: ta.Mapping[str, str]) -> ta.Mapping[str, ta.Any]:
|
28
|
+
out: dict = dict(params)
|
29
|
+
ks = out.pop(key_param)
|
30
|
+
vs = out.pop(value_param, None)
|
31
|
+
if vs is not None:
|
32
|
+
out[kwarg] = list(zip(ks, vs, strict=True))
|
33
|
+
else:
|
34
|
+
out[kwarg] = ks
|
35
|
+
return out
|
36
|
+
|
37
|
+
def backward(kwargs: ta.Mapping[str, ta.Any]) -> ta.Mapping[str, str]:
|
38
|
+
out: dict = dict(kwargs)
|
39
|
+
ts = out.pop(kwarg)
|
40
|
+
is_ts = check.single({isinstance(e, tuple) for e in ts})
|
41
|
+
if is_ts:
|
42
|
+
ks, vs = zip(*ts)
|
43
|
+
out[key_param] = ks
|
44
|
+
out[value_param] = vs
|
45
|
+
else:
|
46
|
+
out[key_param] = ts
|
47
|
+
return out
|
48
|
+
|
49
|
+
return fps.of(forward, backward)
|
@@ -0,0 +1,92 @@
|
|
1
|
+
import enum
|
2
|
+
import typing as ta
|
3
|
+
|
4
|
+
from .... import check
|
5
|
+
from .... import dataclasses as dc
|
6
|
+
from .... import lang
|
7
|
+
from ....funcs import pairs as fps
|
8
|
+
|
9
|
+
|
10
|
+
MessageParamsUnpacker: ta.TypeAlias = fps.FnPair[
|
11
|
+
ta.Mapping[str, str], # params
|
12
|
+
ta.Mapping[str, ta.Any], # kwargs
|
13
|
+
]
|
14
|
+
|
15
|
+
|
16
|
+
##
|
17
|
+
|
18
|
+
|
19
|
+
class MessageFormat(dc.Frozen, final=True):
|
20
|
+
name: str
|
21
|
+
|
22
|
+
class Param(dc.Case):
|
23
|
+
@classmethod
|
24
|
+
def of(cls, obj: ta.Any) -> 'MessageFormat.Param':
|
25
|
+
if isinstance(obj, MessageFormat.Param):
|
26
|
+
return obj
|
27
|
+
|
28
|
+
elif isinstance(obj, str):
|
29
|
+
s = check.non_empty_str(obj)
|
30
|
+
|
31
|
+
optional = False
|
32
|
+
if s.startswith('?'):
|
33
|
+
optional = True
|
34
|
+
s = s[1:]
|
35
|
+
|
36
|
+
arity = MessageFormat.KwargParam.Arity.SINGLE
|
37
|
+
if s.startswith('*'):
|
38
|
+
arity = MessageFormat.KwargParam.Arity.VARIADIC
|
39
|
+
s = s[1:]
|
40
|
+
|
41
|
+
elif s.startswith(','):
|
42
|
+
arity = MessageFormat.KwargParam.Arity.COMMA_LIST
|
43
|
+
s = s[1:]
|
44
|
+
|
45
|
+
return MessageFormat.KwargParam(
|
46
|
+
s,
|
47
|
+
optional=optional,
|
48
|
+
arity=arity,
|
49
|
+
)
|
50
|
+
|
51
|
+
else:
|
52
|
+
raise TypeError(obj)
|
53
|
+
|
54
|
+
class KwargParam(Param):
|
55
|
+
name: str = dc.xfield(validate=lang.is_ident)
|
56
|
+
|
57
|
+
optional: bool = False
|
58
|
+
|
59
|
+
class Arity(enum.Enum):
|
60
|
+
SINGLE = enum.auto() # <foo>
|
61
|
+
VARIADIC = enum.auto() # <foo>{ <foo>}
|
62
|
+
COMMA_LIST = enum.auto() # <foo>{,<foo>}
|
63
|
+
|
64
|
+
arity: Arity = Arity.SINGLE
|
65
|
+
|
66
|
+
class LiteralParam(Param):
|
67
|
+
text: str
|
68
|
+
|
69
|
+
params: ta.Sequence[Param]
|
70
|
+
|
71
|
+
_: dc.KW_ONLY
|
72
|
+
|
73
|
+
unpack_params: MessageParamsUnpacker | None = None
|
74
|
+
|
75
|
+
@dc.init
|
76
|
+
def _validate_params(self) -> None:
|
77
|
+
kws = [p for p in self.params if isinstance(p, MessageFormat.KwargParam)]
|
78
|
+
check.unique(p.name for p in kws)
|
79
|
+
check.state(all(p.arity is not MessageFormat.KwargParam.Arity.VARIADIC for p in kws[:-1]))
|
80
|
+
|
81
|
+
@classmethod
|
82
|
+
def of(
|
83
|
+
cls,
|
84
|
+
name: str,
|
85
|
+
*params: ta.Any,
|
86
|
+
**kwargs: ta.Any,
|
87
|
+
) -> 'MessageFormat':
|
88
|
+
return cls(
|
89
|
+
name,
|
90
|
+
[MessageFormat.Param.of(p) for p in params],
|
91
|
+
**kwargs,
|
92
|
+
)
|