diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ad26484..16d64d5 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -54,3 +54,23 @@ jobs: run: pip install --editable . --verbose - name: Run tests run: python -m pytest + + type-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Install requirements + run: pip install numpy pytest pyright + - name: "Workaround: Generate _soundfile.py explicitly" + run: | + pip install cffi>=1.0 + python soundfile_build.py + - name: Install editable package + run: pip install --editable . --verbose + - name: Run type check + run: python -m pyright soundfile.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 3722022..2ebf61f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -14,7 +14,7 @@ regressions), if you add a feature, you should add tests for it as well. Set up local environment with the following commands:: - pip install numpy pytest "cffi>=1.0" + pip install numpy pytest "cffi>=1.0" typing-extensions python soundfile_build.py To run the tests, use:: @@ -35,6 +35,30 @@ This uses pytest_; .. _known problem: http://www.mega-nerd.com/libsndfile/api.html#open_fd +Type Checking +^^^^^^^^^^^^^ + +Type hints have been added to the codebase to support static type checking. +You can use pyright to check the types: + +.. code-block:: bash + + pip install pyright + pyright soundfile.py + +Or you can use the VS Code extension for inline type checking. + +When contributing, please maintain type hints for all public functions, methods, and classes. +Make sure to use appropriate types from the typing and typing-extensions modules. + +The following conventions are used: + +- Use Literal types for enumerated string values +- Use TypeAlias for complex type definitions +- Use overloads to provide precise return type information +- Use Optional for parameters that can be None +- Use Union for values that can be different types + Coverage ^^^^^^^^ diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..a78bcb0 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,9 @@ +{ + "pythonVersion": "3.9", + "exclude": [ + "**/node_modules", + "**/__pycache__", + "**/.venv", + "tests/" + ] +} diff --git a/setup.py b/setup.py index 1e94f44..5951972 100644 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def get_tag(self): zip_safe=zip_safe, license='BSD 3-Clause License', setup_requires=["cffi>=1.0"], - install_requires=['cffi>=1.0', 'numpy'], + install_requires=['cffi>=1.0', 'numpy', 'typing-extensions'], cffi_modules=["soundfile_build.py:ffibuilder"], extras_require={'numpy': []}, # This option is no longer relevant, but the empty entry must be left in to avoid breaking old build scripts. platforms='any', diff --git a/soundfile.py b/soundfile.py index 61db56c..f17b3cc 100644 --- a/soundfile.py +++ b/soundfile.py @@ -12,17 +12,28 @@ import os as _os import sys as _sys +import numpy.typing from os import SEEK_SET, SEEK_CUR, SEEK_END from ctypes.util import find_library as _find_library +from typing import Any, BinaryIO, Dict, Generator, Optional, Tuple, Union +from typing_extensions import TypeAlias, Self, Final from _soundfile import ffi as _ffi try: - _unicode = unicode # doesn't exist in Python 3.x + _unicode = unicode # type: ignore # doesn't exist in Python 3.x except NameError: _unicode = str +# Type aliases for specific types +if _sys.version_info >= (3, 9): + FileDescriptorOrPath: TypeAlias = Union[str, int, BinaryIO, _os.PathLike[Any]] +else: + FileDescriptorOrPath: TypeAlias = Union[str, int, BinaryIO, _os.PathLike] +AudioData: TypeAlias = numpy.typing.NDArray[Any] +_snd: Any +_ffi: Any -_str_types = { +_str_types: Final[Dict[str, int]] = { 'title': 0x01, 'copyright': 0x02, 'software': 0x03, @@ -35,7 +46,7 @@ 'genre': 0x10, } -_formats = { +_formats: Final[Dict[str, int]] = { 'WAV': 0x010000, # Microsoft WAV format (little endian default). 'AIFF': 0x020000, # Apple/SGI AIFF format (big endian). 'AU': 0x030000, # Sun/NeXT AU format (big endian). @@ -64,7 +75,7 @@ 'MP3': 0x230000, # MPEG-1/2 audio stream } -_subtypes = { +_subtypes: Final[Dict[str, int]] = { 'PCM_S8': 0x0001, # Signed 8 bit data 'PCM_16': 0x0002, # Signed 16 bit data 'PCM_24': 0x0003, # Signed 24 bit data @@ -101,7 +112,7 @@ 'MPEG_LAYER_III': 0x0082, # MPEG-2 Audio Layer III. } -_endians = { +_endians: Final[Dict[str, int]] = { 'FILE': 0x00000000, # Default file endian-ness. 'LITTLE': 0x10000000, # Force little endian-ness. 'BIG': 0x20000000, # Force big endian-ness. @@ -109,7 +120,7 @@ } # libsndfile doesn't specify default subtypes, these are somehow arbitrary: -_default_subtypes = { +_default_subtypes: Final[Dict[str, str]] = { 'WAV': 'PCM_16', 'AIFF': 'PCM_16', 'AU': 'PCM_16', @@ -138,14 +149,14 @@ 'MP3': 'MPEG_LAYER_III', } -_ffi_types = { +_ffi_types: Final[Dict[str, str]] = { 'float64': 'double', 'float32': 'float', 'int32': 'int', 'int16': 'short' } -_bitrate_modes = { +_bitrate_modes: Final[Dict[str, int]] = { 'CONSTANT': 0, 'AVERAGE': 1, 'VARIABLE': 2, @@ -216,9 +227,14 @@ __libsndfile_version__ = __libsndfile_version__[len('libsndfile-'):] -def read(file, frames=-1, start=0, stop=None, dtype='float64', always_2d=False, - fill_value=None, out=None, samplerate=None, channels=None, - format=None, subtype=None, endian=None, closefd=True): + +def read(file: FileDescriptorOrPath, frames: int = -1, start: int = 0, stop: Optional[int] = None, + dtype: str = 'float64', always_2d: bool = False, + fill_value: Optional[float] = None, out: Optional[AudioData] = None, + samplerate: Optional[int] = None, channels: Optional[int] = None, + format: Optional[str] = None, subtype: Optional[str] = None, + endian: Optional[str] = None, closefd: bool = True) -> Tuple[AudioData, int]: + """Provide audio data from a sound file as NumPy array. By default, the whole file is read from the beginning, but the @@ -309,8 +325,12 @@ def read(file, frames=-1, start=0, stop=None, dtype='float64', always_2d=False, return data, f.samplerate -def write(file, data, samplerate, subtype=None, endian=None, format=None, - closefd=True, compression_level=None, bitrate_mode=None): + +def write(file: FileDescriptorOrPath, data: AudioData, samplerate: int, + subtype: Optional[str] = None, endian: Optional[str] = None, + format: Optional[str] = None, closefd: bool = True, + compression_level: Optional[float] = None, + bitrate_mode: Optional[str] = None) -> None: """Write data to a sound file. .. note:: If *file* exists, it will be truncated and overwritten! @@ -366,10 +386,14 @@ def write(file, data, samplerate, subtype=None, endian=None, format=None, f.write(data) -def blocks(file, blocksize=None, overlap=0, frames=-1, start=0, stop=None, - dtype='float64', always_2d=False, fill_value=None, out=None, - samplerate=None, channels=None, - format=None, subtype=None, endian=None, closefd=True): +def blocks(file: FileDescriptorOrPath, blocksize: Optional[int] = None, + overlap: int = 0, frames: int = -1, start: int = 0, + stop: Optional[int] = None, dtype: str = 'float64', + always_2d: bool = False, fill_value: Optional[float] = None, + out: Optional[AudioData] = None, samplerate: Optional[int] = None, + channels: Optional[int] = None, format: Optional[str] = None, + subtype: Optional[str] = None, endian: Optional[str] = None, + closefd: bool = True) -> Generator[AudioData, None, None]: """Return a generator for block-wise reading. By default, iteration starts at the beginning and stops at the end @@ -420,8 +444,7 @@ def blocks(file, blocksize=None, overlap=0, frames=-1, start=0, stop=None, with SoundFile(file, 'r', samplerate, channels, subtype, endian, format, closefd) as f: frames = f._prepare_read(start, stop, frames) - for block in f.blocks(blocksize, overlap, frames, - dtype, always_2d, fill_value, out): + for block in f.blocks(blocksize, overlap, frames, dtype, always_2d, fill_value, out): yield block @@ -477,7 +500,7 @@ def __repr__(self): return info.format(self, indented_extra_info) -def info(file, verbose=False): +def info(file: FileDescriptorOrPath, verbose: bool = False) -> _SoundFileInfo: """Returns an object with information about a `SoundFile`. Parameters @@ -488,7 +511,7 @@ def info(file, verbose=False): return _SoundFileInfo(file, verbose) -def available_formats(): +def available_formats() -> Dict[str, str]: """Return a dictionary of available major formats. Examples @@ -509,7 +532,7 @@ def available_formats(): _snd.SFC_GET_FORMAT_MAJOR)) -def available_subtypes(format=None): +def available_subtypes(format: Optional[str] = None) -> Dict[str, str]: """Return a dictionary of available subtypes. Parameters @@ -532,7 +555,8 @@ def available_subtypes(format=None): if format is None or check_format(format, subtype)) -def check_format(format, subtype=None, endian=None): +def check_format(format: str, subtype: Optional[str] = None, + endian: Optional[str] = None) -> bool: """Check if the combination of format/subtype/endian is valid. Examples @@ -550,7 +574,7 @@ def check_format(format, subtype=None, endian=None): return False -def default_subtype(format): +def default_subtype(format: str) -> Optional[str]: """Return the default subtype for a given format. Examples @@ -574,9 +598,12 @@ class SoundFile(object): """ - def __init__(self, file, mode='r', samplerate=None, channels=None, - subtype=None, endian=None, format=None, closefd=True, - compression_level=None, bitrate_mode=None): + def __init__(self, file: FileDescriptorOrPath, mode: Optional[str] = 'r', + samplerate: Optional[int] = None, channels: Optional[int] = None, + subtype: Optional[str] = None, endian: Optional[str] = None, + format: Optional[str] = None, closefd: bool = True, + compression_level: Optional[float] = None, + bitrate_mode: Optional[str] = None) -> None: """Open a sound file. If a file is opened with `mode` ``'r'`` (the default) or @@ -675,12 +702,13 @@ def __init__(self, file, mode='r', samplerate=None, channels=None, >>> assert myfile.closed """ - # resolve PathLike objects (see PEP519 for details): - # can be replaced with _os.fspath(file) for Python >= 3.6 - file = file.__fspath__() if hasattr(file, '__fspath__') else file + if isinstance(file, _os.PathLike): + file = _os.fspath(file) self._name = file if mode is None: mode = getattr(file, 'mode', None) + if mode is None: + raise TypeError("Can not get `mode` from file. provided `mode` is None.") # Raises ValueError explicitly for type checking. mode_int = _check_mode(mode) self._mode = mode self._compression_level = compression_level @@ -750,7 +778,7 @@ def extra_info(self): # avoid confusion if something goes wrong before assigning self._file: _file = None - def __repr__(self): + def __repr__(self) -> str: compression_setting = (", compression_level={0}".format(self.compression_level) if self.compression_level is not None else "") compression_setting += (", bitrate_mode='{0}'".format(self.bitrate_mode) @@ -760,16 +788,16 @@ def __repr__(self): "format={0.format!r}, subtype={0.subtype!r}, " "endian={0.endian!r}{1})".format(self, compression_setting)) - def __del__(self): + def __del__(self) -> None: self.close() - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, *args): + def __exit__(self, *args: Any) -> None: self.close() - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: Any) -> None: """Write text meta-data in the sound file through properties.""" if name in _str_types: self._check_if_closed() @@ -779,7 +807,7 @@ def __setattr__(self, name, value): else: object.__setattr__(self, name, value) - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: """Read text meta-data in the sound file through properties.""" if name in _str_types: self._check_if_closed() @@ -789,26 +817,26 @@ def __getattr__(self, name): raise AttributeError( "'SoundFile' object has no attribute {0!r}".format(name)) - def __len__(self): + def __len__(self) -> int: # Note: This is deprecated and will be removed at some point, # see https://github.com/bastibe/python-soundfile/issues/199 return self._info.frames - def __bool__(self): + def __bool__(self) -> bool: # Note: This is temporary until __len__ is removed, afterwards it # can (and should) be removed without change of behavior return True - def __nonzero__(self): + def __nonzero__(self) -> bool: # Note: This is only for compatibility with Python 2 and it shall be # removed at the same time as __bool__(). return self.__bool__() - def seekable(self): + def seekable(self) -> bool: """Return True if the file supports seeking.""" return self._info.seekable == _snd.SF_TRUE - def seek(self, frames, whence=SEEK_SET): + def seek(self, frames: int, whence: int = SEEK_SET) -> int: """Set the read/write position. Parameters @@ -849,12 +877,14 @@ def seek(self, frames, whence=SEEK_SET): _error_check(self._errorcode) return position - def tell(self): + def tell(self) -> int: """Return the current read/write position.""" return self.seek(0, SEEK_CUR) - def read(self, frames=-1, dtype='float64', always_2d=False, - fill_value=None, out=None): + + def read(self, frames: int = -1, dtype: str = 'float64', + always_2d: bool = False, fill_value: Optional[float] = None, + out: Optional[AudioData] = None) -> AudioData: """Read from the file and return data as NumPy array. Reads the given number of frames in the given data format @@ -947,7 +977,8 @@ def read(self, frames=-1, dtype='float64', always_2d=False, out[frames:] = fill_value return out - def buffer_read(self, frames=-1, dtype=None): + + def buffer_read(self, frames: int = -1, dtype: Optional[str] = None) -> memoryview: """Read from the file and return data as buffer object. Reads the given number of *frames* in the given data format @@ -982,7 +1013,7 @@ def buffer_read(self, frames=-1, dtype=None): assert read_frames == frames return _ffi.buffer(cdata) - def buffer_read_into(self, buffer, dtype): + def buffer_read_into(self, buffer: Union[bytearray, memoryview, Any], dtype: str) -> int: """Read from the file into a given buffer object. Fills the given *buffer* with frames in the given data format @@ -1015,7 +1046,7 @@ def buffer_read_into(self, buffer, dtype): frames = self._cdata_io('read', cdata, ctype, frames) return frames - def write(self, data): + def write(self, data: AudioData) -> None: """Write audio data from a NumPy array to the file. Writes a number of frames at the read/write position to the @@ -1069,7 +1100,7 @@ def write(self, data): assert written == len(data) self._update_frames(written) - def buffer_write(self, data, dtype): + def buffer_write(self, data: Any, dtype: str) -> None: """Write audio data from a buffer/bytes object to the file. Writes the contents of *data* to the file at the current @@ -1096,8 +1127,10 @@ def buffer_write(self, data, dtype): assert written == frames self._update_frames(written) - def blocks(self, blocksize=None, overlap=0, frames=-1, dtype='float64', - always_2d=False, fill_value=None, out=None): + def blocks(self, blocksize: Optional[int] = None, overlap: int = 0, + frames: int = -1, dtype: str = 'float64', + always_2d: bool = False, fill_value: Optional[float] = None, + out: Optional[AudioData] = None) -> Generator[AudioData, None, None]: """Return a generator for block-wise reading. By default, the generator yields blocks of the given @@ -1189,7 +1222,7 @@ def blocks(self, blocksize=None, overlap=0, frames=-1, dtype='float64', yield np.copy(block) if copy_out else block frames -= toread - def truncate(self, frames=None): + def truncate(self, frames: Optional[int] = None) -> None: """Truncate the file to a given number of frames. After this command, the read/write position will be at the new @@ -1213,7 +1246,7 @@ def truncate(self, frames=None): raise LibsndfileError(err, "Error truncating the file") self._info.frames = frames - def flush(self): + def flush(self) -> None: """Write unwritten data to the file system. Data written with `write()` is not immediately written to @@ -1227,7 +1260,7 @@ def flush(self): self._check_if_closed() _snd.sf_write_sync(self._file) - def close(self): + def close(self) -> None: """Close the file. Can be called multiple times.""" if not self.closed: # be sure to flush data to disk before closing the file @@ -1397,6 +1430,7 @@ def _cdata_io(self, action, data, ctype, frames): """Call one of libsndfile's read/write functions.""" assert ctype in _ffi_types.values() self._check_if_closed() + curr = 0 if self.seekable(): curr = self.tell() func = getattr(_snd, 'sf_' + action + 'f_' + ctype) @@ -1653,13 +1687,13 @@ class LibsndfileError(SoundFileRuntimeError): code libsndfile internal error number. """ - def __init__(self, code, prefix=""): + def __init__(self, code: int, prefix: str = "") -> None: SoundFileRuntimeError.__init__(self, code, prefix) self.code = code self.prefix = prefix @property - def error_string(self): + def error_string(self) -> str: """Raw libsndfile error message.""" if self.code: err_str = _snd.sf_error_number(self.code) @@ -1670,5 +1704,5 @@ def error_string(self): # See https://github.com/erikd/libsndfile/issues/610 for details. return "(Garbled error message from libsndfile)" - def __str__(self): + def __str__(self) -> str: return self.prefix + self.error_string diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index 65fde92..bba9c67 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -598,7 +598,7 @@ def test_open_w_and_wplus_with_too_few_arguments(): def test_open_with_mode_is_none(): with pytest.raises(TypeError) as excinfo: sf.SoundFile(filename_stereo, mode=None) - assert "Invalid mode: None" in str(excinfo.value) + assert "Can not get `mode` from file. provided `mode` is None." in str(excinfo.value) with open(filename_stereo, 'rb') as fobj: with sf.SoundFile(fobj, mode=None) as f: assert f.mode == 'rb'