Skip to content

Commit

Permalink
Merge daaa3cf into 81f1b99
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiascode committed Feb 29, 2024
2 parents 81f1b99 + daaa3cf commit 2d8e56a
Show file tree
Hide file tree
Showing 8 changed files with 377 additions and 331 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-20.04, macos-latest, windows-latest]
python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10']
python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10']
steps:
- name: Checkout code
uses: actions/checkout@v3
Expand All @@ -30,6 +30,12 @@ jobs:
- name: Linting
run: python -m pylint --recursive=y .

- name: Typing
if: matrix.python != 'pypy-3.7'
run: |
python -m pip install mypy
python -m mypy -p tinytag
- name: Unit tests
run: python -m pytest --cov
env:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ tinytag is a library for reading music meta data of most common audio files in p
* WMA
* AIFF / AIFF-C
* Pure Python, no dependencies
* Supports Python 3.6 or higher
* Supports Python 3.7 or higher
* High test coverage
* Just a few hundred lines of code (just include it in your project!)

Expand Down
3 changes: 2 additions & 1 deletion release.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import sys


def release_package():
def release_package() -> None:
# Run tests
subprocess.check_call([sys.executable, "-m", "pycodestyle"])
subprocess.check_call([sys.executable, "-m", "pylint", "--recursive=y", "."])
subprocess.check_call([sys.executable, "-m", "mypy", "-p", "tinytag"])
subprocess.check_call([sys.executable, "-m", "pytest"])

# Prepare source distribution and wheel
Expand Down
8 changes: 5 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ keywords =
classifiers =
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Expand All @@ -33,7 +32,7 @@ long_description = file: README.md
long_description_content_type = text/markdown

[options]
python_requires = >= 3.6
python_requires = >= 3.7
include_package_data = True
packages = find:
install_requires =
Expand Down Expand Up @@ -74,7 +73,10 @@ load-plugins =
pylint.extensions.set_membership,
pylint.extensions.typing
ignore-paths = build
py-version = 3.6
py-version = 3.7

[pylint.format]
max-line-length = 100

[mypy]
strict = True
25 changes: 13 additions & 12 deletions tinytag/__main__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# pylint: disable=missing-module-docstring,protected-access

from __future__ import annotations
from os.path import splitext
import json
import os
import sys

from tinytag.tinytag import TinyTag
from tinytag.tinytag import TinyTag, TinyTagException


def _usage():
def _usage() -> None:
print('''tinytag [options] <filename...>
-h, --help
Expand All @@ -26,23 +27,23 @@ def _usage():
''')


def _pop_param(name, _default):
def _pop_param(name: str, _default: str | None) -> str | None:
if name in sys.argv:
idx = sys.argv.index(name)
sys.argv.pop(idx)
return sys.argv.pop(idx)
return _default


def _pop_switch(name, _default):
def _pop_switch(name: str) -> bool:
if name in sys.argv:
idx = sys.argv.index(name)
sys.argv.pop(idx)
return True
return False


def _print_tag(tag, formatting, header_printed=False):
def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool:
data = {'filename': tag._filename}
data.update(tag._as_dict())
if formatting == 'json':
Expand All @@ -52,25 +53,25 @@ def _print_tag(tag, formatting, header_printed=False):
if isinstance(value, str):
data[field] = value.replace('\x00', ';') # use a more friendly separator for output
if formatting == 'csv':
print('\n'.join(f'{field},{value}' for field, value in data.items()))
print('\n'.join(f'{field},{value!r}' for field, value in data.items()))
elif formatting == 'tsv':
print('\n'.join(f'{field}\t{value}' for field, value in data.items()))
print('\n'.join(f'{field}\t{value!r}' for field, value in data.items()))
elif formatting == 'tabularcsv':
if not header_printed:
print(','.join(field for field, value in data.items()))
header_printed = True
print(','.join(f'"{value}"' for field, value in data.items()))
print(','.join(f'"{value!r}"' for field, value in data.items()))
return header_printed


def _run():
display_help = _pop_switch('--help', False) or _pop_switch('-h', False)
def _run() -> int:
display_help = _pop_switch('--help') or _pop_switch('-h')
if display_help:
_usage()
return 0
save_image_path = _pop_param('--save-image', None) or _pop_param('-i', None)
formatting = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json'
skip_unsupported = _pop_switch('--skip-unsupported', False) or _pop_switch('-s', False)
skip_unsupported = _pop_switch('--skip-unsupported') or _pop_switch('-s')
filenames = sys.argv[1:]
header_printed = False

Expand All @@ -90,7 +91,7 @@ def _run():
with open(actual_save_image_path, 'wb') as file_handle:
file_handle.write(image)
header_printed = _print_tag(tag, formatting, header_printed)
except Exception as exc: # pylint: disable=broad-except
except (OSError, TinyTagException) as exc:
sys.stderr.write(f'{filename}: {exc}\n')
return 1
return 0
Expand Down
74 changes: 44 additions & 30 deletions tinytag/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
# pylint: disable=missing-function-docstring,missing-module-docstring,protected-access


from __future__ import annotations
from typing import Any

import io
import os
import pathlib
Expand Down Expand Up @@ -541,7 +544,7 @@
testfolder = os.path.join(os.path.dirname(__file__))


def load_custom_samples():
def load_custom_samples() -> dict[str, dict[str, Any]]:
retval = {}
custom_samples_folder = os.path.join(testfolder, 'custom_samples')
pattern_field_name_type = [
Expand Down Expand Up @@ -575,15 +578,19 @@ def load_custom_samples():
testfiles.update(load_custom_samples())


def compare_tag(results, expected, file, prev_path=None):
def compare_values(path, result_val, expected_val):
if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture
def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str, Any]],
file: str, prev_path: str | None = None) -> None:
def compare_values(path: str, result_val: int | float | str | dict[str, Any],
expected_val: int | float | str | dict[str, Any]) -> bool:
# lets not copy *all* the lyrics inside the fixture
if (path == 'extra.lyrics'
and isinstance(expected_val, str) and isinstance(result_val, str)):
return result_val.startswith(expected_val)
if isinstance(expected_val, float):
return result_val == pytest.approx(expected_val)
return result_val == expected_val

def error_fmt(value):
def error_fmt(value: int | float | str | dict[str, Any]) -> str:
return f'{repr(value)} ({type(value)})'

assert isinstance(results, dict)
Expand All @@ -603,7 +610,7 @@ def error_fmt(value):


@pytest.mark.parametrize("testfile,expected", testfiles.items())
def test_file_reading_tags(testfile, expected):
def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None:
filename = os.path.join(testfolder, testfile)
tag = TinyTag.get(filename, tags=True)
results = {
Expand All @@ -614,7 +621,7 @@ def test_file_reading_tags(testfile, expected):


@pytest.mark.parametrize("testfile,expected", testfiles.items())
def test_file_reading_no_tags(testfile, expected):
def test_file_reading_no_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None:
filename = os.path.join(testfolder, testfile)
allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"}
tag = TinyTag.get(filename, tags=False)
Expand All @@ -629,14 +636,14 @@ def test_file_reading_no_tags(testfile, expected):
assert tag._image_data is None


def test_pathlib_compatibility():
def test_pathlib_compatibility() -> None:
testfile = next(iter(testfiles.keys()))
filename = pathlib.Path(testfolder) / testfile
TinyTag.get(filename)
assert TinyTag.is_supported(filename)


def test_file_obj_compatibility():
def test_file_obj_compatibility() -> None:
testfile = next(iter(testfiles.keys()))
filename = os.path.join(testfolder, testfile)
with open(filename, 'rb') as file_handle:
Expand All @@ -647,9 +654,9 @@ def test_file_obj_compatibility():


@pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths')
def test_binary_path_compatibility():
def test_binary_path_compatibility() -> None:
binary_file_path = os.path.join(os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3')
testfile = os.path.join(testfolder, next(iter(testfiles.keys())))
testfile = os.path.join(testfolder, next(iter(testfiles.keys()))).encode('utf-8')
shutil.copy(testfile, binary_file_path)
assert os.path.exists(binary_file_path)
TinyTag.get(binary_file_path)
Expand All @@ -658,33 +665,40 @@ def test_binary_path_compatibility():


@pytest.mark.xfail(raises=TinyTagException)
def test_unsupported_extension():
def test_unsupported_extension() -> None:
bogus_file = os.path.join(testfolder, 'samples/there_is_no_such_ext.bogus')
TinyTag.get(bogus_file)


def test_override_encoding():
def test_override_encoding() -> None:
chinese_id3 = os.path.join(testfolder, 'samples/chinese_id3.mp3')
tag = TinyTag.get(chinese_id3, encoding='gbk')
assert tag.artist == '苏云'
assert tag.album == '角落之歌'


@pytest.mark.xfail(raises=TinyTagException)
def test_unsubclassed_tinytag_load() -> None:
tag = TinyTag()
tag._load(tags=True, duration=True)


@pytest.mark.xfail(raises=NotImplementedError)
def test_unsubclassed_tinytag_duration():
tag = TinyTag(None, 0)
tag._determine_duration(None)
def test_unsubclassed_tinytag_duration() -> None:
tag = TinyTag()
tag._determine_duration(None) # type: ignore


@pytest.mark.xfail(raises=NotImplementedError)
def test_unsubclassed_tinytag_parse_tag():
tag = TinyTag(None, 0)
tag._parse_tag(None)
def test_unsubclassed_tinytag_parse_tag() -> None:
tag = TinyTag()
tag._parse_tag(None) # type: ignore


def test_mp3_length_estimation():
def test_mp3_length_estimation() -> None:
_ID3._MAX_ESTIMATION_SEC = 0.7
tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3'))
assert tag.duration is not None
assert 3.5 < tag.duration < 4.0


Expand All @@ -697,7 +711,7 @@ def test_mp3_length_estimation():
('samples/ilbm.aiff', _Aiff),
])
@pytest.mark.xfail(raises=TinyTagException)
def test_invalid_file(path, cls):
def test_invalid_file(path: str, cls: type[TinyTag]) -> None:
cls.get(os.path.join(testfolder, path))


Expand All @@ -713,23 +727,23 @@ def test_invalid_file(path, cls):
('samples/wav_with_image.wav', 4627),
('samples/aiff_with_image.aiff', 21963),
])
def test_image_loading(path, expected_size):
def test_image_loading(path: str, expected_size: int) -> None:
tag = TinyTag.get(os.path.join(testfolder, path), image=True)
image_data = tag.get_image()
image_size = len(image_data)
assert image_data is not None
image_size = len(image_data)
assert image_size == expected_size, \
f'Image is {image_size} bytes but should be {expected_size} bytes'
assert image_data.startswith(b'\xff\xd8\xff\xe0'), \
'The image data must start with a jpeg header'


@pytest.mark.xfail(raises=TinyTagException)
def test_mp3_utf_8_invalid_string_raises_exception():
def test_mp3_utf_8_invalid_string_raises_exception() -> None:
TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'))


def test_mp3_utf_8_invalid_string_can_be_ignored():
def test_mp3_utf_8_invalid_string_can_be_ignored() -> None:
tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'),
ignore_errors=True)
# the title used to be Gran dia, but I replaced the first byte with 0xFF,
Expand All @@ -749,21 +763,21 @@ def test_mp3_utf_8_invalid_string_can_be_ignored():
('samples/detect_mp4_m4a.x', _MP4),
('samples/detect_aiff.x', _Aiff),
])
def test_detect_magic_headers(testfile, expected):
def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None:
filename = os.path.join(testfolder, testfile)
with open(filename, 'rb') as file_handle:
parser = TinyTag._get_parser_class(filename, file_handle)
assert parser == expected


def test_show_hint_for_wrong_usage():
def test_show_hint_for_wrong_usage() -> None:
with pytest.raises(TinyTagException) as exc_info:
TinyTag('filename.mp3', 0)
TinyTag.get()
assert exc_info.type == TinyTagException
assert exc_info.value.args[0] == 'Use `TinyTag.get(filepath)` instead of `TinyTag(filepath)`'
assert exc_info.value.args[0] == 'Either filename or file_obj argument is required'


def test_to_str():
def test_to_str() -> None:
tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3'))
assert str(tag) == (
"{'album': 'Hymns for the Exiled', 'albumartist': None, 'artist': 'Anais Mitchell', "
Expand Down

0 comments on commit 2d8e56a

Please sign in to comment.