yace-codec 0.1.0__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.
yace_codec/codec.py ADDED
@@ -0,0 +1,498 @@
1
+ """The YACE encoding pipeline.
2
+
3
+ Encoding ``text`` with family ``name``, code length ``L`` and ``I`` iterations:
4
+
5
+ 1. Layer ``k`` (``k = 1..I``) replaces every character with its code from table
6
+ ``name-L:k``. Each space gets one of several space codes at random. Codes
7
+ consist of letters and digits, so every layer's output is valid input for
8
+ the next layer.
9
+ 2. ``ceil(chunks / 4)`` bin (noise) codes from the last table are inserted at
10
+ random chunk positions, where a chunk is one ``L``-character code.
11
+ 3. Chunk ``x - 1`` is swapped with chunk ``n - x`` for ``x = L, 2L, 3L, ...``.
12
+ No two swaps overlap, so applying the step twice restores the order.
13
+ 4. The token is the header ``name-L:I``, a newline, and the chunks.
14
+
15
+ Decoding undoes the steps in reverse order with the same tables. This is the
16
+ format of the original YACE scripts, so their tokens and ``encodings.json``
17
+ files keep working.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import binascii
24
+ import codecs
25
+ import os
26
+ import secrets
27
+ from random import Random
28
+ from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple, Union
29
+
30
+ from .errors import DecodingError, EncodingError, UnsupportedCharacterError
31
+ from .keystore import KeyStore
32
+ from .tables import (
33
+ CODE_ALPHABET,
34
+ MAX_LENGTH,
35
+ MIN_LENGTH,
36
+ SPACE_KEY,
37
+ SUPPORTED_CHARACTERS,
38
+ Table,
39
+ parse_table_name,
40
+ table_name,
41
+ validate_family,
42
+ )
43
+
44
+ __all__ = [
45
+ "DEFAULT_FAMILY",
46
+ "DEFAULT_ITERATIONS",
47
+ "DEFAULT_LENGTH",
48
+ "MAX_ENCODED_CHARS",
49
+ "MAX_ITERATIONS",
50
+ "Codec",
51
+ "Header",
52
+ "decode",
53
+ "decode_bytes",
54
+ "encode",
55
+ "encode_bytes",
56
+ "parse_header",
57
+ ]
58
+
59
+ #: Family used when no name is given.
60
+ DEFAULT_FAMILY = "default"
61
+ #: Default code length (``hash_length`` in the original scripts).
62
+ DEFAULT_LENGTH = 2
63
+ #: Default number of encoding layers (``final_hash_iteration`` in the original scripts).
64
+ DEFAULT_ITERATIONS = 3
65
+ #: Upper limit for ``iterations``.
66
+ MAX_ITERATIONS = 32
67
+ #: Encoding is refused when ``len(text) * length ** iterations`` exceeds this.
68
+ #: Encoding and decoding need roughly 25-40 bytes of memory per token character.
69
+ MAX_ENCODED_CHARS = 2**25
70
+
71
+ _CODE_CHARS = frozenset(CODE_ALPHABET)
72
+ _SYSTEM_RANDOM = secrets.SystemRandom()
73
+ # UTF-32 first: its little-endian BOM starts with the UTF-16 one.
74
+ _BOMS = (
75
+ (codecs.BOM_UTF32_LE, "utf-32"),
76
+ (codecs.BOM_UTF32_BE, "utf-32"),
77
+ (codecs.BOM_UTF8, "utf-8-sig"),
78
+ (codecs.BOM_UTF16_LE, "utf-16"),
79
+ (codecs.BOM_UTF16_BE, "utf-16"),
80
+ )
81
+
82
+ KeyStoreLike = Union[KeyStore, str, "os.PathLike[str]", None]
83
+ TokenLike = Union[str, bytes, bytearray]
84
+
85
+
86
+ class Header(NamedTuple):
87
+ """The first line of a token: ``<family>-<length>:<iterations>``."""
88
+
89
+ family: str
90
+ length: int
91
+ iterations: int
92
+
93
+ @property
94
+ def table_names(self) -> Tuple[str, ...]:
95
+ """Names of the tables needed to decode the token, innermost first."""
96
+ return tuple(table_name(self.family, self.length, k) for k in range(1, self.iterations + 1))
97
+
98
+ def __str__(self) -> str:
99
+ return table_name(self.family, self.length, self.iterations)
100
+
101
+
102
+ class Codec:
103
+ """Encode and decode with the tables of one key store.
104
+
105
+ ``keystore`` may be a :class:`~yace_codec.keystore.KeyStore`, a path to a
106
+ key store file, or ``None`` for the default key store
107
+ (:func:`~yace_codec.keystore.default_keystore_path`).
108
+
109
+ ``rng`` replaces the operating system's random source for new tables and
110
+ for the per-token randomness. Pass a seeded :class:`random.Random` only to
111
+ get reproducible output in tests.
112
+ """
113
+
114
+ def __init__(self, keystore: KeyStoreLike = None, *, rng: Optional[Random] = None) -> None:
115
+ self.keystore = _as_keystore(keystore)
116
+ self._rng = rng
117
+
118
+ def __repr__(self) -> str:
119
+ return f"Codec({self.keystore!r})"
120
+
121
+ def encode(
122
+ self,
123
+ text: str,
124
+ name: str = DEFAULT_FAMILY,
125
+ *,
126
+ length: int = DEFAULT_LENGTH,
127
+ iterations: int = DEFAULT_ITERATIONS,
128
+ ) -> str:
129
+ """Encode ``text`` with the table family ``name`` and return the token.
130
+
131
+ Missing tables ``name-length:1`` to ``name-length:iterations`` are
132
+ generated and saved to the key store; existing ones are reused.
133
+
134
+ Raises :class:`~yace_codec.errors.UnsupportedCharacterError` if ``text``
135
+ contains characters the table cannot encode (new tables cover
136
+ :data:`string.printable`; use :meth:`encode_bytes` for anything else),
137
+ :class:`~yace_codec.errors.InvalidNameError` for an invalid ``name``, and
138
+ :class:`~yace_codec.errors.EncodingError` for invalid settings.
139
+ """
140
+ if not isinstance(text, str):
141
+ raise TypeError(
142
+ f"text must be str, not {type(text).__name__}; use encode_bytes() for bytes"
143
+ )
144
+ validate_family(name)
145
+ _check_settings(length, iterations)
146
+ if len(text) * length**iterations > MAX_ENCODED_CHARS:
147
+ raise EncodingError(
148
+ f"the token would exceed {MAX_ENCODED_CHARS} data characters "
149
+ f"({len(text)} characters x {length}**{iterations}); "
150
+ "lower the length or the number of iterations"
151
+ )
152
+ used = frozenset(text)
153
+ first = table_name(name, length, 1)
154
+ existing = self.keystore.find(first)
155
+ # Check before generating tables, so invalid input leaves the store unchanged.
156
+ _check_characters(used, existing.characters if existing else SUPPORTED_CHARACTERS, first)
157
+ tables = self.keystore.ensure_tables(name, length, iterations, rng=self._rng)
158
+ _check_characters(used, tables[0].characters, first)
159
+ last = tables[-1]
160
+ if last.bin_codes and not last.usable_bin_codes:
161
+ # Only possible with hand-made or legacy tables.
162
+ raise EncodingError(
163
+ f"every bin code of table {last.name!r} is also a character or space code, "
164
+ "so it cannot produce an unambiguous token"
165
+ )
166
+
167
+ rng = self._rng or _SYSTEM_RANDOM
168
+ try:
169
+ data = text
170
+ for table in tables:
171
+ data = _substitute(data, table, rng)
172
+ noisy = _insert_bins(data.encode("ascii"), length, last.usable_bin_codes, rng)
173
+ body = _swap_chunks(noisy, length).decode("ascii")
174
+ except MemoryError:
175
+ raise EncodingError(
176
+ "not enough memory for this token; lower the length or the number of iterations"
177
+ ) from None
178
+ return f"{table_name(name, length, iterations)}\n{body}"
179
+
180
+ def decode(self, token: TokenLike) -> str:
181
+ """Decode a token produced by :meth:`encode` (or by the original scripts).
182
+
183
+ Whitespace inside the token is ignored, so wrapped or CRLF tokens work.
184
+
185
+ Raises :class:`~yace_codec.errors.TableNotFoundError` if the key store
186
+ lacks a required table and :class:`~yace_codec.errors.DecodingError` if
187
+ the token is malformed or was encoded with different tables.
188
+ """
189
+ header, body = _split_token(token)
190
+ tables = [self.keystore.get(name) for name in header.table_names]
191
+ length = header.length
192
+ data = body
193
+ for layer in range(header.iterations, 0, -1):
194
+ if len(data) % length:
195
+ raise DecodingError(
196
+ f"layer {layer} has {len(data)} characters, which is not a multiple of "
197
+ f"the code length {length}; the token is truncated or corrupted"
198
+ )
199
+ outermost = layer == header.iterations
200
+ if outermost:
201
+ data = _swap_chunks(data.encode("ascii"), length).decode("ascii")
202
+ table = tables[layer - 1]
203
+ mapping = table.reverse_map(plaintext=layer == 1, outermost=outermost)
204
+ chunks = [data[i : i + length] for i in range(0, len(data), length)]
205
+ data = _restore(chunks, mapping, table, outermost=outermost)
206
+ return data
207
+
208
+ def encode_bytes(
209
+ self,
210
+ data: Union[bytes, bytearray, memoryview],
211
+ name: str = DEFAULT_FAMILY,
212
+ *,
213
+ length: int = DEFAULT_LENGTH,
214
+ iterations: int = DEFAULT_ITERATIONS,
215
+ ) -> str:
216
+ """Encode arbitrary bytes (base64 first, then :meth:`encode`).
217
+
218
+ Use this for binary files and for text outside :data:`string.printable`,
219
+ e.g. ``codec.encode_bytes("café".encode("utf-8"))``. Decode the token
220
+ with :meth:`decode_bytes`.
221
+ """
222
+ if not isinstance(data, (bytes, bytearray, memoryview)):
223
+ raise TypeError(f"data must be bytes-like, not {type(data).__name__}")
224
+ text = base64.b64encode(bytes(data)).decode("ascii")
225
+ return self.encode(text, name, length=length, iterations=iterations)
226
+
227
+ def decode_bytes(self, token: TokenLike) -> bytes:
228
+ """Decode a token produced by :meth:`encode_bytes`."""
229
+ text = self.decode(token)
230
+ try:
231
+ return base64.b64decode(text.encode("ascii"), validate=True)
232
+ except (binascii.Error, UnicodeEncodeError) as exc:
233
+ raise DecodingError(
234
+ "the decoded text is not base64; was the token made with encode_bytes()?"
235
+ ) from exc
236
+
237
+
238
+ def encode(
239
+ text: str,
240
+ name: str = DEFAULT_FAMILY,
241
+ *,
242
+ length: int = DEFAULT_LENGTH,
243
+ iterations: int = DEFAULT_ITERATIONS,
244
+ keystore: KeyStoreLike = None,
245
+ ) -> str:
246
+ """Encode ``text``; shortcut for ``Codec(keystore).encode(...)``."""
247
+ return Codec(keystore).encode(text, name, length=length, iterations=iterations)
248
+
249
+
250
+ def decode(token: TokenLike, *, keystore: KeyStoreLike = None) -> str:
251
+ """Decode ``token``; shortcut for ``Codec(keystore).decode(token)``."""
252
+ return Codec(keystore).decode(token)
253
+
254
+
255
+ def encode_bytes(
256
+ data: Union[bytes, bytearray, memoryview],
257
+ name: str = DEFAULT_FAMILY,
258
+ *,
259
+ length: int = DEFAULT_LENGTH,
260
+ iterations: int = DEFAULT_ITERATIONS,
261
+ keystore: KeyStoreLike = None,
262
+ ) -> str:
263
+ """Encode bytes; shortcut for ``Codec(keystore).encode_bytes(...)``."""
264
+ return Codec(keystore).encode_bytes(data, name, length=length, iterations=iterations)
265
+
266
+
267
+ def decode_bytes(token: TokenLike, *, keystore: KeyStoreLike = None) -> bytes:
268
+ """Decode a bytes token; shortcut for ``Codec(keystore).decode_bytes(token)``."""
269
+ return Codec(keystore).decode_bytes(token)
270
+
271
+
272
+ def parse_header(token: TokenLike) -> Header:
273
+ """Return the :class:`Header` of ``token`` without decoding it.
274
+
275
+ ``parse_header(token).table_names`` lists the tables a key store needs to
276
+ decode the token.
277
+ """
278
+ return _split_token(token)[0]
279
+
280
+
281
+ def _as_keystore(keystore: KeyStoreLike) -> KeyStore:
282
+ if keystore is None:
283
+ return KeyStore.default()
284
+ if isinstance(keystore, KeyStore):
285
+ return keystore
286
+ if isinstance(keystore, (str, os.PathLike)):
287
+ return KeyStore(keystore)
288
+ raise TypeError(f"keystore must be a KeyStore, a path, or None, not {type(keystore).__name__}")
289
+
290
+
291
+ def _check_settings(length: object, iterations: object) -> None:
292
+ if not _is_int(length) or not MIN_LENGTH <= length <= MAX_LENGTH: # type: ignore[operator]
293
+ raise EncodingError(
294
+ f"length must be an integer from {MIN_LENGTH} to {MAX_LENGTH}, got {length!r}"
295
+ )
296
+ if not _is_int(iterations) or not 1 <= iterations <= MAX_ITERATIONS: # type: ignore[operator]
297
+ raise EncodingError(
298
+ f"iterations must be an integer from 1 to {MAX_ITERATIONS}, got {iterations!r}"
299
+ )
300
+
301
+
302
+ def _is_int(value: object) -> bool:
303
+ return isinstance(value, int) and not isinstance(value, bool)
304
+
305
+
306
+ def _check_characters(used: Iterable[str], supported: Iterable[str], name: str) -> None:
307
+ unsupported = sorted(set(used).difference(supported))
308
+ if unsupported:
309
+ shown = ", ".join(repr(ch) for ch in unsupported[:10])
310
+ if len(unsupported) > 10:
311
+ shown += f" and {len(unsupported) - 10} more"
312
+ raise UnsupportedCharacterError(
313
+ f"table {name!r} cannot encode {shown}; use encode_bytes() for "
314
+ "arbitrary text or binary data",
315
+ tuple(unsupported),
316
+ name,
317
+ )
318
+
319
+
320
+ def _substitute(text: str, table: Table, rng: Random) -> str:
321
+ if SPACE_KEY not in text:
322
+ return table.translate(text)
323
+ parts = text.split(SPACE_KEY)
324
+ spaces = rng.choices(table.usable_space_codes, k=len(parts) - 1)
325
+ spaces.append("")
326
+ return "".join([piece for part, space in zip(parts, spaces) for piece in (table.translate(part), space)])
327
+
328
+
329
+ def _insert_bins(data: bytes, length: int, bins: Sequence[str], rng: Random) -> bytes:
330
+ """Insert ``ceil(chunks / 4)`` random bin codes at random chunk positions."""
331
+ chunks = len(data) // length
332
+ count = -(-chunks // 4)
333
+ if not count or not bins:
334
+ return data
335
+ slots = sorted(rng.sample(range(chunks + count), count))
336
+ codes = rng.choices([code.encode("ascii") for code in bins], k=count)
337
+ # The i-th bin (in slot order) lands after slots[i] - i data chunks.
338
+ cuts = [(slot - i) * length for i, slot in enumerate(slots)]
339
+ segments = [data[start:cut] for start, cut in zip([0, *cuts], cuts)]
340
+ pieces = [piece for pair in zip(segments, codes) for piece in pair]
341
+ pieces.append(data[cuts[-1] :])
342
+ return b"".join(pieces)
343
+
344
+
345
+ def _swap_chunks(data: bytes, length: int) -> bytes:
346
+ """Swap chunk ``x - 1`` with chunk ``n - x`` for ``x = length, 2*length, ... < n``.
347
+
348
+ This is the original "misordering" step. The swapped pairs never overlap,
349
+ so the function is its own inverse.
350
+
351
+ Chunks ``x - 1`` sit every ``length`` chunks from chunk ``length - 1``, and
352
+ their partners ``n - x`` sit every ``length`` chunks up to chunk ``n - length``.
353
+ Strided slices move byte ``offset`` of all those chunks at once; reversing
354
+ one side pairs the first chunk of one group with the last of the other.
355
+ """
356
+ n = len(data) // length
357
+ pairs = len(range(length, n, length))
358
+ if not pairs:
359
+ return bytes(data)
360
+ out = bytearray(data)
361
+ step = length * length
362
+ for offset in range(length):
363
+ first = (length - 1) * length + offset
364
+ last = (n - pairs * length) * length + offset
365
+ lower = slice(first, first + pairs * step, step)
366
+ upper = slice(last, last + pairs * step, step)
367
+ out[lower] = data[upper][::-1]
368
+ out[upper] = data[lower][::-1]
369
+ return bytes(out)
370
+
371
+
372
+ def _restore(
373
+ chunks: List[str], mapping: Dict[str, Optional[str]], table: Table, *, outermost: bool
374
+ ) -> str:
375
+ """Map one layer's chunks back to characters and drop the bins.
376
+
377
+ On the outermost layer, the number of bins must match the token length
378
+ (see :func:`_bins_inserted`). Otherwise chunks were lost or added.
379
+ """
380
+ if not outermost:
381
+ return "".join(_lookup(chunks, mapping, table))
382
+ if any(mapping.get(code) is not None for code in table.bin_codes):
383
+ decoded = _lookup(chunks, mapping, table)
384
+ _drop_shared_bins(decoded, chunks, mapping, table.bin_codes)
385
+ bins = decoded.count(None)
386
+ text = "".join([ch for ch in decoded if ch is not None])
387
+ else:
388
+ fast = {code: ("" if ch is None else ch) for code, ch in mapping.items()}
389
+ text = "".join(_lookup(chunks, fast, table))
390
+ bins = len(chunks) - len(text)
391
+ expected = _bins_inserted(len(chunks))
392
+ if table.bin_codes and bins != expected:
393
+ implied = "no valid count" if expected is None else str(expected)
394
+ raise DecodingError(
395
+ f"the token holds {bins} noise chunks, but its length implies {implied}; "
396
+ "it is truncated, extended, or corrupted"
397
+ )
398
+ return text
399
+
400
+
401
+ def _lookup(chunks: List[str], mapping: Dict[str, Any], table: Table) -> List[Any]:
402
+ try:
403
+ return list(map(mapping.__getitem__, chunks))
404
+ except KeyError:
405
+ index = next(i for i, chunk in enumerate(chunks) if chunk not in mapping)
406
+ raise DecodingError(
407
+ f"{chunks[index]!r} at offset {index * table.length} is not a valid code of "
408
+ f"table {table.name!r}; the token is corrupted or was encoded with different tables"
409
+ ) from None
410
+
411
+
412
+ def _bins_inserted(chunks: int) -> Optional[int]:
413
+ """Return how many bins a token of ``chunks`` chunks holds, or ``None``.
414
+
415
+ The encoder adds ``ceil(m / 4)`` bins to ``m`` data chunks. That total grows
416
+ strictly with ``m``, so the chunk count determines the bin count.
417
+ """
418
+ guess = 4 * chunks // 5
419
+ for m in (guess - 1, guess, guess + 1):
420
+ if m >= 0 and m - (-m // 4) == chunks:
421
+ return chunks - m
422
+ return None
423
+
424
+
425
+ def _drop_shared_bins(
426
+ decoded: List[Optional[str]],
427
+ chunks: List[str],
428
+ mapping: Dict[str, Optional[str]],
429
+ bin_codes: Sequence[str],
430
+ ) -> None:
431
+ """Resolve bins whose code a legacy table also uses for a character or space.
432
+
433
+ Such chunks first decode as the character or space. The token length tells
434
+ how many bins the encoder inserted (see :func:`_bins_inserted`), so if fewer
435
+ chunks decoded as bins, some shared-code chunks are bins. Like the original
436
+ decoder, drop the first occurrence of each shared code when that gives the
437
+ right count; otherwise drop the earliest shared-code chunks. Tables
438
+ generated by this package never share codes, so this is a no-op for them.
439
+ """
440
+ shared = {code for code in bin_codes if mapping.get(code) is not None}
441
+ expected = _bins_inserted(len(decoded)) if shared else None
442
+ if expected is None:
443
+ return
444
+ missing = expected - decoded.count(None)
445
+ if missing <= 0:
446
+ return
447
+ positions = [i for i, chunk in enumerate(chunks) if chunk in shared]
448
+ first_of_each = {chunks[i]: i for i in reversed(positions)}
449
+ if len(first_of_each) == missing:
450
+ chosen = list(first_of_each.values())
451
+ elif missing <= len(positions):
452
+ chosen = positions[:missing]
453
+ else:
454
+ return
455
+ for i in chosen:
456
+ decoded[i] = None
457
+
458
+
459
+ def _split_token(token: TokenLike) -> Tuple[Header, str]:
460
+ if isinstance(token, (bytes, bytearray)):
461
+ raw = bytes(token)
462
+ # Windows tools often add a byte order mark (and PowerShell 5.1 writes UTF-16).
463
+ encoding = next((enc for bom, enc in _BOMS if raw.startswith(bom)), "utf-8")
464
+ try:
465
+ token = raw.decode(encoding)
466
+ except UnicodeDecodeError:
467
+ raise DecodingError(
468
+ "a token must be text: UTF-8, or UTF-16/UTF-32 with a byte order mark"
469
+ ) from None
470
+ elif not isinstance(token, str):
471
+ raise TypeError(f"token must be str or bytes, not {type(token).__name__}")
472
+ text = token[1:] if token.startswith("\ufeff") else token
473
+ stripped = text.strip()
474
+ if not stripped:
475
+ raise DecodingError("the token is empty")
476
+ # The header is the first line. The original scripts allowed spaces in family
477
+ # names, so try the whole line first, then "header body" on a single line.
478
+ first_line, _, body = stripped.partition("\n")
479
+ head = first_line.strip()
480
+ parsed = parse_table_name(head)
481
+ if parsed is None:
482
+ parts = stripped.split(None, 1)
483
+ head, body = parts[0], (parts[1] if len(parts) > 1 else "")
484
+ parsed = parse_table_name(head)
485
+ if parsed is None:
486
+ raise DecodingError(
487
+ f"invalid header {head[:40]!r}; expected '<name>-<length>:<iterations>'"
488
+ )
489
+ family, length, iterations = parsed
490
+ if not MIN_LENGTH <= length <= MAX_LENGTH or iterations < 1:
491
+ raise DecodingError(f"invalid header {head!r}: unsupported length or iteration count")
492
+ body = "".join(body.split())
493
+ if not _CODE_CHARS.issuperset(body):
494
+ bad = sorted(set(body) - _CODE_CHARS)[:5]
495
+ raise DecodingError(
496
+ f"the token contains characters the encoder never produces: {bad!r}"
497
+ )
498
+ return Header(family, length, iterations), body
yace_codec/errors.py ADDED
@@ -0,0 +1,65 @@
1
+ """Exceptions raised by yace_codec.
2
+
3
+ Every exception derives from :class:`YaceError`, so ``except YaceError`` catches
4
+ all of them. Errors about bad input also derive from :class:`ValueError`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Tuple
10
+
11
+ __all__ = [
12
+ "DecodingError",
13
+ "EncodingError",
14
+ "InvalidNameError",
15
+ "KeyStoreError",
16
+ "TableNotFoundError",
17
+ "UnsupportedCharacterError",
18
+ "YaceError",
19
+ ]
20
+
21
+
22
+ class YaceError(Exception):
23
+ """Base class for all yace_codec errors."""
24
+
25
+
26
+ class InvalidNameError(YaceError, ValueError):
27
+ """A table family name is not allowed."""
28
+
29
+
30
+ class EncodingError(YaceError, ValueError):
31
+ """The input cannot be encoded with the requested settings."""
32
+
33
+
34
+ class UnsupportedCharacterError(EncodingError):
35
+ """The text contains characters that the substitution table cannot encode.
36
+
37
+ ``characters`` holds the offending characters and ``table_name`` names the
38
+ table that was checked.
39
+ """
40
+
41
+ def __init__(
42
+ self, message: str, characters: Tuple[str, ...] = (), table_name: str = ""
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.characters = characters
46
+ self.table_name = table_name
47
+
48
+
49
+ class DecodingError(YaceError, ValueError):
50
+ """The token is malformed, corrupted, or does not match the key store's tables."""
51
+
52
+
53
+ class TableNotFoundError(YaceError, LookupError):
54
+ """A table needed for decoding or export is not in the key store.
55
+
56
+ ``table_name`` names the missing table (or family).
57
+ """
58
+
59
+ def __init__(self, message: str, table_name: str = "") -> None:
60
+ super().__init__(message)
61
+ self.table_name = table_name
62
+
63
+
64
+ class KeyStoreError(YaceError):
65
+ """The key store cannot be read, parsed, validated, or written."""