omextra 0.0.0.dev423__py3-none-any.whl → 0.0.0.dev425__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.
Files changed (68) hide show
  1. omextra/.omlish-manifests.json +14 -0
  2. omextra/__about__.py +3 -1
  3. omextra/defs.py +216 -0
  4. omextra/dynamic.py +219 -0
  5. omextra/formats/__init__.py +0 -0
  6. omextra/formats/json/Json.g4 +77 -0
  7. omextra/formats/json/__init__.py +0 -0
  8. omextra/formats/json/_antlr/JsonLexer.py +109 -0
  9. omextra/formats/json/_antlr/JsonListener.py +61 -0
  10. omextra/formats/json/_antlr/JsonParser.py +457 -0
  11. omextra/formats/json/_antlr/JsonVisitor.py +42 -0
  12. omextra/formats/json/_antlr/__init__.py +0 -0
  13. omextra/io/__init__.py +0 -0
  14. omextra/io/trampoline.py +289 -0
  15. omextra/specs/__init__.py +0 -0
  16. omextra/specs/irc/__init__.py +0 -0
  17. omextra/specs/irc/messages/__init__.py +0 -0
  18. omextra/specs/irc/messages/base.py +50 -0
  19. omextra/specs/irc/messages/formats.py +92 -0
  20. omextra/specs/irc/messages/messages.py +775 -0
  21. omextra/specs/irc/messages/parsing.py +99 -0
  22. omextra/specs/irc/numerics/__init__.py +0 -0
  23. omextra/specs/irc/numerics/formats.py +97 -0
  24. omextra/specs/irc/numerics/numerics.py +865 -0
  25. omextra/specs/irc/numerics/types.py +59 -0
  26. omextra/specs/irc/protocol/LICENSE +11 -0
  27. omextra/specs/irc/protocol/__init__.py +61 -0
  28. omextra/specs/irc/protocol/consts.py +6 -0
  29. omextra/specs/irc/protocol/errors.py +30 -0
  30. omextra/specs/irc/protocol/message.py +21 -0
  31. omextra/specs/irc/protocol/nuh.py +55 -0
  32. omextra/specs/irc/protocol/parsing.py +158 -0
  33. omextra/specs/irc/protocol/rendering.py +153 -0
  34. omextra/specs/irc/protocol/tags.py +102 -0
  35. omextra/specs/irc/protocol/utils.py +30 -0
  36. omextra/specs/proto/Protobuf3.g4 +396 -0
  37. omextra/specs/proto/__init__.py +0 -0
  38. omextra/specs/proto/_antlr/Protobuf3Lexer.py +340 -0
  39. omextra/specs/proto/_antlr/Protobuf3Listener.py +448 -0
  40. omextra/specs/proto/_antlr/Protobuf3Parser.py +3909 -0
  41. omextra/specs/proto/_antlr/Protobuf3Visitor.py +257 -0
  42. omextra/specs/proto/_antlr/__init__.py +0 -0
  43. omextra/specs/proto/nodes.py +54 -0
  44. omextra/specs/proto/parsing.py +98 -0
  45. omextra/sql/__init__.py +0 -0
  46. omextra/sql/parsing/Minisql.g4 +292 -0
  47. omextra/sql/parsing/__init__.py +0 -0
  48. omextra/sql/parsing/_antlr/MinisqlLexer.py +322 -0
  49. omextra/sql/parsing/_antlr/MinisqlListener.py +511 -0
  50. omextra/sql/parsing/_antlr/MinisqlParser.py +3763 -0
  51. omextra/sql/parsing/_antlr/MinisqlVisitor.py +292 -0
  52. omextra/sql/parsing/_antlr/__init__.py +0 -0
  53. omextra/sql/parsing/parsing.py +120 -0
  54. omextra/text/__init__.py +0 -0
  55. omextra/text/antlr/__init__.py +0 -0
  56. omextra/text/antlr/cli/__init__.py +0 -0
  57. omextra/text/antlr/cli/__main__.py +11 -0
  58. omextra/text/antlr/cli/cli.py +62 -0
  59. omextra/text/antlr/cli/consts.py +7 -0
  60. omextra/text/antlr/cli/gen.py +188 -0
  61. {omextra-0.0.0.dev423.dist-info → omextra-0.0.0.dev425.dist-info}/METADATA +2 -3
  62. omextra-0.0.0.dev425.dist-info/RECORD +67 -0
  63. omextra/.manifests.json +0 -1
  64. omextra-0.0.0.dev423.dist-info/RECORD +0 -9
  65. {omextra-0.0.0.dev423.dist-info → omextra-0.0.0.dev425.dist-info}/WHEEL +0 -0
  66. {omextra-0.0.0.dev423.dist-info → omextra-0.0.0.dev425.dist-info}/entry_points.txt +0 -0
  67. {omextra-0.0.0.dev423.dist-info → omextra-0.0.0.dev425.dist-info}/licenses/LICENSE +0 -0
  68. {omextra-0.0.0.dev423.dist-info → omextra-0.0.0.dev425.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,59 @@
1
+ import dataclasses as dc
2
+ import typing as ta
3
+
4
+ from omlish import collections as col
5
+
6
+
7
+ ##
8
+
9
+
10
+ @dc.dataclass(frozen=True)
11
+ class NumericReply:
12
+ name: str
13
+ num: int
14
+ formats: ta.Sequence[str]
15
+
16
+ @classmethod
17
+ def new(
18
+ cls,
19
+ name: str,
20
+ num: int,
21
+ *formats: str,
22
+ ) -> 'NumericReply':
23
+ return cls(
24
+ name,
25
+ num,
26
+ formats,
27
+ )
28
+
29
+
30
+ ##
31
+
32
+
33
+ class NumericReplies:
34
+ def __init__(self, lst: ta.Iterable[NumericReply]) -> None:
35
+ super().__init__()
36
+
37
+ self._lst = list(lst)
38
+ self._by_name = col.make_map_by(lambda nr: nr.name, self._lst, strict=True)
39
+ self._by_num = col.make_map_by(lambda nr: nr.num, self._lst, strict=True)
40
+
41
+ def __len__(self) -> int:
42
+ return len(self._lst)
43
+
44
+ def __iter__(self) -> ta.Iterator[NumericReply]:
45
+ return iter(self._lst)
46
+
47
+ def __getitem__(self, key: str | int) -> NumericReply:
48
+ if isinstance(key, str):
49
+ return self._by_name[key]
50
+ elif isinstance(key, int):
51
+ return self._by_num[key]
52
+ else:
53
+ raise TypeError(key)
54
+
55
+ def get(self, key: str | int) -> NumericReply | None:
56
+ try:
57
+ return self[key]
58
+ except KeyError:
59
+ return None
@@ -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,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,21 @@
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
+ ##
10
+
11
+
12
+ @dc.dataclass(frozen=True)
13
+ class Message:
14
+ source: str | None
15
+ command: str
16
+ params: ta.Sequence[str]
17
+
18
+ force_trailing: bool = False
19
+
20
+ tags: ta.Mapping[str, str] | None = None
21
+ client_only_tags: ta.Mapping[str, str] | None = None
@@ -0,0 +1,55 @@
1
+ import dataclasses as dc
2
+ import typing as ta
3
+
4
+ from .errors import MalformedNuhError
5
+
6
+
7
+ ##
8
+
9
+
10
+ @dc.dataclass(frozen=True)
11
+ class Nuh:
12
+ name: str | None = None
13
+ user: str | None = None
14
+ host: str | None = None
15
+
16
+ @property
17
+ def tuple(self) -> tuple[str | None, ...]:
18
+ return (self.name, self.user, self.host)
19
+
20
+ def __iter__(self) -> ta.Iterator[str | None]:
21
+ return iter(self.tuple)
22
+
23
+ @classmethod
24
+ def parse(cls, inp: str) -> 'Nuh':
25
+ if not inp:
26
+ raise MalformedNuhError
27
+
28
+ host: str | None = None
29
+ host_start = inp.find('@')
30
+ if host_start != -1:
31
+ host = inp[host_start + 1:]
32
+ inp = inp[:host_start]
33
+
34
+ user_start = inp.find('!')
35
+ user: str | None = None
36
+ if user_start != -1:
37
+ user = inp[user_start + 1:]
38
+ inp = inp[:user_start]
39
+
40
+ return cls(
41
+ name=inp or None,
42
+ user=user,
43
+ host=host,
44
+ )
45
+
46
+ @property
47
+ def canonical(self) -> str:
48
+ parts = []
49
+ if (n := self.name) is not None:
50
+ parts.append(n)
51
+ if (u := self.user) is not None:
52
+ parts.append(f'!{u}')
53
+ if (h := self.host) is not None:
54
+ parts.append(f'@{h}')
55
+ return ''.join(parts)
@@ -0,0 +1,158 @@
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
+ ##
16
+
17
+
18
+ class ParsedLine(ta.NamedTuple):
19
+ message: Message
20
+
21
+ truncated: bool = False
22
+
23
+
24
+ def parse_line_(
25
+ line: str,
26
+ *,
27
+ max_tag_data_length: int | None = None,
28
+ truncate_len: int | None = None,
29
+ ) -> ParsedLine:
30
+ # Remove either \n or \r\n from the end of the line:
31
+ line = line.removesuffix('\n')
32
+ line = line.removesuffix('\r')
33
+
34
+ # Whether we removed them ourselves, or whether they were removed previously, they count against the line limit:
35
+ if truncate_len is not None:
36
+ if truncate_len <= 2:
37
+ raise LineEmptyError
38
+ truncate_len -= 2
39
+
40
+ # Now validate for the 3 forbidden bytes:
41
+ if any(c in line for c in '\x00\n\r'):
42
+ raise BadCharactersError
43
+
44
+ if not line:
45
+ raise LineEmptyError
46
+
47
+ #
48
+
49
+ # Handle tags
50
+ tags: ta.Mapping[str, str] | None = None
51
+ if line.startswith('@'):
52
+ tag_end = line.find(' ')
53
+ if tag_end == -1:
54
+ raise LineEmptyError
55
+ raw_tags = line[1:tag_end]
56
+ if max_tag_data_length is not None and len(raw_tags) > max_tag_data_length:
57
+ raise TagsTooLongError
58
+ tags = parse_tags(raw_tags)
59
+ # Skip over the tags and the separating space
60
+ line = line[tag_end + 1:]
61
+
62
+ #
63
+
64
+ # Truncate if desired
65
+ truncated = False
66
+ if truncate_len is not None and len(line) > truncate_len:
67
+ line = truncate_utf8_safe(line, truncate_len)
68
+ truncated = True
69
+
70
+ line = trim_initial_spaces(line)
71
+
72
+ # Handle source
73
+ source: str | None = None
74
+
75
+ if line.startswith(':'):
76
+ source_end = line.find(' ')
77
+ if source_end == -1:
78
+ raise LineEmptyError
79
+ source = line[1:source_end]
80
+ line = line[source_end + 1:]
81
+
82
+ # Modern: "These message parts, and parameters themselves, are separated by one or more ASCII SPACE characters"
83
+ line = trim_initial_spaces(line)
84
+
85
+ # Handle command
86
+ command_end = line.find(' ')
87
+ param_start = command_end + 1 if command_end != -1 else len(line)
88
+ base_command = line[:command_end] if command_end != -1 else line
89
+
90
+ if not base_command:
91
+ raise LineEmptyError
92
+ # Technically this must be either letters or a 3-digit numeric:
93
+ if not is_ascii(base_command):
94
+ raise BadCharactersError
95
+
96
+ # Normalize command to uppercase:
97
+ command = base_command.upper()
98
+ line = line[param_start:]
99
+
100
+ # Handle parameters
101
+ params: list[str] = []
102
+ while line:
103
+ line = trim_initial_spaces(line)
104
+ if not line:
105
+ break
106
+ # Handle trailing
107
+ if line.startswith(':'):
108
+ params.append(line[1:])
109
+ break
110
+ param_end = line.find(' ')
111
+ if param_end == -1:
112
+ params.append(line)
113
+ break
114
+ params.append(line[:param_end])
115
+ line = line[param_end + 1:]
116
+
117
+ #
118
+
119
+ msg = Message(
120
+ source=source,
121
+ command=command,
122
+ params=params,
123
+ tags=tags,
124
+ )
125
+
126
+ return ParsedLine(
127
+ msg,
128
+ truncated=truncated,
129
+ )
130
+
131
+
132
+ def parse_line(
133
+ line: str,
134
+ *,
135
+ max_tag_data_length: int | None = None,
136
+ truncate_len: int | None = None,
137
+ ) -> Message:
138
+ return parse_line_(
139
+ line,
140
+ max_tag_data_length=max_tag_data_length,
141
+ truncate_len=truncate_len,
142
+ ).message
143
+
144
+
145
+ def parse_line_strict(
146
+ line: str,
147
+ from_client: bool,
148
+ truncate_len: int | None,
149
+ ) -> ParsedLine:
150
+ max_tag_data_length = MAX_LEN_TAG_DATA
151
+ if from_client:
152
+ max_tag_data_length = MAX_LEN_CLIENT_TAG_DATA
153
+
154
+ return parse_line_(
155
+ line,
156
+ max_tag_data_length=max_tag_data_length,
157
+ truncate_len=truncate_len,
158
+ )
@@ -0,0 +1,153 @@
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
+ ##
19
+
20
+
21
+ def param_requires_trailing(param: str) -> bool:
22
+ return len(param) == 0 or ' ' in param or param[0] == ':'
23
+
24
+
25
+ class RenderedLine(ta.NamedTuple):
26
+ raw: bytes
27
+
28
+ truncated: bool = False
29
+
30
+
31
+ def render_line_(
32
+ msg: Message,
33
+ *,
34
+ tag_limit: int | None = None,
35
+ client_only_tag_data_limit: int | None = None,
36
+ server_added_tag_data_limit: int | None = None,
37
+ truncate_len: int | None = None,
38
+ ) -> RenderedLine:
39
+ if not msg.command:
40
+ raise CommandMissingError
41
+
42
+ buf = bytearray()
43
+ len_regular_tags = len_client_only_tags = 0
44
+
45
+ # Write the tags, computing the budgets for client-only tags and regular tags
46
+ if msg.tags or msg.client_only_tags:
47
+ buf.append(ord('@'))
48
+ first_tag = True
49
+ tag_error = None
50
+
51
+ def write_tags(tags: ta.Mapping[str, str]) -> None:
52
+ nonlocal first_tag, tag_error
53
+ for tag, val in tags.items():
54
+ if not (validate_tag_name(tag) and validate_tag_value(val)):
55
+ tag_error = InvalidTagContentError
56
+ if not first_tag:
57
+ buf.append(ord(';'))
58
+ buf.extend(tag.encode('utf-8'))
59
+ if val:
60
+ buf.append(ord('='))
61
+ buf.extend(escape_tag_value(val).encode('utf-8'))
62
+ first_tag = False
63
+
64
+ write_tags(msg.tags or {})
65
+ len_regular_tags = len(buf) - 1
66
+ write_tags(msg.client_only_tags or {})
67
+ len_client_only_tags = (len(buf) - 1) - len_regular_tags
68
+ if len_regular_tags:
69
+ # Semicolon between regular and client-only tags is not counted
70
+ len_client_only_tags -= 1
71
+ buf.append(ord(' '))
72
+ if tag_error:
73
+ raise tag_error
74
+
75
+ len_tags = len(buf)
76
+ if tag_limit is not None and len(buf) > tag_limit:
77
+ raise TagsTooLongError
78
+ if (
79
+ (client_only_tag_data_limit is not None and len_client_only_tags > client_only_tag_data_limit) or
80
+ (server_added_tag_data_limit is not None and len_regular_tags > server_added_tag_data_limit)
81
+ ):
82
+ raise TagsTooLongError
83
+
84
+ if msg.source:
85
+ buf.append(ord(':'))
86
+ buf.extend(msg.source.encode('utf-8'))
87
+ buf.append(ord(' '))
88
+
89
+ buf.extend(msg.command.encode('utf-8'))
90
+
91
+ for i, param in enumerate(msg.params):
92
+ buf.append(ord(' '))
93
+ requires_trailing = param_requires_trailing(param)
94
+ last_param = i == len(msg.params) - 1
95
+
96
+ if (requires_trailing or msg.force_trailing) and last_param:
97
+ buf.append(ord(':'))
98
+ elif requires_trailing and not last_param:
99
+ raise BadParamError
100
+
101
+ buf.extend(param.encode('utf-8'))
102
+
103
+ # Truncate if desired; leave 2 bytes over for \r\n:
104
+ truncated = False
105
+ if truncate_len is not None and (truncate_len - 2) < (len(buf) - len_tags):
106
+ truncated = True
107
+ new_buf_len = len_tags + (truncate_len - 2)
108
+ buf = buf[:find_utf8_truncation_point(buf, new_buf_len)]
109
+
110
+ buf.extend(b'\r\n')
111
+
112
+ to_validate = buf[:-2]
113
+ if any(c in to_validate for c in (b'\x00', b'\r', b'\n')):
114
+ raise BadCharactersError
115
+
116
+ raw = bytes(buf)
117
+
118
+ return RenderedLine(
119
+ raw=raw,
120
+ truncated=truncated,
121
+ )
122
+
123
+
124
+ def render_line(msg: Message) -> bytes:
125
+ return render_line_(msg).raw
126
+
127
+
128
+ def render_line_strict(
129
+ msg: Message,
130
+ from_client: bool,
131
+ truncate_len: int | None,
132
+ ) -> RenderedLine:
133
+ tag_limit: int | None = None
134
+ client_only_tag_data_limit: int | None = None
135
+ server_added_tag_data_limit: int | None = None
136
+ if from_client:
137
+ # enforce client max tags:
138
+ # <client_max> (4096) :: '@' <tag_data 4094> ' '
139
+ tag_limit = MAX_LEN_TAGS_FROM_CLIENT
140
+ else:
141
+ # on the server side, enforce separate client-only and server-added tag budgets:
142
+ # "Servers MUST NOT add tag data exceeding 4094 bytes to messages."
143
+ # <combined_max> (8191) :: '@' <tag_data 4094> ';' <tag_data 4094> ' '
144
+ client_only_tag_data_limit = MAX_LEN_CLIENT_TAG_DATA
145
+ server_added_tag_data_limit = MAX_LEN_SERVER_TAG_DATA
146
+
147
+ return render_line_(
148
+ msg,
149
+ tag_limit=tag_limit,
150
+ client_only_tag_data_limit=client_only_tag_data_limit,
151
+ server_added_tag_data_limit=server_added_tag_data_limit,
152
+ truncate_len=truncate_len,
153
+ )
@@ -0,0 +1,102 @@
1
+ from .errors import InvalidTagContentError
2
+
3
+
4
+ ##
5
+
6
+
7
+ # Mapping for escaping tag values
8
+ TAG_VAL_TO_ESCAPE = {
9
+ '\\': '\\\\',
10
+ ';': '\\:',
11
+ ' ': '\\s',
12
+ '\r': '\\r',
13
+ '\n': '\\n',
14
+ }
15
+
16
+
17
+ TAG_ESCAPE_CHAR_LOOKUP_TABLE = {i: chr(i) for i in range(256)} # Most chars escape to themselves
18
+
19
+ # These are the exceptions
20
+ TAG_ESCAPE_CHAR_LOOKUP_TABLE.update({
21
+ ord(':'): ';',
22
+ ord('s'): ' ',
23
+ ord('r'): '\r',
24
+ ord('n'): '\n',
25
+ })
26
+
27
+
28
+ def escape_tag_value(in_string: str) -> str:
29
+ for key, val in TAG_VAL_TO_ESCAPE.items():
30
+ in_string = in_string.replace(key, val)
31
+ return in_string
32
+
33
+
34
+ def unescape_tag_value(in_string: str) -> str:
35
+ if '\\' not in in_string:
36
+ return in_string
37
+
38
+ buf = []
39
+ remainder = in_string
40
+ while remainder:
41
+ backslash_pos = remainder.find('\\')
42
+ if backslash_pos == -1:
43
+ buf.append(remainder)
44
+ break
45
+ elif backslash_pos == len(remainder) - 1:
46
+ # Trailing backslash, which we strip
47
+ buf.append(remainder[:-1])
48
+ break
49
+
50
+ buf.append(remainder[:backslash_pos])
51
+ buf.append(TAG_ESCAPE_CHAR_LOOKUP_TABLE.get(ord(remainder[backslash_pos + 1]), remainder[backslash_pos + 1]))
52
+ remainder = remainder[backslash_pos + 2:]
53
+
54
+ return ''.join(buf)
55
+
56
+
57
+ def validate_tag_name(name: str) -> bool:
58
+ if len(name) == 0:
59
+ return False
60
+ if name[0] == '+':
61
+ name = name[1:]
62
+ if len(name) == 0:
63
+ return False
64
+ # Let's err on the side of leniency here; allow -./ (45-47) in any position
65
+ for c in name: # noqa
66
+ if not (('-' <= c <= '/') or ('0' <= c <= '9') or ('A' <= c <= 'Z') or ('a' <= c <= 'z')):
67
+ return False
68
+ return True
69
+
70
+
71
+ def validate_tag_value(value: str) -> bool:
72
+ rt = value.encode('utf-8', 'ignore').decode('utf-8', 'ignore')
73
+ return value == rt
74
+
75
+
76
+ def parse_tags(raw_tags: str) -> dict[str, str]:
77
+ dct: dict[str, str] = {}
78
+
79
+ while raw_tags:
80
+ tag_end = raw_tags.find(';')
81
+ if tag_end == -1:
82
+ tag_pair = raw_tags
83
+ raw_tags = ''
84
+ else:
85
+ tag_pair = raw_tags[:tag_end]
86
+ raw_tags = raw_tags[tag_end + 1:]
87
+
88
+ equals_index = tag_pair.find('=')
89
+ if equals_index == -1:
90
+ # Tag with no value
91
+ tag_name, tag_value = tag_pair, ''
92
+ else:
93
+ tag_name, tag_value = tag_pair[:equals_index], tag_pair[equals_index + 1:]
94
+
95
+ # "Implementations [...] MUST NOT perform any validation that would reject the message if an invalid tag key
96
+ # name is used."
97
+ if validate_tag_name(tag_name):
98
+ if not validate_tag_value(tag_value):
99
+ raise InvalidTagContentError
100
+ dct[tag_name] = unescape_tag_value(tag_value)
101
+
102
+ return dct
@@ -0,0 +1,30 @@
1
+ import itertools
2
+ import operator
3
+
4
+
5
+ ##
6
+
7
+
8
+ def truncate_utf8_safe(string: str, length: int) -> str:
9
+ return string[:length] \
10
+ .encode('utf-8', 'ignore') \
11
+ .decode('utf-8', 'ignore')
12
+
13
+
14
+ def find_utf8_truncation_point(buf: bytes | bytearray, length: int) -> int:
15
+ if len(buf) < length:
16
+ raise ValueError(buf)
17
+ cs = itertools.accumulate(
18
+ (len(c.encode('utf-8')) for c in buf.decode('utf-8')),
19
+ operator.add,
20
+ initial=0,
21
+ )
22
+ return next(i for i, o in enumerate(cs) if o >= length)
23
+
24
+
25
+ def trim_initial_spaces(string: str) -> str:
26
+ return string.lstrip(' ')
27
+
28
+
29
+ def is_ascii(string: str) -> bool:
30
+ return all(ord(c) < 128 for c in string)