Skip to content
Open
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
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@ jobs:
python -m pip install flit
flit install -s

- name: Test with pytest
- name: Test with pytest (non-web)
if: ${{ matrix.python-version != '3.14' }}
run: >
pytest -v -ra
--doctest-modules
--cov=pyabc2 --cov-report xml --cov-report term-missing
-m "not web"
-n auto

- name: Test with pytest (full, including web)
if: ${{ matrix.python-version == '3.14' }}
run: >
pytest -v -ra
--doctest-modules
--cov=pyabc2 --cov-report xml --cov-report term-missing
-n auto --dist loadgroup

- name: Check type annotations
run: |
mypy --non-interactive .
Expand Down
4 changes: 4 additions & 0 deletions docs/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Release notes

## v0.1.4 (unreleased)

* Updates for Eskin and Bill Black ({pull}`112`)

## v0.1.3 (2026-04-17)

* Improve styling of abcjs containers, pandas dataframes, and ipywidgets
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/sources.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@
"metadata": {},
"outputs": [],
"source": [
"Tune(df.query(\"group == 'jigs'\").iloc[0].abc)"
"Tune(df.query(\"group == 'Jig A-G'\").iloc[0].abc)"
]
},
{
Expand Down
95 changes: 70 additions & 25 deletions pyabc2/sources/bill_black.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,60 +8,105 @@
* `requests <https://requests.readthedocs.io/>`__
"""

from __future__ import annotations

import functools
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING

from pyabc2._util import get_logger as _get_logger

if TYPE_CHECKING: # pragma: no cover
import requests

logger = _get_logger(__name__)

HERE = Path(__file__).parent

SAVE_TO = HERE / "_bill-black"
TXT_FNS = [
"a-tunes-1.txt",
"b-tunes-1.txt",
"c-tunes-1.txt",
"d-tunes-1.txt",
"e-tunes-1.txt",
"f-tunes-1.txt",
"g-tunes-1.txt",
"h-tunes-1.txt",
"i-tunes-1.txt",
"j-tunes-1.txt",
"k-tunes-1.txt",
"l-tunes-1.txt",
"m-tunes-1.txt",
"n-tunes-1.txt",
"o-tunes-1.txt",
"pq-tunes-1.txt",
"r-tunes-1.txt",
"s-tunes-2.rtf",
"t-tunes-1.txt",
"uv-tunes-1.txt",
"wz-tunes-1.txt",
"a-tunes.txt",
"b-tunes.txt",
"c-tunes.txt",
"d-tunes.txt",
"e-tunes.txt",
"f-tunes.txt",
"g-tunes.txt",
"h-tunes.txt",
"i-tunes.txt",
"j-tunes.txt",
"k-tunes.txt",
"L-tunes.txt",
Comment thread
zmoon marked this conversation as resolved.
"m-tunes.txt",
"n-tunes.txt",
"o-tunes.txt",
"pq-tunes.txt",
"r-tunes.txt",
"s-tunes.txt",
"t-tunes.txt",
"uv-tunes.txt",
"wxyz-tunes.txt",
]


@functools.lru_cache(1)
def _get_session() -> requests.Session:
return _build_session()


def _build_session() -> requests.Session:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

session = requests.Session()
session.headers.update({"User-Agent": "pyabc2"})
retries = Retry(
total=5,
backoff_factor=0.5,
backoff_jitter=0.5,
allowed_methods={"GET", "HEAD"},
status_forcelist=[403, 429, 500, 502, 503, 504],
# Bill Black seems to sporadically return 403 (forbidden)
# possibly to indicate a temporary server issue or throttling/anti-bot
)
session.mount("https://", HTTPAdapter(max_retries=retries))
Comment thread
zmoon marked this conversation as resolved.
session.mount("http://", HTTPAdapter(max_retries=retries))

return session


def download() -> None:
"""Download the alphabetical text files from https://www.capeirish.com/ittl/alltunes/text/
"""Download the alphabetical text files from https://www.capeirish.com/ittl/alltunes/alltunes-text/
and store them in a compressed archive.
"""
import threading
import zipfile
from concurrent.futures import ThreadPoolExecutor

import requests
thread_local = threading.local()

def get_worker_session() -> requests.Session:
# One Session per worker thread to avoid cross-thread Session sharing.
try:
return thread_local.session
except AttributeError:
session = _build_session()
thread_local.session = session
return session

def download_one(url):
r = requests.get(url, headers={"User-Agent": "pyabc2"}, timeout=5)
session = get_worker_session()
r = session.get(url, timeout=5)
r.raise_for_status()
return r.text

with ThreadPoolExecutor(max_workers=4) as executor:
futures = []
for fn in TXT_FNS:
url = f"https://www.capeirish.com/ittl/alltunes/text/{fn}"
url = f"https://www.capeirish.com/ittl/alltunes/alltunes-text/{fn}"
futures.append(executor.submit(download_one, url))

SAVE_TO.mkdir(exist_ok=True)
Expand Down
9 changes: 5 additions & 4 deletions pyabc2/sources/bill_black_tunefolders.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
https://www.capeirish.com/ittl/

As of the 2025-06-14 update, the "tunefolders" method is deprecated.
Bill Black is now using the Eskin ABC Tools (https://www.capeirish.com/ittl/alltunes/html/),
while also posting ABC text files (https://www.capeirish.com/ittl/alltunes/text/),
Bill Black is now using the Eskin ABC Tools (https://www.capeirish.com/ittl/alltunes/alltunes-html/),
while also posting ABC text files (https://www.capeirish.com/ittl/alltunes/alltunes-text/),
both split up alphabetically by tune name.

Requires:
Expand All @@ -19,6 +19,7 @@
from pathlib import Path

from pyabc2._util import get_logger as _get_logger
from pyabc2.sources.bill_black import _get_session

logger = _get_logger(__name__)

Expand Down Expand Up @@ -209,7 +210,7 @@ def get_collection(key: str) -> Collection:
def download(key: str | Iterable[str] | None = None) -> None:
import gzip

import requests
session = _get_session()

SAVE_TO.mkdir(exist_ok=True)

Expand All @@ -224,7 +225,7 @@ def download(key: str | Iterable[str] | None = None) -> None:
for url in collection.abc_urls:
p = collection.url_to_file(url)
logger.info(f"Downloading {url} to {p.relative_to(HERE).as_posix()}")
r = requests.get(url, headers={"User-Agent": "pyabc2"}, timeout=5)
r = session.get(url, timeout=5)
r.raise_for_status()

# Extract filename from URL and append .gz
Expand Down
Loading