Pure move, no behaviour change. web.py 1221 -> 807 lines.
The reason is not file size but isolation of change. CLAUDE.md records
that the next step for detection, should flagging ever need improving, is
a spectral filter or an optional Silero VAD over candidate sections —
that work now lands entirely in isrweb/loudness.py and never has to open
the HTTP layer. The byte-level format code (FLAC frame parsing, CRC-8,
live header patching) is a second self-contained cluster, touched only
when a container format misbehaves.
isrweb/loudness.py 229 detector + its tuning constants
isrweb/audiofile.py 250 filename clock, WAV/FLAC headers, MIME types
web.py 807 cache, listing, handler, main()
Import direction is strictly web.py -> {loudness, audiofile}; the two
leaves import neither each other nor web.py, which is what lets the
detector be imported and tested without an HTTP server.
A package rather than flat modules because `audiofile` is a real PyPI
name and `detect` is generic enough to collide; a local file silently
shadows an installed one, which fails confusingly rather than loudly.
web.py stays at the repo root so `python web.py`, the docker-compose
command and the webui.html lookup are all unchanged.
Each module keeps its own optional numpy/soundfile try-import rather than
sharing one. That block cannot meaningfully diverge — if numpy is
installed every copy says True — and sharing it would stop isr.py being a
standalone file you can drop on a Pi and run.
Tests mirror the modules: test_loudness.py, test_audiofile.py, test_web.py
(+ unchanged test_isr.py). New tests/conftest.py owns the sys.path setup
that was previously an inline hack in test_isr.py — which only worked for
the other modules because that file happened to import first — and
provides the write_wav fixture the split would otherwise have duplicated.
Verified:
* 78 passed, same count as before the split; each test file also passes
standalone.
* All 21 moved functions diffed against HEAD:web.py — byte-identical.
* Import graph asserted acyclic; both leaves import with `web` absent
from sys.modules.
* An analysis cache written *before* the split still hits afterwards,
with identical sections — the one way a "pure" move could quietly
invalidate every user's cache.
* Endpoint smoke (analyze bounds, cache hit, cached_analysis in the
listing, clip Content-Length, 8-bit rejection) and the byte-serving
harness (ranges, download Range, HEAD, 405, live-header patching whole
and straddling) both re-run green.
* Dockerfile COPY set verified by building the image's file tree in a
temp dir and importing web.py with nothing else present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
2.6 KiB
Python
78 lines
2.6 KiB
Python
"""Tests for the HTTP layer and file listing in web.py."""
|
|
|
|
import io
|
|
|
|
import web
|
|
|
|
|
|
def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path, write_wav):
|
|
# mtime is the last write (≈ the end of the recording) and drifts from the
|
|
# name on any copy or round-trip. Here the older recording is touched last.
|
|
import os
|
|
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
|
|
write_wav(early)
|
|
write_wav(late)
|
|
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
|
|
|
|
names = [f['name'] for f in web.list_files(str(tmp_path))]
|
|
assert names == ['20260523_090000.wav', '20260523_080000.wav']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/clip error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _OfflineHandler(web._Handler):
|
|
"""A _Handler with no socket, for driving handler methods directly."""
|
|
|
|
def __init__(self, recordings_dir):
|
|
self.recordings_dir = str(recordings_dir)
|
|
self.analyses_dir = str(recordings_dir)
|
|
self.command = 'GET' # _send skips the body on HEAD
|
|
self.wfile = io.BytesIO()
|
|
self.close_connection = False
|
|
self.codes = []
|
|
|
|
def send_response(self, code):
|
|
self.codes.append(code)
|
|
|
|
def send_header(self, key, value):
|
|
pass
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path, write_wav):
|
|
# Once the audio headers are out, an error body would be a second response
|
|
# on a keep-alive connection that already promised audio/wav plus an exact
|
|
# Content-Length — desyncing every later request on that connection.
|
|
write_wav(tmp_path / 'rec.wav')
|
|
h = _OfflineHandler(tmp_path)
|
|
|
|
def boom(path, start, end):
|
|
h._send_clip_headers(b'HDR', 10, 1, 2)
|
|
raise RuntimeError('decode exploded')
|
|
|
|
h._clip_wav = boom
|
|
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
|
|
|
|
assert h.close_connection is True
|
|
assert h.codes == [200] # no second status line
|
|
assert b'error' not in h.wfile.getvalue() # no JSON body
|
|
|
|
|
|
def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
|
|
write_wav(tmp_path / 'rec.wav')
|
|
h = _OfflineHandler(tmp_path)
|
|
|
|
def boom(path, start, end):
|
|
raise RuntimeError('could not open')
|
|
|
|
h._clip_wav = boom
|
|
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
|
|
|
|
assert h.close_connection is False
|
|
assert h.codes == [500]
|
|
assert b'could not open' in h.wfile.getvalue()
|