diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bfadfed --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: uv run pytest diff --git a/pymusiclooper/analysis.py b/pymusiclooper/analysis.py index 6940db2..4745ae9 100644 --- a/pymusiclooper/analysis.py +++ b/pymusiclooper/analysis.py @@ -172,10 +172,6 @@ def find_best_loop_points( mlaudio, chroma, bpm, candidate_pairs, disable_pruning ) - # prefer longer loops for highly similar sequences - if len(filtered_candidate_pairs) > 1: - _prioritize_duration(filtered_candidate_pairs) - # Set the exact loop start and end in samples and adjust them # to the nearest zero crossing. Avoids audio popping/clicking while looping # as much as possible. @@ -203,6 +199,11 @@ def find_best_loop_points( f"No loop points found for {mlaudio.filename} with current parameters." ) + # prefer longer loops for highly similar sequences + # (must run after the loop positions in samples are set, since it compares loop durations) + if len(filtered_candidate_pairs) > 1: + _prioritize_duration(filtered_candidate_pairs) + logging.info( f"Filtered to {len(filtered_candidate_pairs)} best candidate loop points" ) @@ -522,8 +523,12 @@ def _calculate_subseq_beat_similarity( cosine_sim = dot_prod / (np.maximum(b1_norm * b2_norm, 1e-10)) if max_offset < test_length: + # Pad the missing frames on the side farthest from the loop point: + # after the tested frames when looking ahead, before them when looking behind + missing_frames = test_length - max_offset + pad_width = (missing_frames, 0) if test_end_offset < 0 else (0, missing_frames) return np.average( - np.pad(cosine_sim, pad_width=(0, test_length - max_offset), mode="constant", constant_values=0), + np.pad(cosine_sim, pad_width=pad_width, mode="constant", constant_values=0), weights=weights, ) else: diff --git a/pymusiclooper/audio.py b/pymusiclooper/audio.py index 138613b..1742e08 100644 --- a/pymusiclooper/audio.py +++ b/pymusiclooper/audio.py @@ -48,7 +48,8 @@ def __init__(self, filepath: str) -> None: raise AudioLoadError(f"\"{filepath}\" only contains silence and cannot be analyzed.") # Normalize audio channels to between -1.0 and +1.0 before analysis - mono_signal /= np.max(np.abs(mono_signal)) + # (not in-place: for mono input, to_mono returns raw_audio itself, which is also used for playback/export) + mono_signal = mono_signal / np.max(np.abs(mono_signal)) self.audio, self.trim_offset = librosa.effects.trim(mono_signal, top_db=40) self.trim_offset = self.trim_offset[0] diff --git a/pymusiclooper/core.py b/pymusiclooper/core.py index 82819d2..2ca953e 100644 --- a/pymusiclooper/core.py +++ b/pymusiclooper/core.py @@ -206,10 +206,12 @@ def extend( samples_to_fade = min( self.mlaudio.seconds_to_samples(fade_length), final_loop.shape[0] ) - final_loop[-samples_to_fade:] = ( - final_loop[-samples_to_fade:] - * np.linspace(1, 0, samples_to_fade)[:, np.newaxis] - ) + # Guard against x[-0:], which would select the whole section + if samples_to_fade > 0: + final_loop[-samples_to_fade:] = ( + final_loop[-samples_to_fade:] + * np.linspace(1, 0, samples_to_fade)[:, np.newaxis] + ) # Format extended file name with its duration suffixed extended_loop_length = final_loop.shape[0] + ( @@ -272,9 +274,10 @@ def export_txt( loop_end: Union[str, int, float, str], txt_name: str = "loops", output_dir: Optional[str] = None - ): - """Exports the given loop points to a text file named `loop.txt` in append mode with the format: + ) -> str: + """Exports the given loop points to a text file named `loops.txt` in append mode with the format: `{loop_start} {loop_end} {filename}` + Returns the path to the text file. Args: loop_start (Union[int, float, str]): Loop start in samples, seconds or ftime. @@ -290,6 +293,8 @@ def export_txt( with open(out_path, "a") as file: file.write(f"{loop_start} {loop_end} {self.mlaudio.filename}\n") + return out_path + def _find_start_tag( self, @@ -377,7 +382,7 @@ def export_tags( import taglib if output_dir is None: - output_dir = os.path.abspath(self.mlaudio.filepath) + output_dir = os.path.dirname(os.path.abspath(self.mlaudio.filepath)) track_name, file_extension = os.path.splitext(self.mlaudio.filename) diff --git a/pymusiclooper/handler.py b/pymusiclooper/handler.py index 2d4c148..e514d06 100644 --- a/pymusiclooper/handler.py +++ b/pymusiclooper/handler.py @@ -132,11 +132,11 @@ def get_user_input(): preview = False if num_input == "more": - self.interactive_handler(show_top=show_top * 2) + return self.interactive_handler(show_top=show_top * 2) if num_input == "all": - self.interactive_handler(show_top=total_candidates) + return self.interactive_handler(show_top=total_candidates) if num_input == "reset": - self.interactive_handler() + return self.interactive_handler() if num_input[-1] == "p": idx = int(num_input[:-1]) @@ -322,12 +322,11 @@ def txt_export_runner(self, loop_start: int, loop_end: int): if self.alt_export_top != 0: self.alt_export_runner(mode="TXT") else: - self.musiclooper.export_txt( + out_path = self.musiclooper.export_txt( self._fmt(loop_start), self._fmt(loop_end), output_dir=self.output_directory, ) - out_path = os.path.join(self.output_directory, "loop.txt") message = f'Successfully added "{self.musiclooper.filename}" loop points to "{out_path}"' if self.batch_mode: logging.info(message) diff --git a/pyproject.toml b/pyproject.toml index 9b37b98..1fc9414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,3 +64,11 @@ select = [ # isort "I", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef930f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +"""Shared fixtures: synthetic tracks whose correct loop points are known in advance. + +The test track is an intro followed by the same 8-note pattern repeated several times, +so any loop whose length is a whole number of patterns is seamless. +""" + +import numpy as np +import pytest +import soundfile as sf + +SR = 22050 +BEAT_SAMPLES = SR // 2 # 120 bpm +INTRO_NOTES = [50, 53, 57, 59] +PATTERN_NOTES = [60, 64, 67, 72, 69, 65, 62, 67] +PATTERN_REPEATS = 4 + +INTRO_SAMPLES = len(INTRO_NOTES) * BEAT_SAMPLES +PATTERN_SAMPLES = len(PATTERN_NOTES) * BEAT_SAMPLES + +# Loop points are located on ~23ms STFT frames, then nudged to a zero crossing (+/-5ms) +SAMPLE_TOLERANCE = int(0.03 * SR) + + +def _note(midi: int) -> np.ndarray: + t = np.arange(BEAT_SAMPLES) / SR + freq = 440.0 * 2 ** ((midi - 69) / 12) + tone = sum(np.sin(2 * np.pi * freq * h * t) / h for h in range(1, 5)) + attack = np.minimum(1.0, t / 0.005) + return tone * attack * np.exp(-4 * t) + + +def make_track() -> np.ndarray: + notes = INTRO_NOTES + PATTERN_NOTES * PATTERN_REPEATS + PATTERN_NOTES[:1] + y = np.concatenate([_note(n) for n in notes]) + return (0.5 * y / np.max(np.abs(y))).astype(np.float32) + + +@pytest.fixture(scope="session") +def track() -> np.ndarray: + return make_track() + + +@pytest.fixture(scope="session") +def audio_dir(tmp_path_factory): + return tmp_path_factory.mktemp("audio") + + +@pytest.fixture(scope="session") +def track_path(audio_dir, track): + path = audio_dir / "track.wav" + sf.write(path, track, SR, subtype="FLOAT") + return str(path) + + +@pytest.fixture(scope="session") +def stereo_track_path(audio_dir, track): + path = audio_dir / "stereo.wav" + sf.write(path, np.stack([track, 0.8 * track], axis=1), SR, subtype="FLOAT") + return str(path) + + +@pytest.fixture(scope="session") +def silent_path(audio_dir): + path = audio_dir / "silent.wav" + sf.write(path, np.zeros(SR, dtype=np.float32), SR) + return str(path) + + +@pytest.fixture +def flac_track_path(tmp_path, track): + """A fresh FLAC copy per test, since tagging tests write next to it.""" + path = tmp_path / "track.flac" + sf.write(path, track, SR, format="FLAC") + return str(path) + + +def assert_whole_patterns(loop_start: int, loop_end: int): + """Asserts that a loop spans a whole number of repeated patterns.""" + length = loop_end - loop_start + n_patterns = round(length / PATTERN_SAMPLES) + assert n_patterns >= 1, f"loop of {length} samples is shorter than one pattern" + assert abs(length - n_patterns * PATTERN_SAMPLES) <= SAMPLE_TOLERANCE, ( + f"loop of {length} samples is not a multiple of the {PATTERN_SAMPLES}-sample pattern" + ) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..37c34c2 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,174 @@ +import numpy as np +import pytest +from conftest import INTRO_SAMPLES, SAMPLE_TOLERANCE, SR, assert_whole_patterns + +from pymusiclooper import analysis +from pymusiclooper.analysis import ( + LoopPair, + _calculate_loop_score, + _calculate_subseq_beat_similarity, + _prioritize_duration, + _weights, + nearest_zero_crossing, +) +from pymusiclooper.core import MusicLooper +from pymusiclooper.exceptions import LoopNotFoundError + + +@pytest.fixture(scope="module") +def looper(track_path): + return MusicLooper(track_path) + + +@pytest.fixture(scope="module") +def pairs(looper): + return looper.find_loop_pairs() + + +# --- End-to-end loop detection --- + + +def test_best_loop_spans_whole_patterns(pairs): + best = pairs[0] + assert_whole_patterns(best.loop_start, best.loop_end) + assert best.loop_start >= INTRO_SAMPLES - SAMPLE_TOLERANCE, "loop should not start inside the intro" + + +def test_best_loop_scores_highly(pairs): + assert pairs[0].score > 0.95 + + +def test_pairs_are_sorted_ordered_and_in_bounds(looper, pairs): + scores = [pair.score for pair in pairs[1:]] + assert scores == sorted(scores, reverse=True) + for pair in pairs: + assert 0 <= pair.loop_start < pair.loop_end <= looper.mlaudio.length + + +def test_default_min_duration_is_35_percent_of_track(looper, pairs): + min_samples = 0.35 * looper.mlaudio.length + assert all(p.loop_end - p.loop_start >= min_samples - SAMPLE_TOLERANCE - SR for p in pairs) + + +def test_min_and_max_loop_duration_respected(looper): + pairs = looper.find_loop_pairs(min_loop_duration=5, max_loop_duration=9) + for pair in pairs: + seconds = (pair.loop_end - pair.loop_start) / SR + assert 5 - 0.05 <= seconds <= 9 + 0.05 + assert_whole_patterns(pairs[0].loop_start, pairs[0].loop_end) + + +def test_approx_loop_position_respected(looper): + pairs = looper.find_loop_pairs(approx_loop_start=6.0, approx_loop_end=14.0) + best = pairs[0] + assert abs(best.loop_start / SR - 6.0) <= 2.05 + assert abs(best.loop_end / SR - 14.0) <= 2.05 + assert_whole_patterns(best.loop_start, best.loop_end) + + +def test_disable_pruning_keeps_at_least_as_many_pairs(looper, pairs): + unpruned = looper.find_loop_pairs(disable_pruning=True) + assert len(unpruned) >= len(pairs) + + +def test_impossible_constraints_raise(looper): + with pytest.raises(LoopNotFoundError): + looper.find_loop_pairs(min_loop_duration=60) + + +def test_prioritize_duration_sees_real_loop_positions(monkeypatch, looper): + durations_seen = [] + original = analysis._prioritize_duration + + def spy(pair_list): + durations_seen.extend(p.loop_end - p.loop_start for p in pair_list) + return original(pair_list) + + monkeypatch.setattr(analysis, "_prioritize_duration", spy) + looper.find_loop_pairs() + + assert durations_seen, "_prioritize_duration was not called" + assert max(durations_seen) > 0 + + +# --- Scoring --- + + +def _pair(start, end, score, loudness=0.1): + return LoopPair(0, 0, note_distance=0.0, loudness_difference=loudness, score=score, loop_start=start, loop_end=end) + + +def test_prioritize_duration_prefers_longest_among_tied_scores(): + pairs = [_pair(0, 100, 1.0), _pair(0, 300, 1.0), _pair(0, 900, 0.5)] + _prioritize_duration(pairs) + assert pairs[0].loop_end == 300 + + +def test_prioritize_duration_keeps_clearly_better_score_first(): + pairs = [_pair(0, 100, 0.99), _pair(0, 300, 0.90)] + _prioritize_duration(pairs) + assert pairs[0].loop_end == 100 + + +def _random_chroma(n_frames=200, seed=0): + return np.random.default_rng(seed).random((12, n_frames)) + + +def test_identical_sequences_score_one(): + chroma = _random_chroma() + chroma[:, 100:150] = chroma[:, 20:70] + score = _calculate_loop_score(30, 110, chroma, test_duration=20, weights=_weights(20, start=5)) + assert score == pytest.approx(1.0) + + +def test_unrelated_sequences_score_lower(): + chroma = _random_chroma() + chroma[:, 100:150] = chroma[:, 20:70] + matching = _calculate_loop_score(30, 110, chroma, test_duration=20, weights=_weights(20, start=5)) + unrelated = _calculate_loop_score(30, 160, chroma, test_duration=20, weights=_weights(20, start=5)) + assert unrelated < matching + + +def test_truncated_lookbehind_weights_frames_nearest_the_loop_point(): + chroma = _random_chroma() + # Only 3 frames exist before b1=3; they match the 3 frames before b2=100 + chroma[:, 0:3] = chroma[:, 97:100] + weights = _weights(20, start=10)[::-1] # heaviest weight on the frame right before the loop point + + score = _calculate_subseq_beat_similarity(3, 100, chroma, -20, weights=weights) + + assert score == pytest.approx(weights[-3:].sum() / weights.sum()) + + +# --- Zero crossings --- + + +def _sine(freq=100.0, seconds=1.0, channels=1): + t = np.arange(int(seconds * SR)) / SR + y = np.sin(2 * np.pi * freq * t) + return np.tile(y[:, np.newaxis], (1, channels)) + + +def _assert_rising_zero_crossing(audio, idx): + assert abs(audio[idx, 0]) < 0.05 + assert audio[idx + 1, 0] > audio[idx, 0] + + +@pytest.mark.parametrize("channels", [1, 2]) +def test_zero_crossing_snaps_to_nearby_rising_crossing(channels): + audio = _sine(channels=channels) + idx = nearest_zero_crossing(audio, SR, 1000) + assert abs(idx - 1000) <= SR // 200 + _assert_rising_zero_crossing(audio, idx) + + +def test_zero_crossing_near_start_of_audio(): + audio = _sine() + idx = nearest_zero_crossing(audio, SR, 30) + assert 0 <= idx <= 30 + SR // 200 + _assert_rising_zero_crossing(audio, idx) + + +def test_zero_crossing_keeps_index_without_a_crossing(): + audio = np.full((SR, 1), 0.8) + assert nearest_zero_crossing(audio, SR, 1000) == 1000 diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..d8b714f --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,71 @@ +import numpy as np +import pytest +import soundfile as sf +from conftest import SR, make_track + +from pymusiclooper.audio import MLAudio +from pymusiclooper.exceptions import AudioLoadError + + +def test_loads_mono(track_path, track): + audio = MLAudio(track_path) + assert audio.rate == SR + assert audio.n_channels == 1 + assert audio.length == track.size + assert audio.playback_audio.shape == (track.size, 1) + assert audio.total_duration == pytest.approx(track.size / SR) + assert audio.filename == "track.wav" + + +def test_loads_stereo(stereo_track_path, track): + audio = MLAudio(stereo_track_path) + assert audio.n_channels == 2 + assert audio.playback_audio.shape == (track.size, 2) + + +def test_analysis_signal_is_normalized_mono(stereo_track_path): + audio = MLAudio(stereo_track_path) + assert audio.audio.ndim == 1 + assert np.max(np.abs(audio.audio)) == pytest.approx(1.0) + + +def test_playback_audio_is_untouched(track_path, track): + audio = MLAudio(track_path) + np.testing.assert_array_equal(audio.playback_audio[:, 0], track) + + +def test_silent_file_raises(silent_path): + with pytest.raises(AudioLoadError): + MLAudio(silent_path) + + +def test_non_audio_file_raises(tmp_path): + path = tmp_path / "notes.txt" + path.write_text("not audio") + with pytest.raises(AudioLoadError): + MLAudio(str(path)) + + +def test_leading_silence_is_trimmed_on_frame_boundary(tmp_path): + silence = np.zeros(SR + 1234, dtype=np.float32) + path = tmp_path / "padded.wav" + sf.write(path, np.concatenate([silence, make_track()]), SR, subtype="FLOAT") + + audio = MLAudio(str(path)) + + assert 0 < audio.trim_offset <= silence.size + # apply_trim_offset round-trips through frames, which is only exact + # because librosa trims on hop-length (512 sample) boundaries + assert audio.trim_offset % 512 == 0 + assert audio.frames_to_samples(audio.apply_trim_offset(10)) == ( + audio.frames_to_samples(10) + audio.trim_offset + ) + + +def test_time_conversions(track_path): + audio = MLAudio(track_path) + assert audio.seconds_to_samples(1) == SR + assert audio.samples_to_seconds(SR) == pytest.approx(1.0) + assert audio.frames_to_samples(1) == 512 + assert audio.samples_to_frames(1024) == 2 + assert audio.samples_to_ftime(int(61.5 * SR)) == "01:01.500" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a4765e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,93 @@ +import re +import shutil + +import pytest +from click.testing import CliRunner +from conftest import SR, assert_whole_patterns + +from pymusiclooper import __version__ +from pymusiclooper.cli import cli_main + + +@pytest.fixture +def run(): + def invoke(*args): + result = CliRunner().invoke(cli_main, [str(arg) for arg in args]) + assert result.exception is None or isinstance(result.exception, SystemExit), result.output + return result + return invoke + + +def test_version(run): + result = run("--version") + assert result.exit_code == 0 + assert __version__ in result.output + + +def test_path_is_required(run): + result = run("export-points") + assert result.exit_code != 0 + + +def test_export_points_to_stdout(run, track_path): + result = run("export-points", "--path", track_path) + + assert result.exit_code == 0 + loop_start = int(re.search(r"LOOP_START: (\d+)", result.output).group(1)) + loop_end = int(re.search(r"LOOP_END: (\d+)", result.output).group(1)) + assert_whole_patterns(loop_start, loop_end) + + +def test_export_points_in_seconds(run, track_path): + result = run("export-points", "--path", track_path, "--fmt", "SECONDS") + + loop_start = float(re.search(r"LOOP_START: ([\d.]+)", result.output).group(1)) + loop_end = float(re.search(r"LOOP_END: ([\d.]+)", result.output).group(1)) + assert_whole_patterns(round(loop_start * SR), round(loop_end * SR)) + + +def test_export_points_alt_export_top(run, track_path): + result = run("export-points", "--path", track_path, "--alt-export-top", "3") + + lines = result.output.strip().splitlines() + assert len(lines) == 3 + assert all(len(line.split()) == 5 for line in lines) + + +def test_export_points_to_txt(run, track_path, tmp_path): + run("export-points", "--path", track_path, "--export-to", "TXT", "--output-dir", tmp_path) + + lines = (tmp_path / "loops.txt").read_text().splitlines() + assert len(lines) == 1 + assert lines[0].endswith(" track.wav") + + +def test_split_audio(run, track_path, tmp_path): + run("split-audio", "--path", track_path, "--output-dir", tmp_path) + + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "track.wav-intro.wav", + "track.wav-loop.wav", + "track.wav-outro.wav", + ] + + +def test_extend(run, track_path, tmp_path): + run("extend", "--path", track_path, "--output-dir", tmp_path, "--extended-length", 45, "--format", "WAV") + + assert [p.name for p in tmp_path.iterdir()] == ["track.wav-extended-0m45s.wav"] + + +def test_batch_export_skips_non_audio_files(run, track_path, stereo_track_path, tmp_path): + in_dir = tmp_path / "in" + out_dir = tmp_path / "out" + in_dir.mkdir() + shutil.copy(track_path, in_dir) + shutil.copy(stereo_track_path, in_dir) + (in_dir / "notes.txt").write_text("not audio") + + result = run("export-points", "--path", in_dir, "--export-to", "TXT", "--output-dir", out_dir) + + assert result.exit_code == 0 + lines = (out_dir / "loops.txt").read_text().splitlines() + assert sorted(line.split()[-1] for line in lines) == ["stereo.wav", "track.wav"] diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..931fa60 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,114 @@ +import os + +import numpy as np +import pytest +import soundfile as sf +import taglib +from conftest import INTRO_SAMPLES, PATTERN_SAMPLES, SR + +from pymusiclooper.core import MusicLooper + +LOOP_START = INTRO_SAMPLES +LOOP_END = INTRO_SAMPLES + 2 * PATTERN_SAMPLES + + +@pytest.fixture(scope="module") +def looper(track_path): + return MusicLooper(track_path) + + +# --- Split / extend --- + + +def test_export_splits_into_intro_loop_outro(looper, track, tmp_path): + looper.export(LOOP_START, LOOP_END, format="WAV", output_dir=str(tmp_path)) + + sections = [ + sf.read(tmp_path / f"track.wav-{name}.wav")[0] for name in ("intro", "loop", "outro") + ] + + assert [s.shape[0] for s in sections] == [LOOP_START, LOOP_END - LOOP_START, track.size - LOOP_END] + np.testing.assert_allclose(np.concatenate(sections), looper.mlaudio.playback_audio[:, 0], atol=1e-4) + + +def test_extend_with_fade_out_reaches_requested_length(looper, tmp_path): + output_path = looper.extend(LOOP_START, LOOP_END, extended_length=60, fade_length=3, format="WAV", output_dir=str(tmp_path)) + + extended, rate = sf.read(output_path) + assert rate == SR + assert extended.shape[0] / SR == pytest.approx(60, abs=0.05) + assert np.max(np.abs(extended[-100:])) < 0.01, "track should fade out to silence" + assert os.path.basename(output_path) == "track.wav-extended-1m00s.wav" + + +def test_extend_without_fade_out_keeps_outro(looper, tmp_path): + output_path = looper.extend(LOOP_START, LOOP_END, extended_length=60, disable_fade_out=True, format="WAV", output_dir=str(tmp_path)) + + extended, _ = sf.read(output_path) + outro = looper.mlaudio.playback_audio[LOOP_END:, 0] + assert extended.shape[0] / SR >= 60 + np.testing.assert_allclose(extended[-outro.size:], outro, atol=1e-4) + + +def test_extend_shorter_than_track_raises(looper, tmp_path): + with pytest.raises(ValueError): + looper.extend(LOOP_START, LOOP_END, extended_length=5, output_dir=str(tmp_path)) + + +def test_extend_with_zero_fade_length(looper, tmp_path): + looper.extend(LOOP_START, LOOP_END, extended_length=60, fade_length=0, format="WAV", output_dir=str(tmp_path)) + + +# --- Loop point text export --- + + +def test_export_txt_appends_lines(looper, tmp_path): + looper.export_txt(LOOP_START, LOOP_END, output_dir=str(tmp_path)) + looper.export_txt(1, 2, output_dir=str(tmp_path)) + + lines = (tmp_path / "loops.txt").read_text().splitlines() + assert lines == [f"{LOOP_START} {LOOP_END} track.wav", "1 2 track.wav"] + + +# --- Metadata tags --- + + +@pytest.mark.parametrize( + ("start_tag", "end_tag", "stored_end"), + [ + ("LOOP_START", "LOOP_END", LOOP_END), + ("LOOPSTART", "LOOPLENGTH", LOOP_END - LOOP_START), + ], +) +def test_tags_roundtrip(flac_track_path, tmp_path, start_tag, end_tag, stored_end): + out_dir = tmp_path / "out" + out_dir.mkdir() + + written = MusicLooper(flac_track_path).export_tags(LOOP_START, LOOP_END, start_tag, end_tag, output_dir=str(out_dir)) + assert written == (str(LOOP_START), str(stored_end)) + + tagged = MusicLooper(str(out_dir / "track-tagged.flac")) + assert tagged.read_tags(start_tag, end_tag) == (LOOP_START, LOOP_END) + assert tagged.read_tags(None, None) == (LOOP_START, LOOP_END), "tags should be auto-detected" + + +def test_read_tags_without_loop_tags_raises(flac_track_path): + with pytest.raises(ValueError): + MusicLooper(flac_track_path).read_tags(None, None) + + +def test_export_tags_defaults_to_source_directory(flac_track_path, tmp_path): + MusicLooper(flac_track_path).export_tags(LOOP_START, LOOP_END, "LOOP_START", "LOOP_END") + assert (tmp_path / "track-tagged.flac").exists() + + +def test_extend_copies_source_tags(flac_track_path, tmp_path): + with taglib.File(flac_track_path, save_on_exit=True) as source: + source.tags["TITLE"] = ["Test Track"] + + output_path = MusicLooper(flac_track_path).extend( + LOOP_START, LOOP_END, extended_length=60, format="FLAC", output_dir=str(tmp_path) + ) + + with taglib.File(output_path) as extended: + assert extended.tags["TITLE"] == ["Test Track"] diff --git a/tests/test_handler.py b/tests/test_handler.py new file mode 100644 index 0000000..61a0ad8 --- /dev/null +++ b/tests/test_handler.py @@ -0,0 +1,80 @@ +import os + +import pytest + +from pymusiclooper import handler +from pymusiclooper.analysis import LoopPair +from pymusiclooper.core import MusicLooper +from pymusiclooper.handler import BatchHandler, LoopExportHandler, LoopHandler + + +def _bare_loop_handler(n_pairs=30): + """A LoopHandler with preset loop pairs, skipping audio loading and analysis.""" + loop_handler = LoopHandler.__new__(LoopHandler) + loop_handler.loop_pair_list = [ + LoopPair(0, 0, note_distance=0.0, loudness_difference=0.0, score=1 - i / 100, loop_start=i, loop_end=i + 1000) + for i in range(n_pairs) + ] + loop_handler.filepath = "track.wav" + loop_handler.in_samples = True + loop_handler._musiclooper = None + loop_handler._progressbar = None + return loop_handler + + +@pytest.fixture +def fake_input(monkeypatch): + """Feeds the given answers to the interactive prompt, in order.""" + def feed(*answers): + remaining = iter(answers) + monkeypatch.setattr(handler.rich_console, "input", lambda *args, **kwargs: next(remaining)) + return feed + + +def test_interactive_selects_entered_index(fake_input): + fake_input("3") + assert _bare_loop_handler().interactive_handler() == 3 + + +def test_interactive_reprompts_on_invalid_input(fake_input): + fake_input("abc", "", "99", "4") + assert _bare_loop_handler().interactive_handler() == 4 + + +def test_interactive_more_then_select(fake_input): + fake_input("more", "27", "1") + assert _bare_loop_handler().interactive_handler() == 27 + + +def test_choose_loop_pair_defaults_to_best(): + loop_handler = _bare_loop_handler() + assert loop_handler.choose_loop_pair(interactive_mode=False) is loop_handler.loop_pair_list[0] + + +def test_txt_export_message_names_written_file(monkeypatch, track_path, tmp_path): + export_handler = LoopExportHandler.__new__(LoopExportHandler) + export_handler._musiclooper = MusicLooper(track_path) + export_handler.output_directory = str(tmp_path) + export_handler.alt_export_top = 0 + export_handler.fmt = "samples" + export_handler.batch_mode = False + + messages = [] + monkeypatch.setattr(handler.rich_console, "print", lambda msg, *args, **kwargs: messages.append(msg)) + export_handler.txt_export_runner(100, 200) + + written = os.listdir(tmp_path) + assert written == ["loops.txt"] + assert str(tmp_path / "loops.txt") in messages[0] + + +def test_get_files_in_directory(tmp_path): + (tmp_path / "a.wav").touch() + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.wav").touch() + + flat = BatchHandler.get_files_in_directory(str(tmp_path)) + recursive = BatchHandler.get_files_in_directory(str(tmp_path), recursive=True) + + assert flat == [str(tmp_path / "a.wav")] + assert sorted(recursive) == sorted([str(tmp_path / "a.wav"), str(tmp_path / "sub" / "b.wav")]) diff --git a/uv.lock b/uv.lock index 784e524..6f288ca 100644 --- a/uv.lock +++ b/uv.lock @@ -324,6 +324,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -333,6 +345,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "joblib" version = "1.5.2" @@ -693,6 +714,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pooch" version = "1.8.2" @@ -745,6 +775,11 @@ dependencies = [ { name = "yt-dlp" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "click-option-group", specifier = ">=0.5.6,<0.6" }, @@ -761,6 +796,9 @@ requires-dist = [ { name = "yt-dlp", specifier = ">=2025.1.26" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "pytaglib" version = "3.0.1" @@ -789,6 +827,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/d9/a81226deee5f687edc5e914b7c0a4f1f2cfd1302fb265be37025cf9b352b/pytaglib-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:a65f310df7cd78e4218a7bb581da7775192fc81dfcb2612da1ed93091c8671b9", size = 256377, upload-time = "2025-03-21T22:32:37.669Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1122,6 +1178,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"