Skip to content

Commit 0466f6f

Browse files
committed
Added codecs: huffman, zstd
1 parent 754324f commit 0466f6f

8 files changed

Lines changed: 282 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,14 @@ This category also contains `ascii85`, `adobe`, `[x]btoa`, `zeromq` with the `ba
338338
#### [Compression](https://python-codext.readthedocs.io/en/latest/enc/compressions.html)
339339

340340
- [X] `gzip`: standard Gzip compression/decompression
341+
- [X] `huffman`: canonical Huffman coding of the bytes, with a self-describing header
341342
- [X] `lz77`: compresses the given data with the algorithm of Lempel and Ziv of 1977
342343
- [X] `lz78`: compresses the given data with the algorithm of Lempel and Ziv of 1978
343344
- [X] `pkzip_deflate`: standard Zip-deflate compression/decompression
344345
- [X] `pkzip_bzip2`: standard BZip2 compression/decompression
345346
- [X] `pkzip_lzma`: standard LZMA compression/decompression
346347
- [X] `rle`: compresses runs of identical characters with Run-Length Encoding (count before or after each character, compact, bytes or repeated-byte escape variants)
348+
- [X] `zstd`: Zstandard compression/decompression (relies on `compression.zstd` from Python 3.14, on `backports.zstd` before)
347349

348350
> :warning: Compression functions are of course definitely **NOT** encoding functions ; they are implemented for leveraging the `.encode(...)` API from `codecs`.
349351

‎docs/pages/enc/compressions.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717

1818
-----
1919

20+
### Huffman
21+
22+
[Huffman coding](https://en.wikipedia.org/wiki/Huffman_coding) gives the frequent bytes shorter codes than the rare ones, with an optimal prefix code. This codec writes the code as a [canonical Huffman code](https://en.wikipedia.org/wiki/Canonical_Huffman_code) (given by the lengths of the codes only), in a self-describing format: the number of distinct bytes minus 1 (1 byte), each byte with the length of its code (2 bytes each), the number of padding bits at the end (1 byte), then the codes of the bytes of the text, from the most significant bit.
23+
24+
**Codec** | **Conversions** | **Aliases** | **Comment**
25+
:---: | :---: | --- | ---
26+
`huffman` | data <-> Huffman-coded data | | malformed data replaced, dropped or left with a non-strict errors handling ; as the length of the text is not stored, data truncated at the end of a code gives a truncated text
27+
28+
```python
29+
>>> codext.encode(b"abracadabra", "huffman")
30+
b'\x04a\x01b\x03c\x03d\x03r\x03\x01N\xac\x9c'
31+
>>> codext.decode(b"\x04a\x01b\x03c\x03d\x03r\x03\x01N\xac\x9c", "huffman")
32+
b'abracadabra'
33+
```
34+
35+
-----
36+
2037
### Lempel-Ziv
2138

2239
This implements the algorithm of Lempel and Ziv of 1977 and 1978.
@@ -106,3 +123,21 @@ This replaces each run of identical consecutive characters by its length followe
106123
>>> codext.decode("AA\x03B", "rle-escape")
107124
'AAAAAB'
108125
```
126+
127+
-----
128+
129+
### Zstandard
130+
131+
[Zstandard](https://datatracker.ietf.org/doc/html/rfc8878) (zstd) combines LZ77-style matching with entropy coding, in frames starting with a magic number. This codec relies on the `compression.zstd` module of the standard library (Python 3.14 and later) or on its backport, the [`backports.zstd`](https://pypi.org/project/backports.zstd/) package, installed as a dependency for the previous versions of Python ; it is not available without them.
132+
133+
**Codec** | **Conversions** | **Aliases** | **Comment**
134+
:---: | :---: | --- | ---
135+
`zstd` | data <-> Zstandard-compressed data | `zstandard`, `zstd-1`, `zstd_19`, ... | parametrized with the compression level, from 1 to 22 (default: 3) ; decoding handles concatenated frames
136+
137+
```python
138+
>>> data = codext.encode(b"This is a test " * 10, "zstd-19")
139+
>>> data[:4]
140+
b'(\xb5/\xfd'
141+
>>> codext.decode(data, "zstd")
142+
b'This is a test This is a test This is a test This is a test This is a test This is a test This is a test This is a test This is a test This is a test '
143+
```

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ classifiers = [
5757
"Topic :: Software Development :: Libraries :: Python Modules",
5858
]
5959
dependencies = [
60+
"backports.zstd; python_version < '3.14'",
6061
"legacycrypt; python_version >= '3.13'",
6162
"markdown2>=2.5.4",
6263
]

‎requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
backports.zstd; python_version < '3.14'
12
legacycrypt; python_version >= '3.13'
23
markdown2>=2.5.4

‎src/codext/compressions/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# -*- coding: UTF-8 -*-
22
from .gzipp import *
3+
from .huffman import *
34
from .lz77 import *
45
from .lz78 import *
56
from .pkzip import *
67
from .rle import *
8+
from .zstd import *
79

810

911
for e in list_encodings("compression"):

‎src/codext/compressions/huffman.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# -*- coding: UTF-8 -*-
2+
"""Huffman Codec - Huffman coding content compression.
3+
4+
Huffman coding, devised by David A. Huffman in 1952, gives the frequent symbols shorter codes than the rare ones, with
5+
an optimal prefix code (no code being the beginning of another) built by merging the two least frequent groups of
6+
symbols until a single one remains. This codec builds the code of the bytes of the text and writes it as a canonical
7+
Huffman code (the codes being assigned in order of length, then of symbol, hence given by their lengths only), in a
8+
self-describing format:
9+
- the number of distinct bytes minus 1 (1 byte)
10+
- for each of them, the byte and the length of its code (2 bytes)
11+
- the number of padding bits at the end of the last byte (1 byte)
12+
- the codes of the bytes of the text, from the most significant bit (a single distinct byte having the code "0")
13+
14+
An empty text gives an empty output. As with the other compression codecs, malformed data is reported (or, with a
15+
non-strict errors handling, replaced, dropped or left as is), but, as the length of the text is not stored, data
16+
truncated at the end of a code gives a truncated text.
17+
18+
NB: Not an encoding properly speaking.
19+
20+
This codec:
21+
- en/decodes strings from str to str
22+
- en/decodes strings from bytes to bytes
23+
- decodes file content to str (read)
24+
- encodes file content from str to bytes (write)
25+
26+
Reference: https://en.wikipedia.org/wiki/Canonical_Huffman_code
27+
"""
28+
import heapq
29+
from collections import Counter
30+
31+
from ..__common__ import *
32+
33+
34+
__examples__ = {
35+
'enc(huffman)': {'': "", 'a': "\x00a\x01\x07\x00", 'aab': "\x01a\x01b\x01\x05 ",
36+
'abracadabra': "\x04a\x01b\x03c\x03d\x03r\x03\x01N\xac\x9c"},
37+
'dec(huffman)': {'\x00a\x01\x07\x00': "a", '\x01a\x01b\x01\x05 ': "aab", '\x01a\x01a\x01\x05 ': None,
38+
'\x01a\x01b\x02\x05 ': None, '\x01a\x01b\x01\x08 ': None, '\x01a\x01b\x01': None,
39+
'\x05': None, '\x01a\x01b\x00\x05 ': None, '\x00a\x02\x07\x00': None},
40+
'enc-dec(huffman)': ["", "aaaa", "This is a test", "@random{1,256,2048}"],
41+
}
42+
43+
44+
def _lengths(data):
45+
""" Lengths of the Huffman codes of the bytes of the data, merging the least frequent groups (the ties being broken
46+
by the order of creation, the bytes being taken in increasing order). """
47+
counts = Counter(data)
48+
if len(counts) == 1:
49+
return {next(iter(counts)): 1}
50+
heap, lengths = [(n, i, [s]) for i, (s, n) in enumerate(sorted(counts.items()))], dict.fromkeys(counts, 0)
51+
heapq.heapify(heap)
52+
i = len(heap)
53+
while len(heap) > 1:
54+
n1, _, g1 = heapq.heappop(heap)
55+
n2, _, g2 = heapq.heappop(heap)
56+
for s in g1 + g2:
57+
lengths[s] += 1
58+
heapq.heappush(heap, (n1 + n2, i, g1 + g2))
59+
i += 1
60+
return lengths
61+
62+
63+
def _canonical(lengths):
64+
""" Canonical codes (as (code, length)) of the bytes, assigned in order of length, then of byte. """
65+
codes, code, prev = {}, 0, 0
66+
for s, n in sorted(lengths.items(), key=lambda x: (x[1], x[0])):
67+
code <<= n - prev
68+
codes[s], code, prev = (code, n), code + 1, n
69+
return codes
70+
71+
72+
def huffman_compress(text, errors="strict"):
73+
data = b(ensure_str(text))
74+
if not data:
75+
return b"", len(text)
76+
lengths = _lengths(data)
77+
codes, header = _canonical(lengths), bytes([len(lengths) - 1])
78+
for s in sorted(lengths):
79+
header += bytes([s, lengths[s]])
80+
acc, nbits = 0, 0
81+
for x in data:
82+
c, n = codes[x]
83+
acc, nbits = acc << n | c, nbits + n
84+
pad = -nbits % 8
85+
# the compressed data is given as latin-1 text (or bytes), so that it is read back as is
86+
return (header + bytes([pad]) + (acc << pad).to_bytes((nbits + pad) // 8, "big")).decode("latin-1"), len(text)
87+
88+
89+
def _error(msg):
90+
raise HuffmanDecodeError(f"'huffman' codec can't decode: {msg}")
91+
92+
93+
def _decode(data):
94+
""" Decode Huffman-coded data, raising an error with a precise message if it is malformed. """
95+
if not data:
96+
return b""
97+
n = data[0] + 1
98+
if len(data) < 2 + 2 * n:
99+
_error("truncated header")
100+
lengths = {}
101+
for i in range(n):
102+
s, length = data[1 + 2 * i], data[2 + 2 * i]
103+
if s in lengths or length == 0:
104+
_error("bad code length" if length == 0 else f"duplicate byte {s}")
105+
lengths[s] = length
106+
# the code lengths must give a complete prefix code (Kraft's equality), a single byte having a code of 1 bit
107+
m = max(lengths.values())
108+
if (sum(1 << (m - length) for length in lengths.values()) != 1 << m) if n > 1 else m != 1:
109+
_error("code lengths not giving a complete prefix code")
110+
pad, payload = data[1 + 2 * n], data[2 + 2 * n:]
111+
if pad > 7 or not payload or 8 * len(payload) - pad < min(lengths.values()):
112+
_error("bad padding")
113+
table = {(c, l): s for s, (c, l) in _canonical(lengths).items()}
114+
bits, r, code, length = int.from_bytes(payload, "big") >> pad, bytearray(), 0, 0
115+
# as the code is complete, any sequence of bits is a sequence of codes, possibly followed by a truncated one
116+
for i in range(8 * len(payload) - pad - 1, -1, -1):
117+
code, length = code << 1 | bits >> i & 1, length + 1
118+
if (code, length) in table:
119+
r.append(table[code, length])
120+
code, length = 0, 0
121+
if length:
122+
_error("truncated code at the end")
123+
return bytes(r)
124+
125+
126+
def huffman_decompress(text, errors="strict"):
127+
data = b(ensure_str(text))
128+
try:
129+
return _decode(data), len(text)
130+
except HuffmanDecodeError:
131+
# as with gzip and zstd, malformed data is reported (with a precise message), or replaced, dropped or left as is
132+
if errors == "strict":
133+
raise
134+
return handle_error("huffman", errors, decode=True)(data[:1].decode("latin-1"), 0), len(text)
135+
136+
137+
add("huffman", huffman_compress, huffman_decompress, guess=None)

‎src/codext/compressions/zstd.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# -*- coding: UTF-8 -*-
2+
"""Zstandard Codec - zstd content compression.
3+
4+
Zstandard (zstd), created by Yann Collet at Facebook in 2015 and specified in RFC 8878, combines LZ77-style matching
5+
with entropy coding (Huffman coding for the literals and finite-state entropy for the sequences), in frames with a
6+
magic number (0xFD2FB528). This codec relies on the compression.zstd module of the standard library (Python 3.14 and
7+
later) or on its backport (the backports.zstd package, installed as a dependency for the previous versions of Python),
8+
and is not available without them. The compression level is given with the codec name (from 1 to 22, e.g. "zstd-19")
9+
and defaults to 3 ; decoding handles concatenated frames.
10+
11+
NB: Not an encoding properly speaking.
12+
13+
This codec:
14+
- en/decodes strings from str to str
15+
- en/decodes strings from bytes to bytes
16+
- decodes file content to str (read)
17+
- encodes file content from str to bytes (write)
18+
19+
Reference: https://datatracker.ietf.org/doc/html/rfc8878
20+
"""
21+
from ..__common__ import *
22+
23+
try:
24+
from compression import zstd
25+
except ImportError:
26+
try:
27+
from backports import zstd
28+
except ImportError:
29+
zstd = None
30+
31+
32+
__examples__ = {
33+
'dec(zstd)': {'(\xb5/\xfd \x04!\x00\x00test': "test", '(\xb5/\xfd \x04!\x00\x00tes': None, 'test': None},
34+
'enc-dec(zstd|zstandard|zstd-1|zstd_19|zstd-22)': ["", "test", "This is a test", "@random{512,1024,2048}"],
35+
}
36+
37+
38+
def zstd_compress(level=None):
39+
def compress(text, errors="strict"):
40+
lvl = level or 3
41+
if lvl not in range(1, 23):
42+
raise LookupError("Bad parameter for encoding 'zstd': compression level must be between 1 and 22")
43+
# the compressed data is given as latin-1 text (or bytes), so that it is read back as is
44+
return zstd.compress(b(ensure_str(text)), level=lvl).decode("latin-1"), len(text)
45+
return compress
46+
47+
48+
def zstd_decompress(level=None):
49+
def decompress(text, errors="strict"):
50+
data = b(ensure_str(text))
51+
try:
52+
r = zstd.decompress(data)
53+
except zstd.ZstdError:
54+
return handle_error("zstd", errors, decode=True)(data[:1].decode("latin-1"), 0), len(text)
55+
return r, len(text)
56+
return decompress
57+
58+
59+
if zstd is not None:
60+
add("zstd", zstd_compress, zstd_decompress, r"^(?:zstd|zstandard)(?:[-_](\d{1,2}))?$", examples=__examples__)

‎tests/test_manual.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,50 @@ def test_codec_dummy_str_manips(self):
768768
self.assertEqual(codecs.encode(STR, "tokenize-2"), "th is i s a te st")
769769
self.assertRaises(LookupError, codecs.encode, STR, "tokenize-200")
770770

771+
def test_codec_huffman(self):
772+
import heapq
773+
from collections import Counter
774+
# optimal codes: the length of the codes is the sum of the weights of the merged groups
775+
data = b"The quick brown fox jumps over the lazy dog " * 20
776+
enc = codecs.encode(data, "huffman")
777+
weights, total = list(Counter(data).values()), 0
778+
heapq.heapify(weights)
779+
while len(weights) > 1:
780+
w = heapq.heappop(weights) + heapq.heappop(weights)
781+
total += w
782+
heapq.heappush(weights, w)
783+
n = enc[0] + 1
784+
self.assertEqual(8 * (len(enc) - 2 - 2 * n) - enc[1 + 2 * n], total)
785+
# text with characters outside latin-1, and malformed data (truncated header, null code length, extra bits,
786+
# missing payload) reported with a precise message, or replaced, dropped or left as is (as with gzip and zstd)
787+
self.assertEqual(codecs.decode(codecs.encode("caf\u00e9 \u20ac", "huffman"), "huffman"), "caf\u00e9 \u20ac")
788+
for bad, msg in [(b"\x01a", "truncated header"), (b"\x01a\x00b\x01\x05 ", "bad code length"),
789+
(enc + b"\xff", "truncated code at the end"), (enc[:2 + 2 * n], "bad padding")]:
790+
self.assertRaisesRegex(ValueError, msg, codecs.decode, bad, "huffman")
791+
self.assertEqual(codecs.decode(bad, "huffman", "replace"), b"?")
792+
self.assertEqual(codecs.decode(bad, "huffman", "ignore"), b"")
793+
self.assertEqual(codecs.decode(bad, "huffman", "leave"), bad[:1])
794+
self.assertRaises(ValueError, codecs.decode, b"\x01a", "huffman", "BAD_ERRORS")
795+
796+
def test_codec_zstd(self):
797+
try:
798+
from compression import zstd
799+
except ImportError:
800+
try:
801+
from backports import zstd
802+
except ImportError:
803+
self.skipTest("neither compression.zstd (Python 3.14+) nor backports.zstd is available")
804+
# a frame with a raw block (magic number, frame header with the content size, block header), levels and errors
805+
self.assertEqual(codecs.decode(b"\x28\xb5\x2f\xfd\x20\x04\x21\x00\x00test", "zstd"), b"test")
806+
data = b"This is a test " * 100
807+
self.assertLess(len(codecs.encode(data, "zstd-22")), len(data) // 10)
808+
self.assertEqual(zstd.decompress(codecs.encode(data, "zstd-19")), data)
809+
for e in ["zstd-0", "zstd-23"]:
810+
self.assertRaises(LookupError, codecs.encode, data, e)
811+
self.assertRaises(ValueError, codecs.decode, b"not zstd", "zstd")
812+
self.assertEqual(codecs.decode(b"not zstd", "zstd", "ignore"), b"")
813+
self.assertEqual(codecs.decode(b"not zstd", "zstd", "replace"), b"?")
814+
771815
def test_codec_hash_functions(self):
772816
STR = b"This is a test string!"
773817
for h in ["adler32", "md2", "md5", "sha1", "sha224", "sha256", "sha384", "sha512"]:

0 commit comments

Comments
 (0)