omlish 0.0.0.dev219__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.
Files changed (66) hide show
  1. omlish/__about__.py +2 -2
  2. omlish/algorithm/__init__.py +0 -0
  3. omlish/algorithm/all.py +13 -0
  4. omlish/algorithm/distribute.py +46 -0
  5. omlish/algorithm/toposort.py +26 -0
  6. omlish/algorithm/unify.py +31 -0
  7. omlish/antlr/dot.py +13 -6
  8. omlish/collections/__init__.py +0 -2
  9. omlish/collections/utils.py +0 -46
  10. omlish/dataclasses/__init__.py +1 -0
  11. omlish/dataclasses/impl/api.py +10 -0
  12. omlish/dataclasses/impl/fields.py +8 -1
  13. omlish/dataclasses/impl/init.py +1 -1
  14. omlish/dataclasses/impl/main.py +1 -1
  15. omlish/dataclasses/impl/metaclass.py +112 -29
  16. omlish/dataclasses/impl/overrides.py +53 -0
  17. omlish/dataclasses/impl/params.py +3 -0
  18. omlish/dataclasses/impl/reflect.py +17 -5
  19. omlish/dataclasses/impl/simple.py +0 -42
  20. omlish/docker/oci/building.py +122 -0
  21. omlish/docker/oci/data.py +62 -8
  22. omlish/docker/oci/datarefs.py +98 -0
  23. omlish/docker/oci/loading.py +120 -0
  24. omlish/docker/oci/media.py +44 -14
  25. omlish/docker/oci/repositories.py +72 -0
  26. omlish/graphs/trees.py +2 -1
  27. omlish/http/coro/server.py +53 -24
  28. omlish/http/{simple.py → coro/simple.py} +17 -17
  29. omlish/http/handlers.py +8 -0
  30. omlish/io/fileno.py +11 -0
  31. omlish/lang/__init__.py +4 -1
  32. omlish/lang/cached.py +0 -1
  33. omlish/lang/classes/__init__.py +3 -1
  34. omlish/lang/classes/abstract.py +14 -1
  35. omlish/lang/classes/restrict.py +5 -5
  36. omlish/lang/classes/virtual.py +0 -1
  37. omlish/lang/clsdct.py +0 -1
  38. omlish/lang/contextmanagers.py +0 -8
  39. omlish/lang/descriptors.py +0 -1
  40. omlish/lang/maybes.py +0 -1
  41. omlish/lang/objects.py +0 -2
  42. omlish/secrets/ssl.py +9 -0
  43. omlish/secrets/tempssl.py +50 -0
  44. omlish/sockets/bind.py +6 -1
  45. omlish/sockets/server/server.py +18 -5
  46. omlish/specs/irc/__init__.py +0 -0
  47. omlish/specs/irc/format/LICENSE +11 -0
  48. omlish/specs/irc/format/__init__.py +61 -0
  49. omlish/specs/irc/format/consts.py +6 -0
  50. omlish/specs/irc/format/errors.py +30 -0
  51. omlish/specs/irc/format/message.py +18 -0
  52. omlish/specs/irc/format/nuh.py +52 -0
  53. omlish/specs/irc/format/parsing.py +155 -0
  54. omlish/specs/irc/format/rendering.py +150 -0
  55. omlish/specs/irc/format/tags.py +99 -0
  56. omlish/specs/irc/format/utils.py +27 -0
  57. omlish/specs/irc/numerics/__init__.py +0 -0
  58. omlish/specs/irc/numerics/formats.py +94 -0
  59. omlish/specs/irc/numerics/numerics.py +808 -0
  60. omlish/specs/irc/numerics/types.py +59 -0
  61. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/METADATA +1 -1
  62. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/RECORD +66 -38
  63. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/LICENSE +0 -0
  64. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/WHEEL +0 -0
  65. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/entry_points.txt +0 -0
  66. {omlish-0.0.0.dev219.dist-info → omlish-0.0.0.dev221.dist-info}/top_level.txt +0 -0
@@ -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,6 @@
1
+ # Constants
2
+ MAX_LEN_TAGS = 8191
3
+ MAX_LEN_TAG_DATA = MAX_LEN_TAGS - 2
4
+ MAX_LEN_CLIENT_TAG_DATA = 4094
5
+ MAX_LEN_SERVER_TAG_DATA = 4094
6
+ MAX_LEN_TAGS_FROM_CLIENT = MAX_LEN_CLIENT_TAG_DATA + 2
@@ -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
+ )
@@ -0,0 +1,150 @@
1
+ import typing as ta
2
+
3
+ from .consts import MAX_LEN_CLIENT_TAG_DATA
4
+ from .consts import MAX_LEN_SERVER_TAG_DATA
5
+ from .consts import MAX_LEN_TAGS_FROM_CLIENT
6
+ from .errors import BadCharactersError
7
+ from .errors import BadParamError
8
+ from .errors import CommandMissingError
9
+ from .errors import InvalidTagContentError
10
+ from .errors import TagsTooLongError
11
+ from .message import Message
12
+ from .tags import escape_tag_value
13
+ from .tags import validate_tag_name
14
+ from .tags import validate_tag_value
15
+ from .utils import find_utf8_truncation_point
16
+
17
+
18
+ def param_requires_trailing(param: str) -> bool:
19
+ return len(param) == 0 or ' ' in param or param[0] == ':'
20
+
21
+
22
+ class RenderedLine(ta.NamedTuple):
23
+ raw: bytes
24
+
25
+ truncated: bool = False
26
+
27
+
28
+ def render_line_(
29
+ msg: Message,
30
+ *,
31
+ tag_limit: int | None = None,
32
+ client_only_tag_data_limit: int | None = None,
33
+ server_added_tag_data_limit: int | None = None,
34
+ truncate_len: int | None = None,
35
+ ) -> RenderedLine:
36
+ if not msg.command:
37
+ raise CommandMissingError
38
+
39
+ buf = bytearray()
40
+ len_regular_tags = len_client_only_tags = 0
41
+
42
+ # Write the tags, computing the budgets for client-only tags and regular tags
43
+ if msg.tags or msg.client_only_tags:
44
+ buf.append(ord('@'))
45
+ first_tag = True
46
+ tag_error = None
47
+
48
+ def write_tags(tags: ta.Mapping[str, str]) -> None:
49
+ nonlocal first_tag, tag_error
50
+ for tag, val in tags.items():
51
+ if not (validate_tag_name(tag) and validate_tag_value(val)):
52
+ tag_error = InvalidTagContentError
53
+ if not first_tag:
54
+ buf.append(ord(';'))
55
+ buf.extend(tag.encode('utf-8'))
56
+ if val:
57
+ buf.append(ord('='))
58
+ buf.extend(escape_tag_value(val).encode('utf-8'))
59
+ first_tag = False
60
+
61
+ write_tags(msg.tags or {})
62
+ len_regular_tags = len(buf) - 1
63
+ write_tags(msg.client_only_tags or {})
64
+ len_client_only_tags = (len(buf) - 1) - len_regular_tags
65
+ if len_regular_tags:
66
+ # Semicolon between regular and client-only tags is not counted
67
+ len_client_only_tags -= 1
68
+ buf.append(ord(' '))
69
+ if tag_error:
70
+ raise tag_error
71
+
72
+ len_tags = len(buf)
73
+ if tag_limit is not None and len(buf) > tag_limit:
74
+ raise TagsTooLongError
75
+ if (
76
+ (client_only_tag_data_limit is not None and len_client_only_tags > client_only_tag_data_limit) or
77
+ (server_added_tag_data_limit is not None and len_regular_tags > server_added_tag_data_limit)
78
+ ):
79
+ raise TagsTooLongError
80
+
81
+ if msg.source:
82
+ buf.append(ord(':'))
83
+ buf.extend(msg.source.encode('utf-8'))
84
+ buf.append(ord(' '))
85
+
86
+ buf.extend(msg.command.encode('utf-8'))
87
+
88
+ for i, param in enumerate(msg.params):
89
+ buf.append(ord(' '))
90
+ requires_trailing = param_requires_trailing(param)
91
+ last_param = i == len(msg.params) - 1
92
+
93
+ if (requires_trailing or msg.force_trailing) and last_param:
94
+ buf.append(ord(':'))
95
+ elif requires_trailing and not last_param:
96
+ raise BadParamError
97
+
98
+ buf.extend(param.encode('utf-8'))
99
+
100
+ # Truncate if desired; leave 2 bytes over for \r\n:
101
+ truncated = False
102
+ if truncate_len is not None and (truncate_len - 2) < (len(buf) - len_tags):
103
+ truncated = True
104
+ new_buf_len = len_tags + (truncate_len - 2)
105
+ buf = buf[:find_utf8_truncation_point(buf, new_buf_len)]
106
+
107
+ buf.extend(b'\r\n')
108
+
109
+ to_validate = buf[:-2]
110
+ if any(c in to_validate for c in (b'\x00', b'\r', b'\n')):
111
+ raise BadCharactersError
112
+
113
+ raw = bytes(buf)
114
+
115
+ return RenderedLine(
116
+ raw=raw,
117
+ truncated=truncated,
118
+ )
119
+
120
+
121
+ def render_line(msg: Message) -> bytes:
122
+ return render_line_(msg).raw
123
+
124
+
125
+ def render_line_strict(
126
+ msg: Message,
127
+ from_client: bool,
128
+ truncate_len: int | None,
129
+ ) -> RenderedLine:
130
+ tag_limit: int | None = None
131
+ client_only_tag_data_limit: int | None = None
132
+ server_added_tag_data_limit: int | None = None
133
+ if from_client:
134
+ # enforce client max tags:
135
+ # <client_max> (4096) :: '@' <tag_data 4094> ' '
136
+ tag_limit = MAX_LEN_TAGS_FROM_CLIENT
137
+ else:
138
+ # on the server side, enforce separate client-only and server-added tag budgets:
139
+ # "Servers MUST NOT add tag data exceeding 4094 bytes to messages."
140
+ # <combined_max> (8191) :: '@' <tag_data 4094> ';' <tag_data 4094> ' '
141
+ client_only_tag_data_limit = MAX_LEN_CLIENT_TAG_DATA
142
+ server_added_tag_data_limit = MAX_LEN_SERVER_TAG_DATA
143
+
144
+ return render_line_(
145
+ msg,
146
+ tag_limit=tag_limit,
147
+ client_only_tag_data_limit=client_only_tag_data_limit,
148
+ server_added_tag_data_limit=server_added_tag_data_limit,
149
+ truncate_len=truncate_len,
150
+ )
@@ -0,0 +1,99 @@
1
+ from .errors import InvalidTagContentError
2
+
3
+
4
+ # Mapping for escaping tag values
5
+ TAG_VAL_TO_ESCAPE = {
6
+ '\\': '\\\\',
7
+ ';': '\\:',
8
+ ' ': '\\s',
9
+ '\r': '\\r',
10
+ '\n': '\\n',
11
+ }
12
+
13
+
14
+ TAG_ESCAPE_CHAR_LOOKUP_TABLE = {i: chr(i) for i in range(256)} # Most chars escape to themselves
15
+
16
+ # These are the exceptions
17
+ TAG_ESCAPE_CHAR_LOOKUP_TABLE.update({
18
+ ord(':'): ';',
19
+ ord('s'): ' ',
20
+ ord('r'): '\r',
21
+ ord('n'): '\n',
22
+ })
23
+
24
+
25
+ def escape_tag_value(in_string: str) -> str:
26
+ for key, val in TAG_VAL_TO_ESCAPE.items():
27
+ in_string = in_string.replace(key, val)
28
+ return in_string
29
+
30
+
31
+ def unescape_tag_value(in_string: str) -> str:
32
+ if '\\' not in in_string:
33
+ return in_string
34
+
35
+ buf = []
36
+ remainder = in_string
37
+ while remainder:
38
+ backslash_pos = remainder.find('\\')
39
+ if backslash_pos == -1:
40
+ buf.append(remainder)
41
+ break
42
+ elif backslash_pos == len(remainder) - 1:
43
+ # Trailing backslash, which we strip
44
+ buf.append(remainder[:-1])
45
+ break
46
+
47
+ buf.append(remainder[:backslash_pos])
48
+ buf.append(TAG_ESCAPE_CHAR_LOOKUP_TABLE.get(ord(remainder[backslash_pos + 1]), remainder[backslash_pos + 1]))
49
+ remainder = remainder[backslash_pos + 2:]
50
+
51
+ return ''.join(buf)
52
+
53
+
54
+ def validate_tag_name(name: str) -> bool:
55
+ if len(name) == 0:
56
+ return False
57
+ if name[0] == '+':
58
+ name = name[1:]
59
+ if len(name) == 0:
60
+ return False
61
+ # Let's err on the side of leniency here; allow -./ (45-47) in any position
62
+ for c in name: # noqa
63
+ if not (('-' <= c <= '/') or ('0' <= c <= '9') or ('A' <= c <= 'Z') or ('a' <= c <= 'z')):
64
+ return False
65
+ return True
66
+
67
+
68
+ def validate_tag_value(value: str) -> bool:
69
+ rt = value.encode('utf-8', 'ignore').decode('utf-8', 'ignore')
70
+ return value == rt
71
+
72
+
73
+ def parse_tags(raw_tags: str) -> dict[str, str]:
74
+ dct: dict[str, str] = {}
75
+
76
+ while raw_tags:
77
+ tag_end = raw_tags.find(';')
78
+ if tag_end == -1:
79
+ tag_pair = raw_tags
80
+ raw_tags = ''
81
+ else:
82
+ tag_pair = raw_tags[:tag_end]
83
+ raw_tags = raw_tags[tag_end + 1:]
84
+
85
+ equals_index = tag_pair.find('=')
86
+ if equals_index == -1:
87
+ # Tag with no value
88
+ tag_name, tag_value = tag_pair, ''
89
+ else:
90
+ tag_name, tag_value = tag_pair[:equals_index], tag_pair[equals_index + 1:]
91
+
92
+ # "Implementations [...] MUST NOT perform any validation that would reject the message if an invalid tag key
93
+ # name is used."
94
+ if validate_tag_name(tag_name):
95
+ if not validate_tag_value(tag_value):
96
+ raise InvalidTagContentError
97
+ dct[tag_name] = unescape_tag_value(tag_value)
98
+
99
+ return dct
@@ -0,0 +1,27 @@
1
+ import itertools
2
+ import operator
3
+
4
+
5
+ def truncate_utf8_safe(string: str, length: int) -> str:
6
+ return string[:length] \
7
+ .encode('utf-8', 'ignore') \
8
+ .decode('utf-8', 'ignore')
9
+
10
+
11
+ def find_utf8_truncation_point(buf: bytes | bytearray, length: int) -> int:
12
+ if len(buf) < length:
13
+ raise ValueError(buf)
14
+ cs = itertools.accumulate(
15
+ (len(c.encode('utf-8')) for c in buf.decode('utf-8')),
16
+ operator.add,
17
+ initial=0,
18
+ )
19
+ return next(i for i, o in enumerate(cs) if o >= length)
20
+
21
+
22
+ def trim_initial_spaces(string: str) -> str:
23
+ return string.lstrip(' ')
24
+
25
+
26
+ def is_ascii(string: str) -> bool:
27
+ return all(ord(c) < 128 for c in string)
File without changes
@@ -0,0 +1,94 @@
1
+ import dataclasses as dc
2
+ import typing as ta
3
+
4
+ from .... import check
5
+ from .... import lang
6
+
7
+
8
+ FormatPart: ta.TypeAlias = ta.Union[str, 'Formats.Optional', 'Formats.Variadic']
9
+ FormatParts: ta.TypeAlias = ta.Sequence[FormatPart]
10
+
11
+
12
+ class Formats(lang.Namespace):
13
+ @dc.dataclass(frozen=True)
14
+ class Name:
15
+ name: str
16
+
17
+ @dc.dataclass(frozen=True)
18
+ class Optional:
19
+ body: FormatParts
20
+
21
+ @dc.dataclass(frozen=True)
22
+ class Variadic:
23
+ body: FormatParts
24
+
25
+ #
26
+
27
+ _PARTS_BY_DELIMITERS: ta.Mapping[tuple[str, str], type] = {
28
+ ('[', ']'): Optional,
29
+ ('{', '}'): Variadic,
30
+ }
31
+
32
+ _DELIMITERS_BY_PARTS: ta.Mapping[type, tuple[str, str]] = {v: k for k, v in _PARTS_BY_DELIMITERS.items()}
33
+
34
+ #
35
+
36
+ @staticmethod
37
+ def split_parts(s: str) -> FormatParts:
38
+ stk: list[tuple[str, list]] = [('', [])]
39
+
40
+ p = 0
41
+ while p < len(s):
42
+ n = lang.find_any(s, '{}[]<', p)
43
+
44
+ if n < 0:
45
+ check.state(not stk[-1][0])
46
+ stk[-1][1].append(s[p:])
47
+ break
48
+
49
+ if n != p:
50
+ stk[-1][1].append(s[p:n])
51
+
52
+ d = s[n]
53
+ if d == '<':
54
+ e = s.index('>', n)
55
+ stk[-1][1].append(Formats.Name(s[n + 1:e]))
56
+ p = e + 1
57
+
58
+ elif d in '{[':
59
+ stk.append((d, []))
60
+ p = n + 1
61
+
62
+ elif d in '}]':
63
+ x, l = stk.pop()
64
+ pc = Formats._PARTS_BY_DELIMITERS[(x, d)]
65
+ stk[-1][1].append(pc(l))
66
+ p = n + 1
67
+
68
+ else:
69
+ raise RuntimeError
70
+
71
+ _, ret = check.single(stk)
72
+ return ret
73
+
74
+ #
75
+
76
+ @staticmethod
77
+ def render_parts(p: FormatPart | FormatParts) -> ta.Iterator[str]:
78
+ if isinstance(p, str):
79
+ yield p
80
+
81
+ elif isinstance(p, Formats.Name):
82
+ yield '<'
83
+ yield p.name
84
+ yield '>'
85
+
86
+ elif isinstance(p, (Formats.Optional, Formats.Variadic)):
87
+ l, r = Formats._DELIMITERS_BY_PARTS[type(p)]
88
+ yield l
89
+ yield from Formats.render_parts(p.body)
90
+ yield r
91
+
92
+ else:
93
+ for c in p:
94
+ yield from Formats.render_parts(c)