omlish 0.0.0.dev220__py3-none-any.whl → 0.0.0.dev221__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- omlish/__about__.py +2 -2
- omlish/algorithm/__init__.py +0 -0
- omlish/algorithm/all.py +13 -0
- omlish/algorithm/distribute.py +46 -0
- omlish/algorithm/toposort.py +26 -0
- omlish/algorithm/unify.py +31 -0
- omlish/collections/__init__.py +0 -2
- omlish/collections/utils.py +0 -46
- omlish/docker/oci/building.py +122 -0
- omlish/docker/oci/data.py +62 -8
- omlish/docker/oci/datarefs.py +98 -0
- omlish/docker/oci/loading.py +120 -0
- omlish/docker/oci/media.py +44 -14
- omlish/docker/oci/repositories.py +72 -0
- omlish/graphs/trees.py +2 -1
- omlish/http/coro/server.py +42 -33
- omlish/http/{simple.py → coro/simple.py} +17 -17
- omlish/specs/irc/__init__.py +0 -0
- omlish/specs/irc/format/LICENSE +11 -0
- omlish/specs/irc/format/__init__.py +61 -0
- omlish/specs/irc/format/consts.py +6 -0
- omlish/specs/irc/format/errors.py +30 -0
- omlish/specs/irc/format/message.py +18 -0
- omlish/specs/irc/format/nuh.py +52 -0
- omlish/specs/irc/format/parsing.py +155 -0
- omlish/specs/irc/format/rendering.py +150 -0
- omlish/specs/irc/format/tags.py +99 -0
- omlish/specs/irc/format/utils.py +27 -0
- omlish/specs/irc/numerics/__init__.py +0 -0
- omlish/specs/irc/numerics/formats.py +94 -0
- omlish/specs/irc/numerics/numerics.py +808 -0
- omlish/specs/irc/numerics/types.py +59 -0
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/METADATA +1 -1
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/RECORD +38 -14
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/LICENSE +0 -0
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/WHEEL +0 -0
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/entry_points.txt +0 -0
- {omlish-0.0.0.dev220.dist-info → omlish-0.0.0.dev221.dist-info}/top_level.txt +0 -0
omlish/docker/oci/media.py
CHANGED
@@ -8,18 +8,29 @@ from ...lite.check import check
|
|
8
8
|
from ...lite.marshal import OBJ_MARSHALER_FIELD_KEY
|
9
9
|
from ...lite.marshal import OBJ_MARSHALER_OMIT_IF_NONE
|
10
10
|
from ...lite.marshal import unmarshal_obj
|
11
|
-
from .data import OciDataclass
|
12
11
|
from .data import OciImageConfig
|
12
|
+
from .data import OciImageLayer
|
13
13
|
|
14
14
|
|
15
15
|
##
|
16
16
|
|
17
17
|
|
18
|
-
|
19
|
-
|
18
|
+
OCI_MEDIA_FIELDS: ta.Collection[str] = frozenset([
|
19
|
+
'schema_version',
|
20
|
+
'media_type',
|
21
|
+
])
|
22
|
+
|
23
|
+
|
24
|
+
@dc.dataclass()
|
25
|
+
class OciMediaDataclass(abc.ABC): # noqa
|
20
26
|
SCHEMA_VERSION: ta.ClassVar[int]
|
21
27
|
MEDIA_TYPE: ta.ClassVar[str]
|
22
28
|
|
29
|
+
def __init_subclass__(cls, **kwargs: ta.Any) -> None:
|
30
|
+
super().__init_subclass__(**kwargs)
|
31
|
+
for a in OCI_MEDIA_FIELDS:
|
32
|
+
check.in_(a, cls.__dict__)
|
33
|
+
|
23
34
|
|
24
35
|
_REGISTERED_OCI_MEDIA_DATACLASSES: ta.Dict[str, ta.Type[OciMediaDataclass]] = {}
|
25
36
|
|
@@ -48,11 +59,11 @@ def unmarshal_oci_media_dataclass(
|
|
48
59
|
return unmarshal_obj(dct, cls)
|
49
60
|
|
50
61
|
|
51
|
-
|
62
|
+
##
|
52
63
|
|
53
64
|
|
54
|
-
@dc.dataclass(
|
55
|
-
class OciMediaDescriptor
|
65
|
+
@dc.dataclass()
|
66
|
+
class OciMediaDescriptor:
|
56
67
|
"""https://github.com/opencontainers/image-spec/blob/92353b0bee778725c617e7d57317b568a7796bd0/descriptor.md#properties""" # noqa
|
57
68
|
|
58
69
|
media_type: str = dc.field(metadata={OBJ_MARSHALER_FIELD_KEY: 'mediaType'})
|
@@ -71,8 +82,11 @@ class OciMediaDescriptor(OciDataclass):
|
|
71
82
|
platform: ta.Optional[ta.Mapping[str, ta.Any]] = dc.field(default=None, metadata={OBJ_MARSHALER_OMIT_IF_NONE: True}) # noqa
|
72
83
|
|
73
84
|
|
85
|
+
##
|
86
|
+
|
87
|
+
|
74
88
|
@_register_oci_media_dataclass
|
75
|
-
@dc.dataclass(
|
89
|
+
@dc.dataclass()
|
76
90
|
class OciMediaImageIndex(OciMediaDataclass):
|
77
91
|
"""https://github.com/opencontainers/image-spec/blob/92353b0bee778725c617e7d57317b568a7796bd0/image-index.md"""
|
78
92
|
|
@@ -91,18 +105,16 @@ class OciMediaImageIndex(OciMediaDataclass):
|
|
91
105
|
media_type: str = dc.field(default=MEDIA_TYPE, metadata={OBJ_MARSHALER_FIELD_KEY: 'mediaType'})
|
92
106
|
|
93
107
|
|
108
|
+
#
|
109
|
+
|
110
|
+
|
94
111
|
@_register_oci_media_dataclass
|
95
|
-
@dc.dataclass(
|
112
|
+
@dc.dataclass()
|
96
113
|
class OciMediaImageManifest(OciMediaDataclass):
|
97
114
|
"""https://github.com/opencontainers/image-spec/blob/92353b0bee778725c617e7d57317b568a7796bd0/manifest.md"""
|
98
115
|
|
99
116
|
config: OciMediaDescriptor # -> OciMediaImageConfig
|
100
117
|
|
101
|
-
# MEDIA_TYPES: ta.ClassVar[ta.Mapping[str, str]] = {
|
102
|
-
# 'TAR': 'application/vnd.oci.image.layer.v1.tar',
|
103
|
-
# 'TAR_GZIP': 'application/vnd.oci.image.layer.v1.tar+gzip',
|
104
|
-
# 'TAR_ZSTD': 'application/vnd.oci.image.layer.v1.tar+zstd',
|
105
|
-
# }
|
106
118
|
layers: ta.Sequence[OciMediaDescriptor]
|
107
119
|
|
108
120
|
#
|
@@ -114,8 +126,26 @@ class OciMediaImageManifest(OciMediaDataclass):
|
|
114
126
|
media_type: str = dc.field(default=MEDIA_TYPE, metadata={OBJ_MARSHALER_FIELD_KEY: 'mediaType'})
|
115
127
|
|
116
128
|
|
129
|
+
#
|
130
|
+
|
131
|
+
|
132
|
+
OCI_IMAGE_LAYER_KIND_MEDIA_TYPES: ta.Mapping[OciImageLayer.Kind, str] = {
|
133
|
+
OciImageLayer.Kind.TAR: 'application/vnd.oci.image.layer.v1.tar',
|
134
|
+
OciImageLayer.Kind.TAR_GZIP: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
135
|
+
OciImageLayer.Kind.TAR_ZSTD: 'application/vnd.oci.image.layer.v1.tar+zstd',
|
136
|
+
}
|
137
|
+
|
138
|
+
OCI_IMAGE_LAYER_KIND_MEDIA_TYPES_: ta.Mapping[str, OciImageLayer.Kind] = {
|
139
|
+
v: k
|
140
|
+
for k, v in OCI_IMAGE_LAYER_KIND_MEDIA_TYPES.items()
|
141
|
+
}
|
142
|
+
|
143
|
+
|
144
|
+
#
|
145
|
+
|
146
|
+
|
117
147
|
@_register_oci_media_dataclass
|
118
|
-
@dc.dataclass(
|
148
|
+
@dc.dataclass()
|
119
149
|
class OciMediaImageConfig(OciImageConfig, OciMediaDataclass):
|
120
150
|
SCHEMA_VERSION: ta.ClassVar[int] = 2
|
121
151
|
schema_version: int = dc.field(default=SCHEMA_VERSION, metadata={OBJ_MARSHALER_FIELD_KEY: 'schemaVersion'})
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# ruff: noqa: UP006 UP007
|
2
|
+
# @omlish-lite
|
3
|
+
import abc
|
4
|
+
import os.path
|
5
|
+
import typing as ta
|
6
|
+
|
7
|
+
from ...lite.check import check
|
8
|
+
from ...os.paths import is_path_in_dir
|
9
|
+
from .datarefs import BytesOciDataRef
|
10
|
+
from .datarefs import FileOciDataRef
|
11
|
+
from .datarefs import OciDataRef
|
12
|
+
|
13
|
+
|
14
|
+
##
|
15
|
+
|
16
|
+
|
17
|
+
class OciRepository(abc.ABC):
|
18
|
+
@abc.abstractmethod
|
19
|
+
def read_blob(self, digest: str) -> bytes:
|
20
|
+
raise NotImplementedError
|
21
|
+
|
22
|
+
@abc.abstractmethod
|
23
|
+
def ref_blob(self, digest: str) -> OciDataRef:
|
24
|
+
raise NotImplementedError
|
25
|
+
|
26
|
+
|
27
|
+
#
|
28
|
+
|
29
|
+
|
30
|
+
class DirectoryOciRepository(OciRepository):
|
31
|
+
def __init__(self, data_dir: str) -> None:
|
32
|
+
super().__init__()
|
33
|
+
|
34
|
+
self._data_dir = check.non_empty_str(data_dir)
|
35
|
+
|
36
|
+
def read_file(self, path: str) -> bytes:
|
37
|
+
full_path = os.path.join(self._data_dir, path)
|
38
|
+
check.arg(is_path_in_dir(self._data_dir, full_path))
|
39
|
+
with open(full_path, 'rb') as f:
|
40
|
+
return f.read()
|
41
|
+
|
42
|
+
def blob_path(self, digest: str) -> str:
|
43
|
+
scheme, value = digest.split(':')
|
44
|
+
return os.path.join('blobs', scheme, value)
|
45
|
+
|
46
|
+
def blob_full_path(self, digest: str) -> str:
|
47
|
+
path = self.blob_path(digest)
|
48
|
+
full_path = os.path.join(self._data_dir, path)
|
49
|
+
check.arg(is_path_in_dir(self._data_dir, full_path))
|
50
|
+
return full_path
|
51
|
+
|
52
|
+
def read_blob(self, digest: str) -> bytes:
|
53
|
+
return self.read_file(self.blob_path(digest))
|
54
|
+
|
55
|
+
def ref_blob(self, digest: str) -> OciDataRef:
|
56
|
+
return FileOciDataRef(self.blob_full_path(digest))
|
57
|
+
|
58
|
+
|
59
|
+
#
|
60
|
+
|
61
|
+
|
62
|
+
class DictionaryOciRepository(OciRepository):
|
63
|
+
def __init__(self, blobs: ta.Mapping[str, bytes]) -> None:
|
64
|
+
super().__init__()
|
65
|
+
|
66
|
+
self._blobs = blobs
|
67
|
+
|
68
|
+
def read_blob(self, digest: str) -> bytes:
|
69
|
+
return self._blobs[digest]
|
70
|
+
|
71
|
+
def ref_blob(self, digest: str) -> OciDataRef:
|
72
|
+
return BytesOciDataRef(self._blobs[digest])
|
omlish/graphs/trees.py
CHANGED
@@ -10,6 +10,7 @@ from .. import cached
|
|
10
10
|
from .. import check
|
11
11
|
from .. import collections as col
|
12
12
|
from .. import lang
|
13
|
+
from ..algorithm import all as alg
|
13
14
|
|
14
15
|
|
15
16
|
T = ta.TypeVar('T')
|
@@ -194,7 +195,7 @@ class BasicTreeAnalysis(ta.Generic[NodeT]):
|
|
194
195
|
else:
|
195
196
|
e, d = lang.identity, lang.identity
|
196
197
|
tsd = {e(n): {e(p)} for n, p in parents_by_node.items()}
|
197
|
-
ts = list(
|
198
|
+
ts = list(alg.mut_toposort(tsd))
|
198
199
|
root = d(check.single(ts[0]))
|
199
200
|
|
200
201
|
return cls(
|
omlish/http/coro/server.py
CHANGED
@@ -421,50 +421,59 @@ class CoroHttpServer:
|
|
421
421
|
#
|
422
422
|
|
423
423
|
def coro_handle(self) -> ta.Generator[Io, ta.Optional[bytes], None]:
|
424
|
-
|
425
|
-
gen = self.coro_handle_one()
|
424
|
+
return self._coro_run_handler(self._coro_handle_one())
|
426
425
|
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
426
|
+
def _coro_run_handler(
|
427
|
+
self,
|
428
|
+
gen: ta.Generator[
|
429
|
+
ta.Union[AnyLogIo, AnyReadIo, _Response],
|
430
|
+
ta.Optional[bytes],
|
431
|
+
None,
|
432
|
+
],
|
433
|
+
) -> ta.Generator[Io, ta.Optional[bytes], None]:
|
434
|
+
i: ta.Optional[bytes]
|
435
|
+
o: ta.Any = next(gen)
|
436
|
+
while True:
|
437
|
+
try:
|
438
|
+
if isinstance(o, self.AnyLogIo):
|
439
|
+
i = None
|
440
|
+
yield o
|
434
441
|
|
435
|
-
|
436
|
-
|
442
|
+
elif isinstance(o, self.AnyReadIo):
|
443
|
+
i = check.isinstance((yield o), bytes)
|
437
444
|
|
438
|
-
|
439
|
-
|
445
|
+
elif isinstance(o, self._Response):
|
446
|
+
i = None
|
440
447
|
|
441
|
-
|
442
|
-
|
443
|
-
|
448
|
+
r = self._preprocess_response(o)
|
449
|
+
hb = self._build_response_head_bytes(r)
|
450
|
+
check.none((yield self.WriteIo(hb)))
|
444
451
|
|
445
|
-
|
446
|
-
|
452
|
+
for b in self._yield_response_data(r):
|
453
|
+
yield self.WriteIo(b)
|
447
454
|
|
448
|
-
|
449
|
-
|
455
|
+
o.close()
|
456
|
+
if o.close_connection:
|
457
|
+
break
|
458
|
+
o = None
|
450
459
|
|
451
|
-
|
452
|
-
|
460
|
+
else:
|
461
|
+
raise TypeError(o) # noqa
|
453
462
|
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
463
|
+
try:
|
464
|
+
o = gen.send(i)
|
465
|
+
except EOFError:
|
466
|
+
return
|
467
|
+
except StopIteration:
|
468
|
+
break
|
460
469
|
|
461
|
-
|
462
|
-
|
463
|
-
|
470
|
+
except Exception: # noqa
|
471
|
+
if hasattr(o, 'close'):
|
472
|
+
o.close()
|
464
473
|
|
465
|
-
|
474
|
+
raise
|
466
475
|
|
467
|
-
def
|
476
|
+
def _coro_handle_one(self) -> ta.Generator[
|
468
477
|
ta.Union[AnyLogIo, AnyReadIo, _Response],
|
469
478
|
ta.Optional[bytes],
|
470
479
|
None,
|
@@ -5,23 +5,23 @@ import contextlib
|
|
5
5
|
import functools
|
6
6
|
import typing as ta
|
7
7
|
|
8
|
-
from
|
9
|
-
from
|
10
|
-
from
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
15
|
-
from
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from
|
20
|
-
from
|
21
|
-
from
|
22
|
-
from
|
23
|
-
from .
|
24
|
-
from .
|
8
|
+
from ...lite.check import check
|
9
|
+
from ...sockets.addresses import SocketAndAddress
|
10
|
+
from ...sockets.bind import CanSocketBinder
|
11
|
+
from ...sockets.bind import SocketBinder
|
12
|
+
from ...sockets.server.handlers import ExecutorSocketServerHandler
|
13
|
+
from ...sockets.server.handlers import SocketHandlerSocketServerHandler
|
14
|
+
from ...sockets.server.handlers import SocketServerHandler
|
15
|
+
from ...sockets.server.handlers import SocketWrappingSocketServerHandler
|
16
|
+
from ...sockets.server.handlers import StandardSocketServerHandler
|
17
|
+
from ...sockets.server.server import SocketServer
|
18
|
+
from ...sockets.server.threading import ThreadingSocketServerHandler
|
19
|
+
from ..handlers import HttpHandler
|
20
|
+
from ..parsing import HttpRequestParser
|
21
|
+
from ..versions import HttpProtocolVersion
|
22
|
+
from ..versions import HttpProtocolVersions
|
23
|
+
from .server import CoroHttpServer
|
24
|
+
from .server import CoroHttpServerSocketHandler
|
25
25
|
|
26
26
|
|
27
27
|
if ta.TYPE_CHECKING:
|
File without changes
|
@@ -0,0 +1,11 @@
|
|
1
|
+
Copyright (c) 2016-2021 Daniel Oaks
|
2
|
+
Copyright (c) 2018-2021 Shivaram Lingamneni
|
3
|
+
|
4
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted,
|
5
|
+
provided that the above copyright notice and this permission notice appear in all copies.
|
6
|
+
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
|
8
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
10
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
11
|
+
THIS SOFTWARE.
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# Copyright (c) 2016-2021 Daniel Oaks
|
2
|
+
# Copyright (c) 2018-2021 Shivaram Lingamneni
|
3
|
+
#
|
4
|
+
# Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby
|
5
|
+
# granted, provided that the above copyright notice and this permission notice appear in all copies.
|
6
|
+
#
|
7
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
|
8
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
9
|
+
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
10
|
+
# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
11
|
+
# PERFORMANCE OF THIS SOFTWARE.
|
12
|
+
#
|
13
|
+
# https://github.com/ergochat/irc-go/blob/9beac2d29dc5f998c5a53e5db7a6426d7d083a79/ircmsg/
|
14
|
+
from .consts import ( # noqa
|
15
|
+
MAX_LEN_CLIENT_TAG_DATA,
|
16
|
+
MAX_LEN_SERVER_TAG_DATA,
|
17
|
+
MAX_LEN_TAGS,
|
18
|
+
MAX_LEN_TAGS_FROM_CLIENT,
|
19
|
+
MAX_LEN_TAG_DATA,
|
20
|
+
)
|
21
|
+
|
22
|
+
from .errors import ( # noqa
|
23
|
+
BadCharactersError,
|
24
|
+
BadParamError,
|
25
|
+
CommandMissingError,
|
26
|
+
Error,
|
27
|
+
InvalidTagContentError,
|
28
|
+
LineEmptyError,
|
29
|
+
MalformedNuhError,
|
30
|
+
TagsTooLongError,
|
31
|
+
)
|
32
|
+
|
33
|
+
from .message import ( # noqa
|
34
|
+
Message,
|
35
|
+
)
|
36
|
+
|
37
|
+
from .nuh import ( # noqa
|
38
|
+
Nuh,
|
39
|
+
)
|
40
|
+
|
41
|
+
from .parsing import ( # noqa
|
42
|
+
ParsedLine,
|
43
|
+
parse_line,
|
44
|
+
parse_line_,
|
45
|
+
parse_line_strict,
|
46
|
+
)
|
47
|
+
|
48
|
+
from .rendering import ( # noqa
|
49
|
+
RenderedLine,
|
50
|
+
render_line,
|
51
|
+
render_line_,
|
52
|
+
render_line_strict,
|
53
|
+
)
|
54
|
+
|
55
|
+
from .tags import ( # noqa
|
56
|
+
escape_tag_value,
|
57
|
+
parse_tags,
|
58
|
+
unescape_tag_value,
|
59
|
+
validate_tag_name,
|
60
|
+
validate_tag_value,
|
61
|
+
)
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class Error(Exception):
|
2
|
+
pass
|
3
|
+
|
4
|
+
|
5
|
+
class LineEmptyError(Error):
|
6
|
+
pass
|
7
|
+
|
8
|
+
|
9
|
+
class BadCharactersError(Error):
|
10
|
+
pass
|
11
|
+
|
12
|
+
|
13
|
+
class TagsTooLongError(Error):
|
14
|
+
pass
|
15
|
+
|
16
|
+
|
17
|
+
class InvalidTagContentError(Error):
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
class CommandMissingError(Error):
|
22
|
+
pass
|
23
|
+
|
24
|
+
|
25
|
+
class BadParamError(Error):
|
26
|
+
pass
|
27
|
+
|
28
|
+
|
29
|
+
class MalformedNuhError(Error):
|
30
|
+
pass
|
@@ -0,0 +1,18 @@
|
|
1
|
+
"""
|
2
|
+
TODO:
|
3
|
+
- https://github.com/ergochat/irctest/blob/master/irctest/irc_utils/message_parser.py ?
|
4
|
+
"""
|
5
|
+
import dataclasses as dc
|
6
|
+
import typing as ta
|
7
|
+
|
8
|
+
|
9
|
+
@dc.dataclass(frozen=True)
|
10
|
+
class Message:
|
11
|
+
source: str | None
|
12
|
+
command: str
|
13
|
+
params: ta.Sequence[str]
|
14
|
+
|
15
|
+
force_trailing: bool = False
|
16
|
+
|
17
|
+
tags: ta.Mapping[str, str] | None = None
|
18
|
+
client_only_tags: ta.Mapping[str, str] | None = None
|
@@ -0,0 +1,52 @@
|
|
1
|
+
import dataclasses as dc
|
2
|
+
import typing as ta
|
3
|
+
|
4
|
+
from .errors import MalformedNuhError
|
5
|
+
|
6
|
+
|
7
|
+
@dc.dataclass(frozen=True)
|
8
|
+
class Nuh:
|
9
|
+
name: str | None = None
|
10
|
+
user: str | None = None
|
11
|
+
host: str | None = None
|
12
|
+
|
13
|
+
@property
|
14
|
+
def tuple(self) -> tuple[str | None, ...]:
|
15
|
+
return (self.name, self.user, self.host)
|
16
|
+
|
17
|
+
def __iter__(self) -> ta.Iterator[str | None]:
|
18
|
+
return iter(self.tuple)
|
19
|
+
|
20
|
+
@classmethod
|
21
|
+
def parse(cls, inp: str) -> 'Nuh':
|
22
|
+
if not inp:
|
23
|
+
raise MalformedNuhError
|
24
|
+
|
25
|
+
host: str | None = None
|
26
|
+
host_start = inp.find('@')
|
27
|
+
if host_start != -1:
|
28
|
+
host = inp[host_start + 1:]
|
29
|
+
inp = inp[:host_start]
|
30
|
+
|
31
|
+
user_start = inp.find('!')
|
32
|
+
user: str | None = None
|
33
|
+
if user_start != -1:
|
34
|
+
user = inp[user_start + 1:]
|
35
|
+
inp = inp[:user_start]
|
36
|
+
|
37
|
+
return cls(
|
38
|
+
name=inp or None,
|
39
|
+
user=user,
|
40
|
+
host=host,
|
41
|
+
)
|
42
|
+
|
43
|
+
@property
|
44
|
+
def canonical(self) -> str:
|
45
|
+
parts = []
|
46
|
+
if (n := self.name) is not None:
|
47
|
+
parts.append(n)
|
48
|
+
if (u := self.user) is not None:
|
49
|
+
parts.append(f'!{u}')
|
50
|
+
if (h := self.host) is not None:
|
51
|
+
parts.append(f'@{h}')
|
52
|
+
return ''.join(parts)
|
@@ -0,0 +1,155 @@
|
|
1
|
+
import typing as ta
|
2
|
+
|
3
|
+
from .consts import MAX_LEN_CLIENT_TAG_DATA
|
4
|
+
from .consts import MAX_LEN_TAG_DATA
|
5
|
+
from .errors import BadCharactersError
|
6
|
+
from .errors import LineEmptyError
|
7
|
+
from .errors import TagsTooLongError
|
8
|
+
from .message import Message
|
9
|
+
from .tags import parse_tags
|
10
|
+
from .utils import is_ascii
|
11
|
+
from .utils import trim_initial_spaces
|
12
|
+
from .utils import truncate_utf8_safe
|
13
|
+
|
14
|
+
|
15
|
+
class ParsedLine(ta.NamedTuple):
|
16
|
+
message: Message
|
17
|
+
|
18
|
+
truncated: bool = False
|
19
|
+
|
20
|
+
|
21
|
+
def parse_line_(
|
22
|
+
line: str,
|
23
|
+
*,
|
24
|
+
max_tag_data_length: int | None = None,
|
25
|
+
truncate_len: int | None = None,
|
26
|
+
) -> ParsedLine:
|
27
|
+
# Remove either \n or \r\n from the end of the line:
|
28
|
+
line = line.removesuffix('\n')
|
29
|
+
line = line.removesuffix('\r')
|
30
|
+
|
31
|
+
# Whether we removed them ourselves, or whether they were removed previously, they count against the line limit:
|
32
|
+
if truncate_len is not None:
|
33
|
+
if truncate_len <= 2:
|
34
|
+
raise LineEmptyError
|
35
|
+
truncate_len -= 2
|
36
|
+
|
37
|
+
# Now validate for the 3 forbidden bytes:
|
38
|
+
if any(c in line for c in '\x00\n\r'):
|
39
|
+
raise BadCharactersError
|
40
|
+
|
41
|
+
if not line:
|
42
|
+
raise LineEmptyError
|
43
|
+
|
44
|
+
#
|
45
|
+
|
46
|
+
# Handle tags
|
47
|
+
tags: ta.Mapping[str, str] | None = None
|
48
|
+
if line.startswith('@'):
|
49
|
+
tag_end = line.find(' ')
|
50
|
+
if tag_end == -1:
|
51
|
+
raise LineEmptyError
|
52
|
+
raw_tags = line[1:tag_end]
|
53
|
+
if max_tag_data_length is not None and len(raw_tags) > max_tag_data_length:
|
54
|
+
raise TagsTooLongError
|
55
|
+
tags = parse_tags(raw_tags)
|
56
|
+
# Skip over the tags and the separating space
|
57
|
+
line = line[tag_end + 1:]
|
58
|
+
|
59
|
+
#
|
60
|
+
|
61
|
+
# Truncate if desired
|
62
|
+
truncated = False
|
63
|
+
if truncate_len is not None and len(line) > truncate_len:
|
64
|
+
line = truncate_utf8_safe(line, truncate_len)
|
65
|
+
truncated = True
|
66
|
+
|
67
|
+
line = trim_initial_spaces(line)
|
68
|
+
|
69
|
+
# Handle source
|
70
|
+
source: str | None = None
|
71
|
+
|
72
|
+
if line.startswith(':'):
|
73
|
+
source_end = line.find(' ')
|
74
|
+
if source_end == -1:
|
75
|
+
raise LineEmptyError
|
76
|
+
source = line[1:source_end]
|
77
|
+
line = line[source_end + 1:]
|
78
|
+
|
79
|
+
# Modern: "These message parts, and parameters themselves, are separated by one or more ASCII SPACE characters"
|
80
|
+
line = trim_initial_spaces(line)
|
81
|
+
|
82
|
+
# Handle command
|
83
|
+
command_end = line.find(' ')
|
84
|
+
param_start = command_end + 1 if command_end != -1 else len(line)
|
85
|
+
base_command = line[:command_end] if command_end != -1 else line
|
86
|
+
|
87
|
+
if not base_command:
|
88
|
+
raise LineEmptyError
|
89
|
+
# Technically this must be either letters or a 3-digit numeric:
|
90
|
+
if not is_ascii(base_command):
|
91
|
+
raise BadCharactersError
|
92
|
+
|
93
|
+
# Normalize command to uppercase:
|
94
|
+
command = base_command.upper()
|
95
|
+
line = line[param_start:]
|
96
|
+
|
97
|
+
# Handle parameters
|
98
|
+
params: list[str] = []
|
99
|
+
while line:
|
100
|
+
line = trim_initial_spaces(line)
|
101
|
+
if not line:
|
102
|
+
break
|
103
|
+
# Handle trailing
|
104
|
+
if line.startswith(':'):
|
105
|
+
params.append(line[1:])
|
106
|
+
break
|
107
|
+
param_end = line.find(' ')
|
108
|
+
if param_end == -1:
|
109
|
+
params.append(line)
|
110
|
+
break
|
111
|
+
params.append(line[:param_end])
|
112
|
+
line = line[param_end + 1:]
|
113
|
+
|
114
|
+
#
|
115
|
+
|
116
|
+
msg = Message(
|
117
|
+
source=source,
|
118
|
+
command=command,
|
119
|
+
params=params,
|
120
|
+
tags=tags,
|
121
|
+
)
|
122
|
+
|
123
|
+
return ParsedLine(
|
124
|
+
msg,
|
125
|
+
truncated=truncated,
|
126
|
+
)
|
127
|
+
|
128
|
+
|
129
|
+
def parse_line(
|
130
|
+
line: str,
|
131
|
+
*,
|
132
|
+
max_tag_data_length: int | None = None,
|
133
|
+
truncate_len: int | None = None,
|
134
|
+
) -> Message:
|
135
|
+
return parse_line_(
|
136
|
+
line,
|
137
|
+
max_tag_data_length=max_tag_data_length,
|
138
|
+
truncate_len=truncate_len,
|
139
|
+
).message
|
140
|
+
|
141
|
+
|
142
|
+
def parse_line_strict(
|
143
|
+
line: str,
|
144
|
+
from_client: bool,
|
145
|
+
truncate_len: int | None,
|
146
|
+
) -> ParsedLine:
|
147
|
+
max_tag_data_length = MAX_LEN_TAG_DATA
|
148
|
+
if from_client:
|
149
|
+
max_tag_data_length = MAX_LEN_CLIENT_TAG_DATA
|
150
|
+
|
151
|
+
return parse_line_(
|
152
|
+
line,
|
153
|
+
max_tag_data_length=max_tag_data_length,
|
154
|
+
truncate_len=truncate_len,
|
155
|
+
)
|