Skip to content

Commit 3c200e4

Browse files
authored
Merge pull request #95 from zmoon/eskin-deflate
Support Eskin ABC Tools deflate compression
2 parents 1ed456c + 16aaeb2 commit 3c200e4

3 files changed

Lines changed: 140 additions & 22 deletions

File tree

docs/changes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
* Update Norbeck to the current 2026-01 version ({pull}`96`)
66
* Various Bill Black fixes ({pull}`96`)
7+
* Add zlib-based compression support
8+
for Eskin ABC Transcription Tools URL decoding and URL creation ({pull}`95`).
9+
It is the default in the Eskin tools as of 2026-02-02,
10+
but for now, in {func}`~pyabc2.sources.eskin.abc_to_abctools_url`
11+
you have to opt in using `lzw=False`.
712

813
## v0.1.1 (2026-01-20)
914

pyabc2/sources/eskin.py

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,37 @@
5959
_URL_NETLOCS = {"michaeleskin.com", "www.michaeleskin.com"}
6060

6161

62+
def _deflate(s: str, /) -> str:
63+
"""Use deflate (zlib) to compress and base64-encode `s`."""
64+
import base64
65+
import zlib
66+
67+
b = s.encode("utf-8")
68+
c = zlib.compress(b)
69+
b64 = base64.b64encode(c).decode("ascii")
70+
b64_for_url = b64.replace("+", "-").replace("/", "_").rstrip("=")
71+
return b64_for_url
72+
73+
74+
def _inflate(s: str, /) -> str:
75+
"""Use inflate (zlib) to decompress and base64-decode `s`."""
76+
import base64
77+
import zlib
78+
79+
b64 = s.replace("-", "+").replace("_", "/")
80+
pad = len(b64) % 4
81+
if pad == 2:
82+
b64 += "=="
83+
elif pad == 3:
84+
b64 += "="
85+
else:
86+
if pad != 0:
87+
raise ValueError(f"Invalid base64 string length {len(b64)}")
88+
c = base64.b64decode(b64)
89+
b = zlib.decompress(c)
90+
return b.decode("utf-8")
91+
92+
6293
def abctools_url_to_abc(
6394
url: str,
6495
*,
@@ -85,6 +116,14 @@ def abctools_url_to_abc(
85116
remove_prefs
86117
Remove lines starting with these prefixes.
87118
Use ``False`` or an empty iterable to keep all lines instead.
119+
120+
Notes
121+
-----
122+
``def`` takes preference if both ``def`` and ``lzw`` are present in the URL query parameters.
123+
124+
See Also
125+
--------
126+
abc_to_abctools_url
88127
"""
89128

90129
if not remove_prefs:
@@ -99,19 +138,36 @@ def abctools_url_to_abc(
99138
logger.debug(f"Unexpected Eskin URL path: {res.path}")
100139

101140
query_params = parse_qs(res.query)
102-
try:
103-
(lzw,) = query_params["lzw"]
104-
except Exception as e:
105-
raise ValueError("URL does not contain required 'lzw' parameter") from e
106-
# Note `+` has been replaced with space by parse_qs
107-
# Note js LZString.compressToEncodedURIComponent() is used to compress/encode the ABC
141+
# Note `+` is now replaced with space
108142

109-
try:
110-
abc = LZString.decompressFromEncodedURIComponent(lzw)
111-
except Exception as e:
112-
raise RuntimeError("Failed to decompress LZString data") from e
113-
if abc is None:
114-
raise RuntimeError("Failed to decompress LZString data")
143+
abc_params = ["def", "lzw"]
144+
todo = abc_params[:]
145+
while todo:
146+
param = todo.pop(0)
147+
try:
148+
(encoded,) = query_params[param]
149+
except KeyError:
150+
continue
151+
152+
if param == "lzw":
153+
try:
154+
abc = LZString.decompressFromEncodedURIComponent(encoded)
155+
except Exception as e:
156+
raise RuntimeError("Failed to decompress LZString data") from e
157+
if abc is None: # pragma: no cover
158+
raise RuntimeError("Failed to decompress LZString data")
159+
break
160+
elif param == "def":
161+
try:
162+
abc = _inflate(encoded)
163+
except Exception as e:
164+
raise RuntimeError("Failed to decompress deflate data") from e
165+
break
166+
else: # pragma: no cover
167+
raise AssertionError(f"Unexpected ABC data parameter: {param!r}")
168+
else:
169+
s_params = ", ".join(repr(p) for p in abc_params)
170+
raise ValueError(f"No known ABC data parameter found in URL (tried {s_params})")
115171

116172
wanted_lines = [
117173
line.strip() for line in abc.splitlines() if not line.lstrip().startswith(remove_prefs)
@@ -120,19 +176,37 @@ def abctools_url_to_abc(
120176
return "\n".join(wanted_lines)
121177

122178

123-
def abc_to_abctools_url(abc: str) -> str:
179+
def abc_to_abctools_url(abc: str, *, lzw: bool = True) -> str:
124180
"""Create an Eskin abctools (``michaeleskin.com/abctools/``) share URL for `abc`.
125181
126182
More info: https://michaeleskin.com/tools/generate_share_link.html
183+
184+
Parameters
185+
----------
186+
abc
187+
The tune.
188+
lzw
189+
Whether to use the original LZString compression method (``True``, default)
190+
or the newer deflate (zlib) compression method (``False``),
191+
which gives shorter URLs.
192+
193+
See Also
194+
--------
195+
abctools_url_to_abc
127196
"""
128197

129198
# Must start with 'X:' (seems value is not required)
130199
if not abc.lstrip().startswith("X"):
131200
abc = "X:\n" + abc
132201

133-
lzw = LZString.compressToEncodedURIComponent(abc)
202+
if lzw:
203+
param = "lzw"
204+
compressed = LZString.compressToEncodedURIComponent(abc)
205+
else:
206+
param = "def"
207+
compressed = _deflate(abc)
134208

135-
return f"https://michaeleskin.com/abctools/abctools.html?lzw={lzw}"
209+
return f"https://michaeleskin.com/abctools/abctools.html?{param}={compressed}"
136210

137211

138212
class EskinTunebookInfo(NamedTuple):
@@ -298,8 +372,10 @@ def load_url(url: str) -> Tune:
298372
from . import load_example_abc
299373

300374
abc = load_example_abc("For the Love of Music")
301-
url = abc_to_abctools_url(abc)
302-
print(url)
375+
url_lzw = abc_to_abctools_url(abc, lzw=True)
376+
print(url_lzw)
377+
url_def = abc_to_abctools_url(abc, lzw=False)
378+
print(url_def)
303379

304380
kss = load_meta("kss")
305381
print(kss)

tests/test_sources.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919

2020
NORBECK_IRISH_COUNT = 2813
2121

22+
ESKIN_COMPRESSED_ABC_DATA = {
23+
# For the Love of Music with `X:`
24+
# Compressed/encoded tune data only (no other query params)
25+
"lzw": "BoLgUAKiBiD2BOACCALApogMrAbhg8gGaICyArgM4CWAxmAEogUA2VADogFZUDmYAwiExUAXon4BDePFjNmYEiACcAegAcYTCACM6sAGkQAcTBGAogBFEFs0cQBBIwCFEAHwdG7zgCaI0333dzKxs7Rxo3RCc0DCd7F3MzRBBXMB5-PxVCFR4EpxUaFUDEdN80HgAjRAkAJmJ3Uszs3Id8wuL-F28nMKdAtIy0LJy8gqLIxvKq2olIipimnIxankjOxG7e+zdUoA",
26+
"def": "eJyFjbEKwkAQRPv9iv2DQyvd7jbGK0wQJIVtktucJ4FIghZyH-8aCWJlM_BmZ2fOBBXthxGri2AxPASPHZb3KbZwoqmPN7zGABkV8YlZPY5D30NJW7OBglaqB3Lg8h3ucofWMSZVh449ivdK31urxCLIltXNkRIE0ZjpTFCHTWveD7MXGqzX3UKfhF0S4hk9a6WO_OuolRodnROiRvgpsJgSvAAdaUjy",
27+
}
28+
2229

2330
@pytest.mark.parametrize("tune_name", examples)
2431
def test_examples_load(tune_name):
@@ -258,10 +265,16 @@ def test_load_url_norbeck(netloc):
258265

259266

260267
@pytest.mark.parametrize("netloc", sorted(eskin._URL_NETLOCS))
261-
def test_load_url_eskin(netloc):
262-
url = f"https://{netloc}/abctools/abctools.html?lzw=BoLgUAKiBiD2BOACCALApogMrAbhg8gGaICyArgM4CWAxmAEogUA2VADogFZUDmYAwiExUAXon4BDePFjNmYEiACcAegAcYTCACM6sAGkQAcTBGAogBFEFs0cQBBIwCFEAHwdG7zgCaI0333dzKxs7Rxo3RCc0DCd7F3MzRBBXMB5-PxVCFR4EpxUaFUDEdN80HgAjRAkAJmJ3Uszs3Id8wuL-F28nMKdAtIy0LJy8gqLIxvKq2olIipimnIxankjOxG7e+zdUoA"
268+
@pytest.mark.parametrize("param", list(ESKIN_COMPRESSED_ABC_DATA))
269+
def test_load_url_eskin(netloc, param):
270+
data = ESKIN_COMPRESSED_ABC_DATA[param]
271+
url = f"https://{netloc}/abctools/abctools.html?{param}={data}"
263272
tune = load_url(url)
264273
assert tune.title == "For The Love Of Music"
274+
assert str(tune.key) == "Gmaj"
275+
assert len(tune.measures) == 16
276+
assert len(tune.measures[0]) == len(tune.measures[-1]) == 9
277+
assert tune.abc + "\n" == "X:\n" + examples["for the love of music"]
265278

266279

267280
def test_load_url_invalid_domain():
@@ -375,7 +388,10 @@ def test_eskin_abc_url_parsing():
375388

376389
def test_eskin_abc_url_missing_param():
377390
url = "https://michaeleskin.com/abctools/abctools.html?"
378-
with pytest.raises(ValueError, match="URL does not contain required 'lzw' parameter"):
391+
with pytest.raises(
392+
ValueError,
393+
match=r"No known ABC data parameter found in URL \(tried 'def', 'lzw'\)",
394+
):
379395
_ = eskin.abctools_url_to_abc(url)
380396

381397

@@ -385,6 +401,12 @@ def test_eskin_abc_url_bad_param():
385401
_ = eskin.abctools_url_to_abc(url)
386402

387403

404+
def test_eskin_abc_url_bad_param_def():
405+
url = "https://michaeleskin.com/abctools/abctools.html?def=hi"
406+
with pytest.raises(RuntimeError, match="Failed to decompress deflate data"):
407+
_ = eskin.abctools_url_to_abc(url)
408+
409+
388410
def test_eskin_abc_url_bad(caplog):
389411
url = "https://michaeleski.com/deftools/abctools.html?lzw=BoLgjAUApFAuCWsA2BTAZgewHawAQAUBDJQhLDXMADmigGcBXAIwWXWzyJLIrAGZa8LJkw4CxUkN4CYAB0IAnWHVGcJPSjLgoAtrIyrx3KZtqwUAD1iGuk8qYAqIBwAsUuAIJMmKAJ64IABlwAHoaAFkQABYQqIgARRAAJliAXgBOAAYIACUQHJQUJGg6AHchAHNcTIA6SABpEABxaEImAGMAKzoAfToMBiwAE0M0UiYMX1pwgEkAERncWQUMCoVCHWrp+cWmQjo6ZdWtmFmF3HaXDAUho6rs053cPYOANwwkXAA2OMfzy+uQ3enx+rSGQx6xCQPVkJF8e3aAGsekghIi6BAAEQeHSYzx8XAAIU8SVwTQAorgAD6eDxNDy4TFNPGE8HEmnY3G0jzEunkgBi1MZzLJTXpRLZ1KxOLxHlJPJJZMpNI8dIZTJZko5MsVCr5go5IrF4tZQyqVKp0q5KAqttwhFJeyFGtwFTaVUIFRQQ2dOptdodrsIzuZTDdaFd7iGpMtnLx-o9FTDIcxnuTnu9vutto9pLdKbDhAjXqG7IAukA&format=noten&ssp=10&name=The_Abbey&play=1"
390412
with caplog.at_level("DEBUG"):
@@ -396,12 +418,13 @@ def test_eskin_abc_url_bad(caplog):
396418
]
397419

398420

399-
def test_eskin_abc_url_creation():
421+
@pytest.mark.parametrize("use_lzw", [True, False])
422+
def test_eskin_abc_url_creation(use_lzw):
400423
import requests
401424

402425
abc = load_example_abc("For the Love of Music")
403426

404-
url = eskin.abc_to_abctools_url(abc)
427+
url = eskin.abc_to_abctools_url(abc, lzw=use_lzw)
405428
r = requests.head(url, timeout=5)
406429
r.raise_for_status()
407430
if (
@@ -416,6 +439,20 @@ def test_eskin_invalid_tunebook_key():
416439
_ = eskin.get_tunebook_info("asdf")
417440

418441

442+
def test_eskin_inflate_invalid_length():
443+
s = "eJyFjbEKwkAQRPv9iv2DQyvd7jbGK0wQJIVtktucJ4FIghZyH-abcdefg"
444+
with pytest.raises(
445+
ValueError,
446+
match=f"Invalid base64 string length {len(s)}",
447+
):
448+
_ = eskin._inflate(s)
449+
450+
451+
def test_eskin_inflate_pad_3():
452+
s = "abc"
453+
assert eskin._inflate(eskin._deflate(s)) == s
454+
455+
419456
@pytest.mark.xfail(reason="Bill Black site now has HTTPS", strict=False)
420457
def test_bill_black_no_https():
421458
# If the site does get HTTPS, we'd like to know

0 commit comments

Comments
 (0)