tamp 2.2.4__tar.gz → 2.3.0__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.
Files changed (29) hide show
  1. {tamp-2.2.4 → tamp-2.3.0}/PKG-INFO +9 -8
  2. {tamp-2.2.4 → tamp-2.3.0}/README.md +8 -7
  3. {tamp-2.2.4 → tamp-2.3.0}/build.py +5 -0
  4. {tamp-2.2.4 → tamp-2.3.0}/pyproject.toml +3 -3
  5. {tamp-2.2.4 → tamp-2.3.0}/tamp/__init__.py +1 -1
  6. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_compressor.pyx +16 -8
  7. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_decompressor.pyx +21 -4
  8. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/compressor.c +71 -8
  9. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/compressor_find_match_desktop.c +6 -2
  10. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/decompressor.c +33 -11
  11. {tamp-2.2.4 → tamp-2.3.0}/tamp/cli/build_dictionary.py +27 -2
  12. {tamp-2.2.4 → tamp-2.3.0}/tamp/compressor.py +20 -5
  13. {tamp-2.2.4 → tamp-2.3.0}/tamp/compressor_viper.py +12 -7
  14. {tamp-2.2.4 → tamp-2.3.0}/tamp/decompressor.py +28 -6
  15. {tamp-2.2.4 → tamp-2.3.0}/tamp/decompressor_viper.py +8 -0
  16. {tamp-2.2.4 → tamp-2.3.0}/LICENSE +0 -0
  17. {tamp-2.2.4 → tamp-2.3.0}/tamp/__init__.pyi +0 -0
  18. {tamp-2.2.4 → tamp-2.3.0}/tamp/__main__.py +0 -0
  19. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_build_dictionary.pyx +0 -0
  20. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_common.pxd +0 -0
  21. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_common.pyx +0 -0
  22. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/common.c +0 -0
  23. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/common.h +0 -0
  24. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/compressor.h +0 -0
  25. {tamp-2.2.4 → tamp-2.3.0}/tamp/_c_src/tamp/decompressor.h +0 -0
  26. {tamp-2.2.4 → tamp-2.3.0}/tamp/cli/__init__.py +0 -0
  27. {tamp-2.2.4 → tamp-2.3.0}/tamp/cli/main.py +0 -0
  28. {tamp-2.2.4 → tamp-2.3.0}/tamp/ctamp.pxd +0 -0
  29. {tamp-2.2.4 → tamp-2.3.0}/tamp/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tamp
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary:
5
5
  Home-page: https://github.com/BrianPugh/tamp
6
6
  License: Apache-2.0
@@ -343,13 +343,14 @@ low-memory compressor.
343
343
 
344
344
  To give an idea of Tamp's speed on an embedded device, the following table shows
345
345
  compression/decompression in **bytes/second of the first 100KB of enwik8 on a pi
346
- pico (rp2040)** at the default 125MHz clock rate. The C benchmark **does not**
346
+ pico (rp2040)** at the default 125MHz clock rate, with native-module
347
+ measurements taken on MicroPython v1.26.1 firmware. The C benchmark **does not**
347
348
  use a filesystem nor dynamic memory allocation, so it represents the maximum
348
349
  speed Tamp can achieve. In all tests, a 1KB window (10 bit) was used.
349
350
 
350
351
  | | Compression (bytes/s) | Decompression (bytes/s) |
351
352
  | -------------------------------- | --------------------- | ----------------------- |
352
- | Tamp (Micropython Native Module) | 31,328 | 990,099 |
353
+ | Tamp (Micropython Native Module) | 34,510 | 980,392 |
353
354
  | Tamp (C) | 36,127 | 1,400,600 |
354
355
  | Deflate (micropython builtin) | 6,885 | 294,985 |
355
356
 
@@ -365,11 +366,11 @@ Numbers reported in bytes. Tamp sizes were measured using `arm-none-eabi-gcc`
365
366
 
366
367
  | | Compressor | Decompressor | Compressor + Decompressor |
367
368
  | -------------------------------- | ---------- | ------------ | ------------------------- |
368
- | Tamp (MicroPython Native) | 4700 | 4347 | 8024 |
369
- | Tamp (C, no extended, no stream) | 1754 | 1656 | 3172 |
370
- | Tamp (C, no extended) | 2036 | 1894 | 3692 |
371
- | Tamp (C, extended, no stream) | 2838 | 2452 | 5052 |
372
- | Tamp (C, extended) | 3120 | 2690 | 5572 |
369
+ | Tamp (MicroPython Native) | 5469 | 4725 | 9125 |
370
+ | Tamp (C, no extended, no stream) | 1932 | 1634 | 3328 |
371
+ | Tamp (C, no extended) | 2214 | 1872 | 3848 |
372
+ | Tamp (C, extended, no stream) | 3120 | 2452 | 5334 |
373
+ | Tamp (C, extended) | 3402 | 2690 | 5854 |
373
374
  | Heatshrink (C) | 2956 | 3876 | 6832 |
374
375
  | uzlib (C) | 2355 | 3963 | 6318 |
375
376
 
@@ -322,13 +322,14 @@ low-memory compressor.
322
322
 
323
323
  To give an idea of Tamp's speed on an embedded device, the following table shows
324
324
  compression/decompression in **bytes/second of the first 100KB of enwik8 on a pi
325
- pico (rp2040)** at the default 125MHz clock rate. The C benchmark **does not**
325
+ pico (rp2040)** at the default 125MHz clock rate, with native-module
326
+ measurements taken on MicroPython v1.26.1 firmware. The C benchmark **does not**
326
327
  use a filesystem nor dynamic memory allocation, so it represents the maximum
327
328
  speed Tamp can achieve. In all tests, a 1KB window (10 bit) was used.
328
329
 
329
330
  | | Compression (bytes/s) | Decompression (bytes/s) |
330
331
  | -------------------------------- | --------------------- | ----------------------- |
331
- | Tamp (Micropython Native Module) | 31,328 | 990,099 |
332
+ | Tamp (Micropython Native Module) | 34,510 | 980,392 |
332
333
  | Tamp (C) | 36,127 | 1,400,600 |
333
334
  | Deflate (micropython builtin) | 6,885 | 294,985 |
334
335
 
@@ -344,11 +345,11 @@ Numbers reported in bytes. Tamp sizes were measured using `arm-none-eabi-gcc`
344
345
 
345
346
  | | Compressor | Decompressor | Compressor + Decompressor |
346
347
  | -------------------------------- | ---------- | ------------ | ------------------------- |
347
- | Tamp (MicroPython Native) | 4700 | 4347 | 8024 |
348
- | Tamp (C, no extended, no stream) | 1754 | 1656 | 3172 |
349
- | Tamp (C, no extended) | 2036 | 1894 | 3692 |
350
- | Tamp (C, extended, no stream) | 2838 | 2452 | 5052 |
351
- | Tamp (C, extended) | 3120 | 2690 | 5572 |
348
+ | Tamp (MicroPython Native) | 5469 | 4725 | 9125 |
349
+ | Tamp (C, no extended, no stream) | 1932 | 1634 | 3328 |
350
+ | Tamp (C, no extended) | 2214 | 1872 | 3848 |
351
+ | Tamp (C, extended, no stream) | 3120 | 2452 | 5334 |
352
+ | Tamp (C, extended) | 3402 | 2690 | 5854 |
352
353
  | Heatshrink (C) | 2956 | 3876 | 6832 |
353
354
  | uzlib (C) | 2355 | 3963 | 6318 |
354
355
 
@@ -20,6 +20,11 @@ if profile and sanitize:
20
20
  "Please choose either TAMP_PROFILE=1 or TAMP_SANITIZE=1, not both."
21
21
  )
22
22
 
23
+ # Sanitizer flags below are UNIX-only; previously this path crashed with a
24
+ # NameError (extra_link_args unbound) instead of a clear message.
25
+ if sanitize and os.name == "nt":
26
+ raise ValueError("TAMP_SANITIZE=1 is not supported on Windows.")
27
+
23
28
 
24
29
  def build_cython_extensions():
25
30
  import Cython.Compiler.Options
@@ -9,7 +9,7 @@ style = "semver"
9
9
 
10
10
  [tool.poetry]
11
11
  name = "tamp"
12
- version = "2.2.4" # Do not change, let poetry-dynamic-versioning handle it.
12
+ version = "2.3.0" # Do not change, let poetry-dynamic-versioning handle it.
13
13
  homepage = "https://github.com/BrianPugh/tamp"
14
14
  repository = "https://github.com/BrianPugh/tamp"
15
15
  license = "Apache-2.0"
@@ -56,12 +56,12 @@ pytest-cov = ">=3.0.0"
56
56
  pytest-mock = ">=3.7.0"
57
57
  black = ">=23.1.0"
58
58
  belay = ">=0.21.3"
59
- tqdm = ">=4.65.0"
59
+ tqdm = ">=4.68.2"
60
60
  matplotlib = ">=3.7.4"
61
61
  ipdb = ">=0.13.13"
62
62
  packaging = ">=26.1"
63
63
  mpremote = ">=1.20.0"
64
- cython = ">=3.2.5"
64
+ cython = ">=3.2.8"
65
65
  pytest-subtests = ">=0.10.0"
66
66
  numpy = ">=1.24"
67
67
  pyelftools = "^0.32"
@@ -1,4 +1,4 @@
1
- __version__ = "2.2.4"
1
+ __version__ = "2.3.0"
2
2
 
3
3
 
4
4
  class ExcessBitsError(Exception):
@@ -1,11 +1,9 @@
1
- import builtins
2
1
  cimport ctamp
3
2
  from libcpp cimport bool
4
3
  from cpython.exc cimport PyErr_CheckSignals
5
4
  from cpython.mem cimport PyMem_Malloc, PyMem_Free
6
5
  from libc.stddef cimport size_t
7
6
  from io import BytesIO
8
- from . import bit_size
9
7
  from ._c_common cimport CHUNK_SIZE
10
8
  from ._c_common import ERROR_LOOKUP
11
9
 
@@ -42,7 +40,9 @@ cdef class Compressor:
42
40
  ):
43
41
  cdef ctamp.TampConf conf
44
42
 
45
- if dictionary and bit_size(len(dictionary) - 1) != window:
43
+ if dictionary is not None and len(dictionary) != (1 << window):
44
+ # The exact size is required: a too-small buffer used as the window
45
+ # would let the C compressor write out of bounds.
46
46
  raise ValueError("Dictionary-window size mismatch.")
47
47
 
48
48
  if not hasattr(f, "write"): # It's probably a path-like object.
@@ -55,7 +55,7 @@ cdef class Compressor:
55
55
 
56
56
  conf.window = window
57
57
  conf.literal = literal
58
- conf.use_custom_dictionary = builtins.bool(dictionary)
58
+ conf.use_custom_dictionary = dictionary is not None
59
59
  # Set lazy_matching - this field is conditionally compiled based on TAMP_LAZY_MATCHING
60
60
  # The build system defines this macro, so the field should be available
61
61
  conf.lazy_matching = lazy_matching
@@ -63,7 +63,7 @@ cdef class Compressor:
63
63
  conf.dictionary_reset = dictionary_reset
64
64
  conf.append = append
65
65
 
66
- self._window_buffer = dictionary if dictionary else bytearray(1 << window)
66
+ self._window_buffer = dictionary if dictionary is not None else bytearray(1 << window)
67
67
  self._window_buffer_ptr = <unsigned char *>self._window_buffer
68
68
 
69
69
  self._dictionary_reset = dictionary_reset
@@ -75,15 +75,23 @@ cdef class Compressor:
75
75
  cdef:
76
76
  ctamp.tamp_res res
77
77
 
78
- bytearray output_buffer = bytearray(CHUNK_SIZE)
79
- unsigned char *output_buffer_ptr = output_buffer
80
- const unsigned char* data_ptr = &data[0]
78
+ bytearray output_buffer
79
+ unsigned char *output_buffer_ptr
80
+ const unsigned char* data_ptr
81
81
 
82
82
  size_t input_consumed_size = 0
83
83
  size_t input_remaining_size = data.shape[0]
84
84
  size_t output_buffer_written_size
85
85
  int written_to_disk_size = 0
86
86
 
87
+ if input_remaining_size == 0:
88
+ # &data[0] on an empty buffer raises IndexError.
89
+ return 0
90
+
91
+ output_buffer = bytearray(CHUNK_SIZE)
92
+ output_buffer_ptr = output_buffer
93
+ data_ptr = &data[0]
94
+
87
95
  output_buffer_mv = memoryview(output_buffer)
88
96
 
89
97
  while input_remaining_size:
@@ -3,7 +3,6 @@ from cpython.exc cimport PyErr_CheckSignals
3
3
  from cpython.mem cimport PyMem_Malloc, PyMem_Free
4
4
  from libc.stddef cimport size_t
5
5
  from io import BytesIO
6
- from . import bit_size
7
6
  from ._c_common cimport CHUNK_SIZE
8
7
  from ._c_common import ERROR_LOOKUP
9
8
 
@@ -64,7 +63,13 @@ cdef class Decompressor:
64
63
  if conf.use_custom_dictionary and dictionary is None:
65
64
  raise ValueError
66
65
 
67
- self._window_buffer = dictionary if dictionary else bytearray(1 << conf.window)
66
+ if dictionary is not None and len(dictionary) < (1 << conf.window):
67
+ # A too-small buffer used as the window would let the C
68
+ # decompressor write out of bounds. An oversized buffer is fine:
69
+ # C uses only the first 2**window bytes, in place.
70
+ raise ValueError("Dictionary-window size mismatch.")
71
+
72
+ self._window_buffer = dictionary if dictionary is not None else bytearray(1 << conf.window)
68
73
  self._window_buffer_ptr = <unsigned char *>self._window_buffer
69
74
 
70
75
  res = ctamp.tamp_decompressor_init(self._c_decompressor, &conf, self._window_buffer_ptr, conf.window)
@@ -105,7 +110,17 @@ cdef class Decompressor:
105
110
 
106
111
  if res == ctamp.TAMP_INPUT_EXHAUSTED:
107
112
  # Read in more data
108
- self.input_size = self.f.readinto(self.input_buffer)
113
+ if hasattr(self.f, "readinto"):
114
+ self.input_size = self.f.readinto(self.input_buffer)
115
+ else:
116
+ # Fall back for file-like objects that only implement read().
117
+ chunk = self.f.read(CHUNK_SIZE)
118
+ self.input_size = len(chunk)
119
+ if self.input_size > <size_t>CHUNK_SIZE:
120
+ # A larger chunk would resize (reallocate) input_buffer,
121
+ # leaving input_buffer_ptr dangling.
122
+ raise ValueError("read() returned more bytes than requested.")
123
+ self.input_buffer[:self.input_size] = chunk
109
124
  self.input_consumed = 0
110
125
  if self.input_size == 0:
111
126
  break;
@@ -137,7 +152,9 @@ cdef class Decompressor:
137
152
  # decompression happens inside of readinto
138
153
  read_size = self.readinto(buf)
139
154
  if size > 0:
140
- # We one-shot the decompression
155
+ # We one-shot the decompression.
156
+ # Trim unwritten zero-padding when the stream ended early.
157
+ del buf[read_size:]
141
158
  out.append(buf)
142
159
  break
143
160
  # We are decompressing in chunks
@@ -121,8 +121,29 @@ static TAMP_NOINLINE void find_best_match(TampCompressor* compressor, uint16_t*
121
121
  const uint8_t max_pattern_size = MIN(compressor->input_size, MAX_PATTERN_SIZE);
122
122
  const unsigned char* window = compressor->window;
123
123
 
124
+ #if defined(__GNUC__)
125
+ // Word-at-a-time skip over bytes that can't start a match: ~3 cycles/byte
126
+ // instead of ~9 on Cortex-M0+, and the scan dominates compression time.
127
+ const uint32_t first_broadcast = first_byte * 0x01010101u;
128
+ #endif
129
+
124
130
  for (uint32_t window_index = 0; window_index < window_size_minus_1; window_index++) {
125
131
  if (TAMP_LIKELY(window[window_index] != first_byte)) {
132
+ #if defined(__GNUC__)
133
+ uint32_t wi = window_index + 1;
134
+ if (TAMP_LIKELY(((uintptr_t)(window + wi) & 3) == 0)) {
135
+ // Aligned: skip 4 bytes per iteration until a word may hold
136
+ // first_byte (zero-byte detect on the XOR; false positives
137
+ // are fine, the byte loop re-checks).
138
+ typedef uint32_t __attribute__((may_alias)) tamp_word_alias;
139
+ while (wi + 4 <= window_size_minus_1) {
140
+ uint32_t word = *(const tamp_word_alias*)(window + wi) ^ first_broadcast;
141
+ if ((word - 0x01010101u) & ~word & 0x80808080u) break;
142
+ wi += 4;
143
+ }
144
+ }
145
+ window_index = wi - 1; // loop increment advances to wi
146
+ #endif
126
147
  continue;
127
148
  }
128
149
  if (TAMP_LIKELY(window[window_index + 1] != second_byte)) {
@@ -267,6 +288,12 @@ static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE uint8_t get_last_window_byte(TampCompres
267
288
  * @param[out] new_pos Position of found match (only valid if new_count > current_count)
268
289
  * @param[out] new_count Length of found match
269
290
  */
291
+ #if TAMP_ESP32
292
+ /* ESP32-optimized implementation in espidf/tamp/compressor_esp32.cpp (same contract,
293
+ * but may return a different equally-long match position on ties). */
294
+ extern void find_extended_match(TampCompressor* compressor, uint16_t current_pos, uint8_t current_count,
295
+ uint16_t* new_pos, uint8_t* new_count);
296
+ #else
270
297
  static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE void find_extended_match(TampCompressor* compressor, uint16_t current_pos,
271
298
  uint8_t current_count, uint16_t* new_pos,
272
299
  uint8_t* new_count) {
@@ -304,6 +331,7 @@ static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE void find_extended_match(TampCompressor*
304
331
  }
305
332
  }
306
333
  }
334
+ #endif // TAMP_ESP32
307
335
 
308
336
  /**
309
337
  * @brief Write RLE token to bit buffer and update window.
@@ -396,13 +424,20 @@ static TAMP_NOINLINE tamp_res write_extended_match_token(TampCompressor* compres
396
424
  * significant code size on register-constrained Cortex-M0+ where the compiler
397
425
  * otherwise spills heavily to stack (~48 bytes saved on armv6m).
398
426
  *
427
+ * @param[out] match_index Pre-computed match position; only set when a match was
428
+ * already found while deciding RLE-vs-pattern (see match_size).
429
+ * @param[out] match_size Set (>= 2) together with match_index when returning
430
+ * TAMP_POLL_CONTINUE with a match already found, so the caller can skip
431
+ * its own find_best_match. Left untouched otherwise.
432
+ *
399
433
  * @return TAMP_OK if fully handled (caller should return TAMP_OK),
400
434
  * TAMP_POLL_CONTINUE if caller should proceed to normal pattern matching,
401
435
  * other tamp_res on error.
402
436
  */
403
437
  static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE tamp_res poll_extended_handling(TampCompressor* compressor,
404
438
  unsigned char** output, size_t* output_size,
405
- size_t* output_written_size) {
439
+ size_t* output_written_size,
440
+ uint16_t* match_index, uint8_t* match_size) {
406
441
  // Handle extended match continuation
407
442
  if (compressor->extended_match_count) {
408
443
  const uint8_t max_ext_match = compressor->min_pattern_size + 11 + EXTENDED_MATCH_MAX_EXTRA;
@@ -460,6 +495,9 @@ static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE tamp_res poll_extended_handling(TampComp
460
495
 
461
496
  if (pattern_size > total_rle) {
462
497
  compressor->rle_count = 0;
498
+ // Hand the match to the caller so it doesn't repeat the search.
499
+ *match_index = pattern_index;
500
+ *match_size = pattern_size;
463
501
  return TAMP_POLL_CONTINUE; // Proceed to pattern matching
464
502
  }
465
503
  }
@@ -471,7 +509,18 @@ static TAMP_NOINLINE TAMP_OPTIMIZE_SIZE tamp_res poll_extended_handling(TampComp
471
509
  return TAMP_OK;
472
510
  }
473
511
 
474
- if (total_rle == 1) compressor->rle_count = 0;
512
+ if (TAMP_UNLIKELY(compressor->rle_count == 1)) {
513
+ // A lone run byte was consumed into rle_count by a previous poll and the
514
+ // run then ended; RLE tokens require count >= 2 and the byte is no longer
515
+ // in the input buffer, so re-emit it as a literal (mirrors the
516
+ // rle_count == 1 drain in tamp_compressor_flush).
517
+ const uint16_t window_mask = (1 << compressor->conf.window) - 1;
518
+ write_to_bit_buffer(compressor, IS_LITERAL_FLAG | last_byte, compressor->conf.literal + 1);
519
+ compressor->window[compressor->window_pos] = last_byte;
520
+ compressor->window_pos = (compressor->window_pos + 1) & window_mask;
521
+ compressor->rle_count = 0;
522
+ return TAMP_OK;
523
+ }
475
524
  return TAMP_POLL_CONTINUE; // Proceed to pattern matching
476
525
  }
477
526
  #endif // TAMP_EXTENDED_COMPRESS
@@ -509,9 +558,17 @@ TAMP_NOINLINE tamp_res tamp_compressor_poll(TampCompressor* compressor, unsigned
509
558
 
510
559
  #if TAMP_EXTENDED_COMPRESS
511
560
  if (TAMP_UNLIKELY(compressor->conf.extended)) {
512
- // Handle extended match continuation + RLE (outlined for code size)
513
- res = poll_extended_handling(compressor, &output, &output_size, output_written_size);
514
- if (res != TAMP_POLL_CONTINUE) return res;
561
+ // Handle extended match continuation + RLE (outlined for code size).
562
+ // May pre-compute match_index/match_size (match_size becomes non-zero).
563
+ res = poll_extended_handling(compressor, &output, &output_size, output_written_size, &match_index, &match_size);
564
+ if (res != TAMP_POLL_CONTINUE) {
565
+ #if TAMP_LAZY_MATCHING
566
+ // Extended handling consumed input and/or mutated the window, so any
567
+ // match cached by lazy matching no longer matches the current input.
568
+ compressor->cached_match_index = -1;
569
+ #endif
570
+ return res;
571
+ }
515
572
  // TAMP_POLL_CONTINUE: proceed to pattern matching below
516
573
  }
517
574
  #endif // TAMP_EXTENDED_COMPRESS
@@ -523,7 +580,7 @@ TAMP_NOINLINE tamp_res tamp_compressor_poll(TampCompressor* compressor, unsigned
523
580
  match_index = compressor->cached_match_index;
524
581
  match_size = compressor->cached_match_size;
525
582
  compressor->cached_match_index = -1; // Clear cache after using
526
- } else {
583
+ } else if (match_size == 0) { // No match pre-computed by extended handling
527
584
  find_best_match(compressor, &match_index, &match_size);
528
585
  }
529
586
 
@@ -557,11 +614,11 @@ TAMP_NOINLINE tamp_res tamp_compressor_poll(TampCompressor* compressor, unsigned
557
614
  } else {
558
615
  compressor->cached_match_index = -1; // Clear cache
559
616
  }
560
- } else {
617
+ } else if (match_size == 0) { // No match pre-computed by extended handling
561
618
  find_best_match(compressor, &match_index, &match_size);
562
619
  }
563
620
  #else
564
- find_best_match(compressor, &match_index, &match_size);
621
+ if (match_size == 0) find_best_match(compressor, &match_index, &match_size);
565
622
  #endif
566
623
 
567
624
  // Shared token/literal writing logic
@@ -825,6 +882,12 @@ TAMP_OPTIMIZE_SIZE tamp_res tamp_compressor_reset_dictionary(TampCompressor* com
825
882
 
826
883
  #if TAMP_STREAM
827
884
 
885
+ #if TAMP_EXTENDED_COMPRESS && (TAMP_STREAM_WORK_BUFFER_SIZE / 2) < EXTENDED_MATCH_MIN_OUTPUT_BYTES
886
+ // A smaller buffer deadlocks tamp_compress_stream: write_extended_match_token
887
+ // returns TAMP_OUTPUT_FULL forever because the token can never fit.
888
+ #error "TAMP_STREAM_WORK_BUFFER_SIZE must be at least 12 bytes when extended compression is enabled"
889
+ #endif
890
+
828
891
  TAMP_OPTIMIZE_SIZE tamp_res tamp_compress_stream(TampCompressor* compressor, tamp_read_t read_cb, void* read_handle,
829
892
  tamp_write_t write_cb, void* write_handle, size_t* input_consumed_size,
830
893
  size_t* output_written_size, tamp_callback_t callback,
@@ -88,8 +88,12 @@ static inline void find_best_match(TampCompressor *compressor, uint16_t *match_i
88
88
  const uint8_t max_pattern_size = MIN(compressor->input_size, MAX_PATTERN_SIZE);
89
89
  const unsigned char *window = compressor->window;
90
90
 
91
- // Pre-load input bytes into linear array to avoid repeated modular arithmetic
92
- uint8_t input_bytes[16];
91
+ // Pre-load input bytes into linear array to avoid repeated modular arithmetic.
92
+ // Zero-initialized: entries at max_pattern_size and beyond are read when
93
+ // assembling input_word_ext below. Their values never affect the result (the
94
+ // max_pattern_size >= 10 guard in EXTEND_MATCH sees to that), but reading
95
+ // indeterminate bytes is UB (MSan finding, -Werror=maybe-uninitialized).
96
+ uint8_t input_bytes[16] = {0};
93
97
  for (uint8_t i = 0; i < max_pattern_size && i < 16; i++) {
94
98
  input_bytes[i] = read_input(i);
95
99
  }
@@ -2,6 +2,29 @@
2
2
 
3
3
  #include "common.h"
4
4
 
5
+ /* Copy primitives for the hot paths. Platform-specific implementations live
6
+ * with their platform component; TAMP_ESP32 builds already require the espidf
7
+ * component sources on the include path (same contract as the extern
8
+ * find_best_match in compressor.c). On generic targets the macros expand to
9
+ * the plain byte loops / tamp_window_copy call they replaced (macros, not
10
+ * inline functions: Cortex-M0+ codegen is sensitive to function boundaries). */
11
+ #if TAMP_ESP32
12
+ #include "private/tamp_copy.h"
13
+ #else
14
+
15
+ /* Copy count bytes from src to the output cursor and advance it. */
16
+ #define TAMP_COPY_TO_OUTPUT(out, src, count) \
17
+ do { \
18
+ for (uint8_t _tamp_i = 0; _tamp_i < (count); _tamp_i++) { \
19
+ *(out)++ = (src)[_tamp_i]; \
20
+ } \
21
+ } while (0)
22
+
23
+ #define TAMP_WINDOW_COPY(window, window_pos, window_offset, match_size, window_mask) \
24
+ tamp_window_copy((window), (window_pos), (window_offset), (match_size), (window_mask))
25
+
26
+ #endif /* TAMP_ESP32 */
27
+
5
28
  #define MAX(x, y) (((x) > (y)) ? (x) : (y))
6
29
  #define MIN(x, y) (((x) < (y)) ? (x) : (y))
7
30
 
@@ -233,9 +256,7 @@ static tamp_res decode_extended_match(TampDecompressor* d, unsigned char** outpu
233
256
 
234
257
  /* Copy from window to output */
235
258
  uint16_t src_offset = window_offset + skip;
236
- for (uint8_t i = 0; i < to_write; i++) {
237
- *(*output)++ = d->window[src_offset + i];
238
- }
259
+ TAMP_COPY_TO_OUTPUT(*output, d->window + src_offset, to_write);
239
260
  *output_written_size += to_write;
240
261
 
241
262
  /* Update window only on complete decode.
@@ -244,7 +265,7 @@ static tamp_res decode_extended_match(TampDecompressor* d, unsigned char** outpu
244
265
  uint16_t wp = d->window_pos;
245
266
  uint16_t remaining = window_size - wp;
246
267
  uint8_t window_write = (match_size < remaining) ? match_size : remaining;
247
- tamp_window_copy(d->window, &wp, window_offset, window_write, window_size - 1);
268
+ TAMP_WINDOW_COPY(d->window, &wp, window_offset, window_write, window_size - 1);
248
269
  d->window_pos = wp;
249
270
  }
250
271
 
@@ -288,6 +309,12 @@ static TAMP_OPTIMIZE_SIZE tamp_res tamp_decompressor_populate_from_conf(TampDeco
288
309
  if (conf_window < 8 || conf_window > 15) return TAMP_INVALID_CONF;
289
310
  if (conf_literal < 5 || conf_literal > 8) return TAMP_INVALID_CONF;
290
311
  if (conf_window > decompressor->window_bits_max) return TAMP_INVALID_CONF;
312
+ #if !TAMP_EXTENDED_DECOMPRESS
313
+ // Reject before committing any state: marking the decompressor configured and
314
+ // then returning an error would let a retrying caller decode the extended
315
+ // stream as classic format.
316
+ if (conf_extended) return TAMP_INVALID_CONF; // Extended stream but extended support not compiled in
317
+ #endif
291
318
  if (!conf_use_custom_dictionary)
292
319
  tamp_initialize_dictionary(decompressor->window, (size_t)1 << conf_window, conf_extended ? conf_literal : 8);
293
320
 
@@ -297,9 +324,6 @@ static TAMP_OPTIMIZE_SIZE tamp_res tamp_decompressor_populate_from_conf(TampDeco
297
324
  decompressor->configured = true;
298
325
  decompressor->conf_extended = conf_extended;
299
326
  decompressor->conf_dictionary_reset = conf_dictionary_reset;
300
- #if !TAMP_EXTENDED_DECOMPRESS
301
- if (conf_extended) return TAMP_INVALID_CONF; // Extended stream but extended support not compiled in
302
- #endif
303
327
 
304
328
  return TAMP_OK;
305
329
  }
@@ -538,14 +562,12 @@ tamp_res tamp_decompressor_decompress_cb(TampDecompressor* decompressor, unsigne
538
562
  }
539
563
 
540
564
  // Copy pattern to output
541
- for (uint8_t i = 0; i < match_size_skip; i++) {
542
- *output++ = decompressor->window[window_offset_skip + i];
543
- }
565
+ TAMP_COPY_TO_OUTPUT(output, decompressor->window + window_offset_skip, match_size_skip);
544
566
  (*output_written_size) += match_size_skip;
545
567
 
546
568
  if (TAMP_LIKELY(decompressor->skip_bytes == 0)) {
547
569
  uint16_t wp = decompressor->window_pos;
548
- tamp_window_copy(decompressor->window, &wp, window_offset, match_size, window_mask);
570
+ TAMP_WINDOW_COPY(decompressor->window, &wp, window_offset, match_size, window_mask);
549
571
  decompressor->window_pos = wp;
550
572
  }
551
573
  }
@@ -7,8 +7,30 @@ from typing import Annotated, Optional
7
7
  from cyclopts import Parameter, validators
8
8
 
9
9
  import tamp
10
- from tamp._c_build_dictionary import score_and_multi_frag, select_candidates
11
- from tamp._c_build_dictionary import score_substrings as _c_score_substrings
10
+
11
+ _MISSING_KERNELS_MESSAGE = (
12
+ "Building dictionaries requires tamp's compiled C extensions, which are not available in this installation."
13
+ )
14
+
15
+ try:
16
+ from tamp._c_build_dictionary import score_and_multi_frag, select_candidates
17
+ from tamp._c_build_dictionary import score_substrings as _c_score_substrings
18
+
19
+ _KERNELS_AVAILABLE = True
20
+ except ImportError as e:
21
+ # C extensions unavailable (e.g. TAMP_BUILD_C_EXTENSIONS=0). Importing this
22
+ # module must still succeed so the rest of the tamp CLI works; only
23
+ # dictionary building itself requires the compiled kernels.
24
+ _KERNELS_AVAILABLE = False
25
+ _IMPORT_ERROR = e
26
+
27
+ def _missing_kernels(*args, **kwargs):
28
+ raise RuntimeError(_MISSING_KERNELS_MESSAGE) from _IMPORT_ERROR
29
+
30
+ score_and_multi_frag = _missing_kernels
31
+ select_candidates = _missing_kernels
32
+ _c_score_substrings = _missing_kernels
33
+
12
34
  from tamp.compressor import (
13
35
  _EXTENDED_MATCH_SYMBOL,
14
36
  _LEADING_EXTENDED_MATCH_HUFFMAN_BITS,
@@ -760,6 +782,9 @@ def build_dictionary_cli(
760
782
  quiet: bool
761
783
  Suppress the tradeoff table and summary output.
762
784
  """
785
+ if not _KERNELS_AVAILABLE:
786
+ raise RuntimeError(_MISSING_KERNELS_MESSAGE)
787
+
763
788
  dictionary_size = 1 << window
764
789
 
765
790
  # Materialize corpus for reuse in compression analysis.
@@ -19,7 +19,7 @@ try:
19
19
  except ImportError:
20
20
  sz = None
21
21
 
22
- from . import ExcessBitsError, bit_size, compute_min_pattern_size, initialize_dictionary
22
+ from . import ExcessBitsError, compute_min_pattern_size, initialize_dictionary
23
23
 
24
24
  # encodes [0, 14] pattern lengths
25
25
  _huffman_codes = b"\x00\x03\x08\x0b\x14$&+KT\x94\x95\xaa'\xab"
@@ -199,7 +199,9 @@ class Compressor:
199
199
  close_f_on_close = False
200
200
 
201
201
  self._bit_writer = _BitWriter(f, close_f_on_close=close_f_on_close)
202
- if dictionary and bit_size(len(dictionary) - 1) != window:
202
+ if dictionary is not None and len(dictionary) != (1 << window):
203
+ # The exact size is required: a too-small buffer used as the window
204
+ # would be indexed out of bounds.
203
205
  raise ValueError("Dictionary-window size mismatch.")
204
206
 
205
207
  if self.extended:
@@ -244,7 +246,7 @@ class Compressor:
244
246
  # Write header
245
247
  self._bit_writer.write(window - 8, 3, flush=False)
246
248
  self._bit_writer.write(literal - 5, 2, flush=False)
247
- self._bit_writer.write(bool(dictionary), 1, flush=False)
249
+ self._bit_writer.write(dictionary is not None, 1, flush=False)
248
250
  self._bit_writer.write(self.extended, 1, flush=False)
249
251
  self._bit_writer.write(
250
252
  1 if dictionary_reset else 0, 1, flush=False
@@ -258,7 +260,7 @@ class Compressor:
258
260
  self._window_buffer = _RingBuffer(
259
261
  buffer=(
260
262
  dictionary
261
- if dictionary
263
+ if dictionary is not None
262
264
  else initialize_dictionary(1 << self.window_bits, literal=self.literal_bits if self.extended else 8)
263
265
  ),
264
266
  )
@@ -287,6 +289,9 @@ class Compressor:
287
289
  self._last_was_flush = False
288
290
 
289
291
  if self._extended_match_count:
292
+ # This block consumes input and/or mutates the window, so any match
293
+ # cached by lazy matching no longer matches the current input.
294
+ self._cached_match_index = -1
290
295
  while self._input_buffer:
291
296
  if (self._extended_match_position + self._extended_match_count) >= self._window_buffer.size:
292
297
  # Reached window boundary - emit match (no wrap-around, only 0.02% compression loss)
@@ -332,6 +337,7 @@ class Compressor:
332
337
 
333
338
  # If RLE hasn't ended and we haven't hit max, consume and wait for more
334
339
  if not rle_ended and total_rle > 0:
340
+ self._cached_match_index = -1 # Input consumed; cached lazy match is stale.
335
341
  self._rle_count = total_rle
336
342
  for _ in range(rle_available):
337
343
  self._input_buffer.popleft()
@@ -353,6 +359,7 @@ class Compressor:
353
359
 
354
360
  if not use_pattern:
355
361
  # Use RLE - consume bytes and write token
362
+ self._cached_match_index = -1 # Input consumed; cached lazy match is stale.
356
363
  for _ in range(rle_available):
357
364
  self._input_buffer.popleft()
358
365
  self._rle_count = total_rle
@@ -360,7 +367,15 @@ class Compressor:
360
367
  return bytes_written
361
368
  self._rle_count = 0
362
369
  elif total_rle == 1:
363
- # Single byte - not worth RLE, will be handled as literal/pattern
370
+ if self._rle_count == 1:
371
+ # The lone run byte was consumed into _rle_count by a previous
372
+ # cycle and the run then ended; it is no longer in the input
373
+ # buffer, so emit it (_write_rle writes a count-1 run as a literal).
374
+ self._cached_match_index = -1
375
+ bytes_written += self._write_rle()
376
+ return bytes_written
377
+ # Single byte still in the input buffer - not worth RLE, will be
378
+ # handled as literal/pattern
364
379
  self._rle_count = 0
365
380
 
366
381
  # Normal pattern matching
@@ -5,7 +5,7 @@ from io import BytesIO
5
5
  import micropython
6
6
  from micropython import const
7
7
 
8
- from . import ExcessBitsError, bit_size, compute_min_pattern_size, initialize_dictionary
8
+ from . import ExcessBitsError, compute_min_pattern_size, initialize_dictionary
9
9
 
10
10
  # encodes [2, 15] pattern lengths
11
11
  _HUFFMAN_CODES = b"\x00\x03\x08\x0b\x14$&+KT\x94\x95\xaa'"
@@ -38,9 +38,9 @@ class Compressor:
38
38
  self.min_pattern_size = compute_min_pattern_size(window, literal)
39
39
 
40
40
  # Window Buffer
41
- if dictionary:
42
- if bit_size(len(dictionary) - 1) != window:
43
- raise ValueError
41
+ if dictionary is not None:
42
+ if len(dictionary) != (1 << window):
43
+ raise ValueError("Dictionary-window size mismatch.")
44
44
  self.window_buf = dictionary
45
45
  else:
46
46
  self.window_buf = initialize_dictionary(1 << window)
@@ -53,7 +53,7 @@ class Compressor:
53
53
 
54
54
  # Write header
55
55
  self.f = f
56
- self.f_buf = ((window - 8) << 5 | (literal - 5) << 3 | int(bool(dictionary)) << 2) << (22)
56
+ self.f_buf = ((window - 8) << 5 | (literal - 5) << 3 | int(dictionary is not None) << 2) << (22)
57
57
  self.f_pos = 8
58
58
 
59
59
  @micropython.viper
@@ -95,9 +95,14 @@ class Compressor:
95
95
  continue # Small Speed short-cut
96
96
 
97
97
  current_match_size = int(2)
98
- for k in range(current_match_size, input_size):
98
+ # Clamp to the window edge so the ptr8 read below can't go
99
+ # out of bounds; keeps the hot loop to a single comparison.
100
+ max_k = window_size - window_index
101
+ if max_k > input_size:
102
+ max_k = input_size
103
+ for k in range(current_match_size, max_k):
99
104
  input_index = (input_pos + k) & 0xF
100
- if input_buf[input_index] != window_buf[window_index + k] or window_index + k >= window_size:
105
+ if input_buf[input_index] != window_buf[window_index + k]:
101
106
  break
102
107
  current_match_size = k + 1
103
108
  if current_match_size > match_size:
@@ -111,9 +111,10 @@ class _BitReader:
111
111
 
112
112
 
113
113
  class _RingBuffer:
114
- def __init__(self, buffer):
114
+ def __init__(self, buffer, size=None):
115
115
  self.buffer = buffer
116
- self.size = len(buffer)
116
+ # The window may occupy only a prefix of an oversized buffer.
117
+ self.size = len(buffer) if size is None else size
117
118
  self.pos = 0 # Always pointing to the byte-to-be-overwritten
118
119
  self.index = self.buffer.index
119
120
 
@@ -163,7 +164,10 @@ class Decompressor:
163
164
  dictionary: Optional[bytearray]
164
165
  Use the given **initialized** buffer inplace.
165
166
  At compression time, the same initialized buffer must be provided.
166
- Decompression stream's ``window`` must agree with the dictionary size.
167
+ Must be at least ``2**window`` bytes long for the stream's window
168
+ (read from the header). The first ``2**window`` bytes are used in
169
+ place as the decompression window; bytes past that are never read
170
+ or written.
167
171
  If providing a pre-allocated buffer, but with default initialization, it must
168
172
  first be initialized with :func:`~tamp.initialize_dictionary`
169
173
  """
@@ -193,12 +197,27 @@ class Decompressor:
193
197
  if uses_custom_dictionary and dictionary is None:
194
198
  raise ValueError
195
199
 
200
+ window_size = 1 << self.window_bits
201
+ if dictionary is not None:
202
+ if len(dictionary) < window_size:
203
+ raise ValueError("Dictionary-window size mismatch.")
204
+ if not uses_custom_dictionary:
205
+ # Stream does not use a custom dictionary: initialize the
206
+ # supplied buffer's window region in place instead of using its
207
+ # contents verbatim, matching the C implementation.
208
+ literal = self.literal_bits if self.extended else 8
209
+ if len(dictionary) == window_size:
210
+ initialize_dictionary(dictionary, literal=literal)
211
+ else:
212
+ dictionary[:window_size] = initialize_dictionary(window_size, literal=literal)
213
+
196
214
  self._window_buffer = _RingBuffer(
197
215
  buffer=(
198
216
  dictionary
199
- if dictionary
200
- else initialize_dictionary(1 << self.window_bits, literal=self.literal_bits if self.extended else 8)
217
+ if dictionary is not None
218
+ else initialize_dictionary(window_size, literal=self.literal_bits if self.extended else 8)
201
219
  ),
220
+ size=window_size,
202
221
  )
203
222
 
204
223
  self.min_pattern_size = compute_min_pattern_size(self.window_bits, self.literal_bits)
@@ -335,7 +354,10 @@ class Decompressor:
335
354
  read_size = self.readinto(buf)
336
355
  if size > 0:
337
356
  # Read the entire contents in one go.
338
- out.append(buf)
357
+ # Trim unwritten zero-padding when the stream ended early.
358
+ # Slice instead of ``del buf[read_size:]``: MicroPython
359
+ # bytearrays don't support item deletion.
360
+ out.append(buf if read_size == len(buf) else buf[:read_size])
339
361
  break
340
362
  else:
341
363
  if read_size < len(buf):
@@ -35,6 +35,10 @@ class Decompressor:
35
35
  if uses_custom_dictionary:
36
36
  if not dictionary:
37
37
  raise ValueError
38
+ if len(dictionary) != (1 << self.w_bits):
39
+ # A wrong-size buffer used as the window would make the unchecked
40
+ # ptr8 window writes below go out of bounds.
41
+ raise ValueError("Dictionary-window size mismatch.")
38
42
  self.w_buf = dictionary
39
43
  else:
40
44
  self.w_buf = initialize_dictionary(1 << self.w_bits)
@@ -140,6 +144,10 @@ class Decompressor:
140
144
 
141
145
  match_size += min_pattern_size
142
146
  index = (f_buf >> (30 - token_bits)) & w_mask
147
+ if index + match_size > w_mask + 1:
148
+ # Corrupt stream: the match would read past the window
149
+ # buffer (the C implementation returns TAMP_OOB here).
150
+ raise ValueError("out-of-bounds window reference")
143
151
  f_buf = (f_buf << token_bits) & full_mask
144
152
  f_pos -= token_bits
145
153
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes