gnobjects 0.0.0.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 KeyisB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ graft gnobjects
2
+ global-exclude __pycache__ *.py[cod] .DS_Store
3
+ include LICENSE
4
+ include README.md
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: gnobjects
3
+ Version: 0.0.0.0.1
4
+ Summary: gnobjects
5
+ Home-page: https://github.com/KeyisB/libs/tree/main/gnobjects
6
+ Author: KeyisB
7
+ Author-email: keyisb.pip@gmail.com
8
+ License: MMB License v1.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/plain
13
+ License-File: LICENSE
14
+ Requires-Dist: KeyisBTools
15
+ Requires-Dist: ast
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: license
23
+ Dynamic: license-file
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ GW and MMB Project libraries
@@ -0,0 +1,28 @@
1
+ Copyright (C) 2024 KeyisB. All rights reserved.
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated
5
+ 1. Copying, modification, merging, publishing, distribution,
6
+ sublicensing, and/or selling copies of the Software are
7
+ strictly prohibited.documentation
8
+ files (the "Software"), to use the Software exclusively for
9
+ projects related to the GN or GW systems, including personal,
10
+ educational, and commercial purposes, subject to the following
11
+ conditions:
12
+
13
+ 2. The licensee may use the Software only in its original,
14
+ unmodified form.
15
+ 3. All copies or substantial portions of the Software must
16
+ remain unaltered and include this copyright notice and these terms of use.
17
+ 4. Use of the Software for projects not related to GN or
18
+ GW systems is strictly prohibited.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
21
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
22
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR
23
+ A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT
24
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
25
+ CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
26
+ OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
27
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
28
+ DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,31 @@
1
+ """
2
+ Copyright (C) 2024 KeyisB. All rights reserved.
3
+
4
+ Permission is hereby granted, free of charge, to any person
5
+ obtaining a copy of this software and associated documentation
6
+ files (the "Software"), to use the Software exclusively for
7
+ projects related to the GN or GW systems, including personal,
8
+ educational, and commercial purposes, subject to the following
9
+ conditions:
10
+
11
+ 1. Copying, modification, merging, publishing, distribution,
12
+ sublicensing, and/or selling copies of the Software are
13
+ strictly prohibited.
14
+ 2. The licensee may use the Software only in its original,
15
+ unmodified form.
16
+ 3. All copies or substantial portions of the Software must
17
+ remain unaltered and include this copyright notice and these terms of use.
18
+ 4. Use of the Software for projects not related to GN or
19
+ GW systems is strictly prohibited.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
22
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
23
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR
24
+ A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT
25
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
26
+ CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
27
+ OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
28
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
29
+ DEALINGS IN THE SOFTWARE.
30
+ """
31
+
@@ -0,0 +1,192 @@
1
+
2
+ def guess_type(filename: str) -> str:
3
+ """
4
+ Возвращает актуальный MIME-тип по расширению файла.
5
+ Только современные и часто используемые типы.
6
+ """
7
+ ext = filename.lower().rsplit('.', 1)[-1] if '.' in filename else ''
8
+
9
+ mime_map = {
10
+ # 🔹 Текст и данные
11
+ "txt": "text/plain",
12
+ "html": "text/html",
13
+ "css": "text/css",
14
+ "csv": "text/csv",
15
+ "xml": "application/xml",
16
+ "json": "application/json",
17
+ "js": "application/javascript",
18
+
19
+ # 🔹 Изображения (актуальные для веба)
20
+ "jpg": "image/jpeg",
21
+ "jpeg": "image/jpeg",
22
+ "png": "image/png",
23
+ "gif": "image/gif",
24
+ "webp": "image/webp",
25
+ "svg": "image/svg+xml",
26
+ "avif": "image/avif",
27
+
28
+ # 🔹 Видео (современные форматы)
29
+ "mp4": "video/mp4",
30
+ "webm": "video/webm",
31
+
32
+ # 🔹 Аудио (современные форматы)
33
+ "mp3": "audio/mpeg",
34
+ "ogg": "audio/ogg",
35
+ "oga": "audio/ogg",
36
+ "m4a": "audio/mp4",
37
+ "flac": "audio/flac",
38
+
39
+ # 🔹 Архивы
40
+ "zip": "application/zip",
41
+ "gz": "application/gzip",
42
+ "tar": "application/x-tar",
43
+ "7z": "application/x-7z-compressed",
44
+ "rar": "application/vnd.rar",
45
+
46
+ # 🔹 Документы (актуальные офисные)
47
+ "pdf": "application/pdf",
48
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
49
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
50
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
51
+
52
+ # 🔹 Шрифты
53
+ "woff": "font/woff",
54
+ "woff2": "font/woff2",
55
+ "ttf": "font/ttf",
56
+ "otf": "font/otf",
57
+ }
58
+
59
+ return mime_map.get(ext, "application/octet-stream")
60
+
61
+
62
+ import re
63
+ from typing import List
64
+
65
+ # regex для !{var}, поддерживает вложенность через точку
66
+ TPL_VAR_RE = re.compile(r'(?<!\\)!\{([A-Za-z_][A-Za-z0-9_\.]*)\}')
67
+
68
+ # список mime, которые считаем текстовыми
69
+ TEXTUAL_MIME_PREFIXES = [
70
+ "text/", # text/html, text/css, text/plain
71
+ ]
72
+ TEXTUAL_MIME_EXACT = {
73
+ "application/javascript",
74
+ "application/json",
75
+ "application/xml",
76
+ "application/xhtml+xml"
77
+ }
78
+ TEXTUAL_MIME_SUFFIXES = (
79
+ "+xml", # например application/rss+xml
80
+ "+json", # application/ld+json
81
+ )
82
+
83
+ def extract_template_vars(filedata: bytes, mime: str) -> List[str]:
84
+ """
85
+ Ищет все !{var} в тексте, если MIME относится к текстовым.
86
+ """
87
+ mime = (mime or "").lower().strip()
88
+
89
+ # определяем, текстовый ли mime
90
+ is_textual = (
91
+ mime.startswith(tuple(TEXTUAL_MIME_PREFIXES))
92
+ or mime in TEXTUAL_MIME_EXACT
93
+ or mime.endswith(TEXTUAL_MIME_SUFFIXES)
94
+ or "javascript" in mime
95
+ or "json" in mime
96
+ or "xml" in mime
97
+ )
98
+
99
+ if not is_textual:
100
+ return []
101
+
102
+ try:
103
+ text = filedata.decode("utf-8", errors="ignore")
104
+ except Exception:
105
+ return []
106
+
107
+ return list(set(m.group(1) for m in TPL_VAR_RE.finditer(text)))
108
+
109
+
110
+ import re
111
+ import asyncio
112
+ from functools import lru_cache
113
+ from typing import Dict, Any
114
+
115
+ _SIGILS = (b'%', b'!', b'&')
116
+ _PATTERNS: dict[bytes, re.Pattern[bytes]] = {
117
+ s: re.compile(rb'(?<!\\)' + re.escape(s) + rb'\{([A-Za-z_][A-Za-z0-9_\.]*)\}')
118
+ for s in _SIGILS
119
+ }
120
+
121
+ @lru_cache(maxsize=4096)
122
+ def _split_path(path: str) -> tuple[str, ...]:
123
+ return tuple(path.split('.'))
124
+
125
+ def _resolve(path: str, ctx: Dict[str, Any]) -> Any:
126
+ cur: Any = ctx
127
+ for k in _split_path(path):
128
+ if not isinstance(cur, dict) or k not in cur:
129
+ raise KeyError(path)
130
+ cur = cur[k]
131
+ return cur
132
+
133
+ def make_renderer(sigil: bytes = b'%'):
134
+ """
135
+ Возвращает рендер для одного сигила. Работает с bytes.
136
+ """
137
+ if sigil not in _PATTERNS:
138
+ raise ValueError(f"unsupported sigil: {sigil!r}")
139
+ rx = _PATTERNS[sigil]
140
+ esc_seq = b'\\' + sigil + b'{'
141
+ unesc_seq = sigil + b'{'
142
+
143
+ def render(data: bytes, ctx: Dict[str, Any], *, keep_unresolved: bool = False) -> bytes:
144
+ parts: list[bytes] = []
145
+ append = parts.append
146
+ last = 0
147
+ finditer = rx.finditer
148
+
149
+ for m in finditer(data):
150
+ start, end = m.span()
151
+ append(data[last:start])
152
+
153
+ key = m.group(1).decode('utf-8')
154
+ try:
155
+ val = _resolve(key, ctx)
156
+ append(b"" if val is None else str(val).encode('utf-8'))
157
+ except KeyError:
158
+ if keep_unresolved:
159
+ append(data[start:end])
160
+ else:
161
+ raise
162
+ last = end
163
+
164
+ if last < len(data):
165
+ append(data[last:])
166
+
167
+ out = b''.join(parts)
168
+ if esc_seq in out:
169
+ out = out.replace(esc_seq, unesc_seq)
170
+ return out
171
+
172
+ return render
173
+
174
+ # предсобранные
175
+ render_pct = make_renderer(b'%')
176
+ render_bang = make_renderer(b'!')
177
+ render_amp = make_renderer(b'&')
178
+
179
+ # асинхронные обёртки
180
+ _BIG = 256 * 1024
181
+
182
+ async def render_pct_async(data: bytes, ctx: Dict[str, Any], *, keep_unresolved: bool = False) -> bytes:
183
+ return render_pct(data, ctx, keep_unresolved=keep_unresolved) if len(data) < _BIG \
184
+ else await asyncio.to_thread(render_pct, data, ctx, keep_unresolved=keep_unresolved)
185
+
186
+ async def render_bang_async(data: bytes, ctx: Dict[str, Any], *, keep_unresolved: bool = False) -> bytes:
187
+ return render_bang(data, ctx, keep_unresolved=keep_unresolved) if len(data) < _BIG \
188
+ else await asyncio.to_thread(render_bang, data, ctx, keep_unresolved=keep_unresolved)
189
+
190
+ async def render_amp_async(data: bytes, ctx: Dict[str, Any], *, keep_unresolved: bool = False) -> bytes:
191
+ return render_amp(data, ctx, keep_unresolved=keep_unresolved) if len(data) < _BIG \
192
+ else await asyncio.to_thread(render_amp, data, ctx, keep_unresolved=keep_unresolved)
@@ -0,0 +1,294 @@
1
+ import re
2
+ from typing import Iterable, Optional, List, NamedTuple
3
+ from functools import lru_cache
4
+
5
+ _VERSION_RE = re.compile(r"^\d+(?:\.\d+)*(?:-\d+(?:\.\d+)*)?$").match
6
+ _is_ver = _VERSION_RE
7
+
8
+
9
+ def _to_list(v: str) -> List[int]:
10
+ return [int(x) for x in v.split(".")] if v else []
11
+
12
+
13
+ def _cmp(a: List[int], b: List[int]) -> int:
14
+ n = max(len(a), len(b))
15
+ a += [0] * (n - len(a))
16
+ b += [0] * (n - len(b))
17
+ return (a > b) - (a < b)
18
+
19
+
20
+ class _VersionRange:
21
+ """Одиночная версия, диапазон a‑b, 'last' или wildcard (None)."""
22
+
23
+ __slots__ = ("raw", "kind", "lo", "hi", "single")
24
+
25
+ def __init__(self, raw: Optional[str]):
26
+ self.raw = raw # None == wildcard
27
+ if raw is None:
28
+ self.kind = "wild"
29
+ return
30
+ if raw.lower() == "last":
31
+ self.kind = "single_last"
32
+ return
33
+ if "-" in raw:
34
+ self.kind = "range"
35
+ lo, hi = raw.split("-", 1)
36
+ self.lo = _to_list(lo)
37
+ self.hi = _to_list(hi)
38
+ else:
39
+ self.kind = "single"
40
+ self.single = _to_list(raw)
41
+
42
+ def contains(self, ver: Optional[str]) -> bool: # noqa: C901
43
+ if self.kind == "wild":
44
+ return True
45
+ ver = ver or "last"
46
+ if self.kind == "single_last":
47
+ return ver.lower() == "last"
48
+ if ver.lower() == "last":
49
+ return False
50
+ v = _to_list(ver)
51
+ if self.kind == "single":
52
+ return _cmp(self.single[:], v) == 0
53
+ return _cmp(self.lo[:], v) <= 0 <= _cmp(v, self.hi[:])
54
+
55
+ # for debugging / logs
56
+ def __str__(self) -> str:
57
+ return self.raw or "last"
58
+
59
+
60
+ class _Pat(NamedTuple):
61
+ gn_ver: _VersionRange
62
+ p1_name: Optional[str]
63
+ p1_ver: _VersionRange
64
+ p1_need_last: bool
65
+ p2_name: Optional[str]
66
+ p2_ver: _VersionRange
67
+ p2_need_last: bool
68
+
69
+
70
+ @lru_cache(maxsize=2048)
71
+ def _compile_full_pattern(pat: str) -> _Pat:
72
+ t = pat.split(":")
73
+ gn_ver = _VersionRange(None)
74
+ if t and t[0].lower() == "gn":
75
+ t.pop(0)
76
+ gn_ver = _VersionRange(t.pop(0)) if t and (_is_ver(t[0]) or t[0].lower() == "last") else _VersionRange(None)
77
+
78
+ p2_name = p2_ver = p1_name = p1_ver = None
79
+ p2_need_last = p1_need_last = False
80
+
81
+ if t:
82
+ if _is_ver(t[-1]) or t[-1].lower() == "last":
83
+ p2_ver = _VersionRange(t.pop())
84
+ else:
85
+ p2_need_last = True
86
+ p2_name = t.pop() if t else None
87
+
88
+ if t:
89
+ if _is_ver(t[-1]) or t[-1].lower() == "last":
90
+ p1_ver = _VersionRange(t.pop())
91
+ else:
92
+ p1_need_last = True
93
+ p1_name = t.pop() if t else None
94
+
95
+ if t:
96
+ raise ValueError(f"bad pattern {pat!r}")
97
+
98
+ return _Pat(
99
+ gn_ver=gn_ver,
100
+ p1_name=None if p1_name is None else p1_name.lower(),
101
+ p1_ver=p1_ver or _VersionRange(None),
102
+ p1_need_last=p1_need_last,
103
+ p2_name=None if p2_name is None else p2_name.lower(),
104
+ p2_ver=p2_ver or _VersionRange(None),
105
+ p2_need_last=p2_need_last,
106
+ )
107
+
108
+
109
+ class _LeafPat(NamedTuple):
110
+ name: Optional[str]
111
+ ver: _VersionRange
112
+ need_last: bool
113
+
114
+
115
+ @lru_cache(maxsize=4096)
116
+ def _compile_leaf_pattern(pat: str) -> _LeafPat:
117
+ """
118
+ pattern ::= NAME
119
+ | NAME ':' VERSION
120
+ | VERSION (# имя опущено)
121
+ """
122
+ if ":" not in pat:
123
+ if _is_ver(pat) or pat.lower() == "last":
124
+ return _LeafPat(name=None, ver=_VersionRange(pat), need_last=False)
125
+ return _LeafPat(name=pat.lower(), ver=_VersionRange(None), need_last=True)
126
+
127
+ name, ver = pat.split(":", 1)
128
+ name = name.lower() or None
129
+ need_last = False
130
+ if not ver:
131
+ need_last = True
132
+ ver_range = _VersionRange(None)
133
+ else:
134
+ ver_range = _VersionRange(ver)
135
+ return _LeafPat(name=name, ver=ver_range, need_last=need_last)
136
+
137
+
138
+ class GNProtocol:
139
+ """
140
+ Строка формата gn[:gnVer]:transport[:ver1]:route[:ver2]
141
+ """
142
+
143
+ __slots__ = (
144
+ "raw",
145
+ "gn_ver_raw",
146
+ "gn_ver",
147
+ "trnsp_name",
148
+ "trnsp_ver_raw",
149
+ "trnsp_ver",
150
+ "route_name",
151
+ "route_ver_raw",
152
+ "route_ver",
153
+ "_gn_leaf",
154
+ "_trnsp_leaf",
155
+ "_route_leaf",
156
+ )
157
+
158
+ # ---------------------------------------------------------------- init ---
159
+ def __init__(self, raw: str):
160
+ self.raw = raw
161
+ self._parse()
162
+ self._gn_leaf = self._LeafProto("gn", self.gn_ver_raw)
163
+ self._trnsp_leaf = self._LeafProto(self.trnsp_name, self.trnsp_ver_raw)
164
+ self._route_leaf = self._LeafProto(self.route_name, self.route_ver_raw)
165
+
166
+ # ---------------------------------------------------------------- parse --
167
+ @staticmethod
168
+ def _take_ver(tokens: List[str]) -> Optional[str]:
169
+ return tokens.pop(0) if tokens and (_is_ver(tokens[0]) or tokens[0].lower() == "last") else None
170
+
171
+ def _parse(self) -> None:
172
+ t = self.raw.split(":")
173
+ if not t or t[0].lower() != "gn":
174
+ raise ValueError("must start with 'gn'")
175
+ t.pop(0)
176
+
177
+ self.gn_ver_raw = self._take_ver(t)
178
+ self.gn_ver = _VersionRange(self.gn_ver_raw)
179
+
180
+ if not t:
181
+ raise ValueError("missing transport proto")
182
+ self.trnsp_name = t.pop(0).lower()
183
+ self.trnsp_ver_raw = self._take_ver(t)
184
+ self.trnsp_ver = _VersionRange(self.trnsp_ver_raw)
185
+
186
+ if not t:
187
+ raise ValueError("missing route proto")
188
+ self.route_name = t.pop(0).lower()
189
+ self.route_ver_raw = self._take_ver(t)
190
+ self.route_ver = _VersionRange(self.route_ver_raw)
191
+
192
+ if t:
193
+ raise ValueError(f"extra tokens: {t!r}")
194
+
195
+ def structure(self) -> dict:
196
+ return {
197
+ "gn": {"version": str(self.gn_ver)},
198
+ self.trnsp_name: {"version": str(self.trnsp_ver)},
199
+ self.route_name: {"version": str(self.route_ver)},
200
+ }
201
+
202
+ def matches_any(self, patterns: Iterable[str]) -> bool:
203
+ gv = self.gn_ver_raw
204
+ c_name, c_ver = self.trnsp_name, self.trnsp_ver_raw
205
+ r_name, r_ver = self.route_name, self.route_ver_raw
206
+
207
+ for pat in patterns:
208
+ gn_v, p1n, p1v, p1need, p2n, p2v, p2need = _compile_full_pattern(pat)
209
+
210
+ # gn
211
+ if not gn_v.contains(gv):
212
+ continue
213
+
214
+ # transport
215
+ if p1n and p1n != c_name:
216
+ continue
217
+ if p1need:
218
+ if c_ver is not None:
219
+ continue
220
+ elif not p1v.contains(c_ver):
221
+ continue
222
+
223
+ # route
224
+ if p2n and p2n != r_name:
225
+ continue
226
+ if p2need:
227
+ if r_ver is not None:
228
+ continue
229
+ elif not p2v.contains(r_ver):
230
+ continue
231
+
232
+ return True
233
+ return False
234
+
235
+ class _LeafProto:
236
+ __slots__ = ("_name", "_ver_raw")
237
+
238
+ def __init__(self, name: str, ver_raw: Optional[str]):
239
+ self._name = name
240
+ self._ver_raw = ver_raw # None == 'last'
241
+
242
+ def protocol(self) -> str:
243
+ return self._name
244
+
245
+ def version(self) -> str:
246
+ return self._ver_raw or "last"
247
+
248
+ def matches_any(self, *patterns) -> bool:
249
+ if len(patterns) == 1 and not isinstance(patterns[0], str):
250
+ patterns_iter = patterns[0]
251
+ else:
252
+ patterns_iter = patterns
253
+
254
+ nm = self._name
255
+ vr = self._ver_raw
256
+
257
+ for p in patterns_iter:
258
+ pat = _compile_leaf_pattern(p)
259
+
260
+ if pat.name is not None and pat.name != nm:
261
+ continue
262
+
263
+ if pat.need_last:
264
+ if vr is not None:
265
+ continue
266
+ return True
267
+
268
+ if pat.ver.contains(vr):
269
+ return True
270
+
271
+ return False
272
+
273
+ def __repr__(self) -> str:
274
+ return f"<Proto {self._name}:{self.version()}>"
275
+
276
+ @property
277
+ def gn(self) -> _LeafProto:
278
+ """Top‑level 'gn' protocol."""
279
+ return self._gn_leaf
280
+
281
+ @property
282
+ def transport(self) -> _LeafProto:
283
+ return self._trnsp_leaf
284
+
285
+ @property
286
+ def route(self) -> _LeafProto:
287
+ return self._route_leaf
288
+
289
+ def __repr__(self) -> str:
290
+ return (
291
+ f"<GNProtocol gn:{self.gn_ver_raw or 'last'} "
292
+ f"{self.trnsp_name}:{self.trnsp_ver_raw or 'last'} "
293
+ f"{self.route_name}:{self.route_ver_raw or 'last'}>"
294
+ )