Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ Functions:

bill_black.load_meta

Paul Hardy
----------

.. automodule:: pyabc2.sources.hardy

Functions:

.. currentmodule:: pyabc2.sources

.. autosummary::
:toctree: api/

hardy.load_meta

abcjs tools
===========

Expand Down
1 change: 1 addition & 0 deletions docs/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{class}`~pyabc2.abcjs.widget.ABCJSWidget`,
but non-responsive is still the default.
* Update for changes in <https://www.capeirish.com/ittl/tunefolders/> ({pull}`104`).
* Add Paul Hardy to sources ({mod}`pyabc2.sources.hardy`; {pull}`105`).

## v0.1.2 (2026-02-03)

Expand Down
33 changes: 32 additions & 1 deletion docs/examples/sources.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"outputs": [],
"source": [
"from pyabc2 import Tune\n",
"from pyabc2.sources import load_example, norbeck, the_session, eskin, bill_black"
"from pyabc2.sources import load_example, norbeck, the_session, eskin, bill_black, hardy"
]
},
{
Expand Down Expand Up @@ -450,6 +450,37 @@
"source": [
"Tune(abcs[0])"
]
},
{
"cell_type": "markdown",
"id": "39",
"metadata": {},
"source": [
"## Hardy\n",
"\n",
"Paul Hardy has a tunebook collection available at <https://pghardy.net/tunebooks/>. We can load selected tunebooks as a list of tune blocks (strings) with {func}`pyabc2.sources.hardy.load_meta`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "40",
"metadata": {},
"outputs": [],
"source": [
"abcs = hardy.load_meta(\"basic\")\n",
"len(abcs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "41",
"metadata": {},
"outputs": [],
"source": [
"Tune(abcs[0])"
]
}
],
"metadata": {
Expand Down
142 changes: 142 additions & 0 deletions pyabc2/sources/hardy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
Load data from Paul Hardy's tunebooks (https://pghardy.net/tunebooks/).

Requires:

* `requests <https://requests.readthedocs.io/>`__
"""

import re
from pathlib import Path
from typing import Literal

HERE = Path(__file__).parent

SAVE_TO = HERE / "_hardy"

_BASE_URL = "https://pghardy.net/tunebooks/"

_TUNEBOOK_KEY_TO_URL = {
"session": _BASE_URL + "pgh_session_tunebook.abc",
"annex": _BASE_URL + "pgh_annex_tunebook.abc",
"basic": _BASE_URL + "pgh_basic_tunebook.abc",
"xmas": _BASE_URL + "pgh_xmas_tunebook.abc",
"possible": _BASE_URL + "pgh_possible_tunebook.abc",
"pete_mac": _BASE_URL + "pgh_pete_mac_tunebook.abc",
"clarke": _BASE_URL + "williamclarke_tunes.abc",
}


def download(key: str) -> None:
"""Download the ABC file for the given tunebook key and cache it."""
import requests

key = key.lower()
try:
url = _TUNEBOOK_KEY_TO_URL[key]
except KeyError:
raise ValueError(
f"Unknown Hardy tunebook key: {key!r}. Valid options: {sorted(_TUNEBOOK_KEY_TO_URL)}."
) from None

r = requests.get(url, timeout=10)
r.raise_for_status()

SAVE_TO.mkdir(exist_ok=True)
(SAVE_TO / f"{key}.abc").write_text(r.text, encoding="utf-8")


def load_meta(
key: str,
*,
redownload: bool = False,
remove_prefs: str | tuple[str, ...] | Literal[False] = ("%",),
) -> list[str]:
"""Load ABC tune blocks from a Paul Hardy tunebook, no parsing.

Parameters
----------
key
Tunebook key.

.. list-table::
:header-rows: 1
:widths: 15 85

* - Key
- Description
* - ``session``
- Paul Hardy's Session Tunebook (the main tunebook)
* - ``annex``
- Paul Hardy's Annex Tunebook (current edition; tunes awaiting next session edition)
* - ``basic``
- Paul Hardy's Basic Tunebook (subset of simpler/common session tunes)
* - ``xmas``
- Paul Hardy's Xmas Tunebook (Christmas tunes and carols)
* - ``possible``
- Paul Hardy's Possible Tunebook (tunes not yet fully learned)
* - ``pete_mac``
- Paul Hardy's Pete Mac Tunebook (CC0 tunes by Pete Mac)
* - ``clarke``
- William Clarke of Feltwell Tunebook (19th century East Anglian manuscript)

See https://pghardy.net/tunebooks/ for more information.
redownload
Re-download the data file.
remove_prefs
Remove lines starting with these prefixes (applied at load time; cached file is unmodified).
Defaults to ``("%",)``, which strips all ``%`` comment and ``%%`` directive lines.
Pass ``False`` or ``()`` to keep all lines.

See Also
--------
:doc:`/examples/sources`
"""
key = key.lower()
if key not in _TUNEBOOK_KEY_TO_URL:
raise ValueError(
f"Unknown Hardy tunebook key: {key!r}. Valid options: {sorted(_TUNEBOOK_KEY_TO_URL)}."
)

if redownload or not (SAVE_TO / f"{key}.abc").is_file():
print("downloading...", end=" ", flush=True)
download(key)
print("done")

# Read as binary to avoid universal-newlines mangling of \r\r\n (Hardy's line ending)
# and then convert to \n.
text = (SAVE_TO / f"{key}.abc").read_bytes().decode("utf-8").replace("\r", "")

if not remove_prefs:
remove_prefs = ()
elif isinstance(remove_prefs, str):
remove_prefs = (remove_prefs,)

# Split into tune blocks by finding X: at start of line
# (Each tune block begins with X:)
parts = re.split(r"(?m)^(?=X:)", text)
abcs = []
for part in parts:
part = part.strip()
if not part.startswith("X:"):
continue

# Strip trailing % directives unconditionally
lines = part.splitlines()
while lines and lines[-1].lstrip().startswith("%"):
Comment thread
zmoon marked this conversation as resolved.
lines.pop()
part = "\n".join(lines).strip()

if not part: # pragma: no cover
continue

if remove_prefs:
lines = [
line for line in part.splitlines() if not line.lstrip().startswith(remove_prefs)
]
part = "\n".join(lines).strip()

if part:
abcs.append(part)

return abcs
58 changes: 58 additions & 0 deletions tests/test_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
bill_black_tunefolders,
eskin,
examples,
hardy,
load_example,
load_example_abc,
load_url,
Expand Down Expand Up @@ -549,3 +550,60 @@ def test_the_session_consume_validation():
def test_the_session_consume_auto_leading_slash():
(d,) = the_session._consume("tunes/22878")
assert d["name"] == "Jack Farrell's"


@pytest.mark.parametrize("key", list(hardy._TUNEBOOK_KEY_TO_URL))
def test_hardy_load_meta(key):
abcs = hardy.load_meta(key)
assert isinstance(abcs, list)
assert len(abcs) > 0
for abc in abcs:
assert abc.startswith("X:")
assert any(line.startswith("T:") for line in abc.splitlines())
assert "\n\n" not in abc, "no empty lines within a tune block"


def test_hardy_load_meta_remove_prefs():
# Default: no % lines
key = "session"
abcs = hardy.load_meta(key)
abcs_single_pref_as_str = hardy.load_meta(key, remove_prefs="%")
assert abcs == abcs_single_pref_as_str
assert not any(line.lstrip().startswith("%") for abc in abcs for line in abc.splitlines())

# With remove_prefs=False: % lines preserved
abcs_raw = hardy.load_meta(key, remove_prefs=False)
assert sum(line.lstrip().startswith("%") for abc in abcs_raw for line in abc.splitlines()) > 1


def test_hardy_bad_key():
with pytest.raises(ValueError, match="Unknown Hardy tunebook key"):
_ = hardy.load_meta("asdf")


def test_hardy_download_bad_key():
with pytest.raises(ValueError, match="Unknown Hardy tunebook key"):
_ = hardy.download("asdf")


def test_hardy_annex_is_latest():
"""Confirm the hardcoded 'annex' URL points to the current (non-superseded) annex file."""
import requests

r = requests.get("https://pghardy.net/tunebooks/", timeout=10)
r.raise_for_status()
html = r.text

# The current annex abc link should appear outside the 'superceded' path
annex_url = hardy._TUNEBOOK_KEY_TO_URL["annex"]
annex_fn = annex_url.split("/")[-1] # e.g. 'pgh_annex_tunebook.abc'

# Find all abc links on the page
abc_links = re.findall(r'href="([^"]*\.abc)"', html)

# The non-superseded link matching our filename should exist
non_superseded = [lnk for lnk in abc_links if annex_fn in lnk and "superceded" not in lnk]
assert non_superseded, (
f"No non-superseded link found for {annex_fn!r} on the tunebooks page. "
f"The 'annex' key URL may need updating. Found links: {abc_links}"
)