diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 06137fe..7a10f79 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -36,7 +36,7 @@ jobs: uses: actions/cache@v4 with: path: build - key: taglib-${{ matrix.os }}-${{ hashFiles('build_taglib.py') }} + key: taglib-${{ matrix.os }}-${{ hashFiles('build_native_taglib.py') }} - name: build binary wheels uses: pypa/cibuildwheel@v2.17.0 - name: upload wheels diff --git a/README.md b/README.md index 3eea719..35be8af 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,11 @@ and also development tools for Python. On Ubuntu, Mint and other Debian-Based distributions, install the `libtag1-dev` and `python-dev` packages. On Fedora and friends, these are called `taglib-devel` and `python-devel`, respectively. On a Mac, use HomeBrew to install the `taglib` package. For Windows, see below. -As an alternative, run `python build_taglib.py` in this directory to -automatically download and build the latest Taglib version into the `build` subdirectory (also works on Windows). This requires Python and a -suitable compiler to be installed; specific instructions are beyond the +As an alternative, run `python build_native_taglib.py` in this directory to +automatically download and build the latest Taglib version into the `lib/taglib-cpp` subdirectory (also works on +Windows). + +This requires Python and a suitable compiler to be installed; specific instructions are beyond the scope of this README. ### Linux: Distribution-Specific Packages @@ -98,7 +100,7 @@ Then: - open the VS native tools command prompt - navigate to the _pytaglib_ repository -- run `python build_taglib.py` which will download and build the latest official TagLib release +- run `python build_native_taglib.py` which will download and build the latest official TagLib release - run `python setup.py install` ## Contact diff --git a/build_native_taglib.py b/build_native_taglib.py new file mode 100644 index 0000000..40fe812 --- /dev/null +++ b/build_native_taglib.py @@ -0,0 +1,185 @@ +import hashlib +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import urllib.request +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path + +taglib_version = "2.0" +taglib_url = f"https://github.com/taglib/taglib/archive/refs/tags/v{taglib_version}.tar.gz" +taglib_sha256sum = "e36ea877a6370810b97d84cf8f72b1e4ed205149ab3ac8232d44c850f38a2859" + +utfcpp_version = "4.0.5" +utfcpp_url = f"https://github.com/nemtrif/utfcpp/archive/refs/tags/v{utfcpp_version}.tar.gz" + +root = Path(__file__).resolve().parent + +system = platform.system() + + +def run_script(): + config = get_config() + + _download(taglib_url, config.taglib_tarball, taglib_sha256sum) + _download(utfcpp_url, config.utfcpp_tarball) + + if config.force or not config.taglib_extract_dir.exists(): + _del_if_exists(config.taglib_extract_dir) + _del_if_exists(config.utfcpp_extract_dir) + _extract(config.taglib_tarball, config.taglib_extract_dir) + _extract(config.utfcpp_tarball, config.utfcpp_extract_dir) + shutil.copytree(config.utfcpp_extract_dir, config.taglib_extract_dir / "3rdparty" / "utfcpp", + dirs_exist_ok=True) + + if config.force or not config.taglib_install_dir.exists(): + _del_if_exists(config.taglib_install_dir) + cmake_config(config.taglib_extract_dir, config.taglib_install_dir) + cmake_build(config.taglib_extract_dir) + + _del_if_exists(config.target_dir) + shutil.copytree(config.taglib_install_dir, config.target_dir) + + +@dataclass +class Configuration: + target_dir: Path + cache_dir: Path + force: bool + platform_id: str + + @property + def taglib_tarball(self): + return self.cache_dir / f"taglib-{taglib_version}.tar.gz" + + @property + def utfcpp_tarball(self): + return self.cache_dir / f"utfcpp-{utfcpp_version}.tar.gz" + + @property + def platform_dir(self): + return self.cache_dir / self.platform_id + + @property + def taglib_extract_dir(self): + return self.platform_dir / f"taglib-{taglib_version}" + + @property + def utfcpp_extract_dir(self): + return self.platform_dir / f"utfcpp-{utfcpp_version}" + + @property + def taglib_install_dir(self): + return self.platform_dir / "taglib-install" + + +def get_config() -> Configuration: + parser = ArgumentParser(description="helper to download and build C++ TagLib") + parser.add_argument( + "--target", + help="target directory for TagLib (binaries and headers)", + type=Path, + default=root / "lib" / "taglib-cpp", + ) + parser.add_argument( + "--cache", + help="temporary directory for downloads and builds; suitable to be cached in CI", + type=Path, + default=root / "build" / "cache", + ) + parser.add_argument("--force", action="store_true", help="fore clean build even if output already exists") + args = parser.parse_args() + return Configuration(target_dir=args.target.resolve(), cache_dir=args.cache.resolve(), force=args.force, + platform_id=get_platform_id()) + + +def _download(url: str, target: Path, sha256sum: str = None): + print(f"downloading {url} ...") + if target.exists(): + print("skipping download, file exists") + return + response = urllib.request.urlopen(url) + data = response.read() + target.parent.mkdir(exist_ok=True, parents=True) + target.write_bytes(data) + print("download complete") + if sha256sum is None: + return + the_hash = hashlib.sha256(target.read_bytes()).hexdigest() + if the_hash != taglib_sha256sum: + error = f'checksum of downloaded file ({the_hash}) does not match expected hash ({taglib_sha256sum})' + raise RuntimeError(error) + + +def get_platform_id(): + """Tries to generate a string that is unique for the C compiler configuration used to build + Python extensions. + + Compiler potentially depends on: + - OS + - architecture + - Python implementation (CPython, PyPy) + - Python version (major/minor) + - C library type (uclib for musllinux) + + In cibuildwheel, the AUDITWHEEL_PLAT environment variable is used for all of these except + Python version and implementation. + - """ + platform_identifier = os.environ.get('AUDITWHEEL_PLAT', f"{system}-{platform.machine()}") + python_identifier = f"{sys.implementation.name}-{sys.version_info[0]}.{sys.version_info[1]}" + return f"{platform_identifier}-{python_identifier}" + + +def _extract(archive: Path, target: Path): + """Extracts `archive` into `target`. + """ + print(f"extracting {archive} to {target} ...") + tar = tarfile.open(archive) + tar.extractall(target.parent) + + +def _del_if_exists(dir: Path): + if dir.exists(): + shutil.rmtree(dir) + + +def _cmake(cwd: Path, *args): + print(f"running cmake {' '.join(args)}") + return subprocess.run(["cmake", *args], cwd=cwd, check=True) + + +def cmake_config(source_dir: Path, install_dir: Path): + print("running cmake ...") + args = ["-DWITH_ZLIB=OFF"] # todo fix building wheels with zlib support + if system == "Windows": + args += ["-A", "x64"] + elif system == "Linux": + args.append("-DCMAKE_POSITION_INDEPENDENT_CODE=ON") + args.append("-DBUILD_TESTING=OFF") + args.append(f"-DCMAKE_INSTALL_PREFIX={install_dir}") + args.append(f"-DCMAKE_CXX_FLAGS=-I{source_dir / '3rdparty' / 'utfcpp' / 'source'}") + args.append(".") + install_dir.mkdir(exist_ok=True, parents=True) + _cmake(source_dir, *args) + + +def cmake_build(source_dir: Path): + print("building taglib ...") + build_configuration = "Release" + _cmake( + source_dir, + "--build", + ".", + "--config", + build_configuration + ) + print("installing cmake ...") + _cmake(source_dir, "--install", ".", "--config", build_configuration) + + +if __name__ == "__main__": + run_script() diff --git a/build_taglib.py b/build_taglib.py deleted file mode 100644 index 03ce5e2..0000000 --- a/build_taglib.py +++ /dev/null @@ -1,185 +0,0 @@ -import hashlib -import os -import platform -import shutil -import subprocess -import sys -import tarfile -import urllib.request -from argparse import ArgumentParser -from pathlib import Path - -system = platform.system() -here = Path(__file__).resolve().parent - -taglib_version = "2.0" -taglib_release = f"https://github.com/taglib/taglib/archive/refs/tags/v{taglib_version}.tar.gz" -taglib_sha256sum = "e36ea877a6370810b97d84cf8f72b1e4ed205149ab3ac8232d44c850f38a2859" - -utfcpp_version = "4.0.5" -utfcpp_release = f"https://github.com/nemtrif/utfcpp/archive/refs/tags/v{utfcpp_version}.tar.gz" - -sys_identifier = f"{system}-{platform.machine()}-{sys.implementation.name}-{platform.python_version()}" - -class Configuration: - def __init__(self): - self.build_base = here / "build" - self.build_path = self.build_base / sys_identifier - self.tl_install_dir = self.build_path / "taglib" - self.clean = False - - @property - def tl_download_dest(self): - return self.build_base / f"taglib-{taglib_version}.tar.gz" - - @property - def utfcpp_download_dest(self): - return self.build_base / f"utfcpp-{utfcpp_version}.tar.gz" - - @property - def tl_extract_dir(self): - return self.build_path / f"taglib-{taglib_version}" - - @property - def utfcpp_extract_dir(self): - return self.build_path / f"utfcpp-{utfcpp_version}" - - @property - def utfcpp_include_dir(self): - return self.utfcpp_extract_dir / "source" - - -def _download_file(url: str, target: Path, sha256sum: str = None): - if target.exists(): - print("skipping download, file exists") - return - print(f"downloading {url} ...") - response = urllib.request.urlopen(url) - data = response.read() - target.parent.mkdir(exist_ok=True, parents=True) - target.write_bytes(data) - if sha256sum is None: - return - the_hash = hashlib.sha256(target.read_bytes()).hexdigest() - if the_hash != taglib_sha256sum: - error = f'checksum of downloaded file ({the_hash}) does not match expected hash ({taglib_sha256sum})' - raise RuntimeError(error) - - -def download(config: Configuration): - _download_file(taglib_release, config.tl_download_dest, taglib_sha256sum) - _download_file(utfcpp_release, config.utfcpp_download_dest) - - -def _extract_tar(archive: Path, target: Path): - if target.exists(): - print(f"extracted directory {target} found; skipping tar") - return - print(f"extracting {archive} ...") - tar = tarfile.open(archive) - tar.extractall(target.parent) - - -def extract(config: Configuration): - _extract_tar(config.tl_download_dest, config.tl_extract_dir) - _extract_tar(config.utfcpp_download_dest, config.utfcpp_extract_dir) - - -def copy_utfcpp(config: Configuration): - target = config.tl_extract_dir / "3rdparty" / "utfcpp" - if target.exists(): - shutil.rmtree(target) - shutil.copytree(config.utfcpp_extract_dir, target) - - -def cmake_clean(config: Configuration): - if not config.clean: - return - print("removing previous cmake cache ...") - cache = config.tl_extract_dir / "CMakeCache.txt" - if cache.exists(): - cache.unlink() - shutil.rmtree(config.tl_extract_dir / "CMakeFiles", ignore_errors=True) - - -def call_cmake(config, *args): - return subprocess.run( - ["cmake", *[a for a in args if a is not None]], - cwd=config.tl_extract_dir, - check=True, - ) - - -def cmake_config(config: Configuration): - print("running cmake ...") - args = ["-DWITH_ZLIB=OFF"] # todo fix building wheels with zlib support - if system == "Windows": - args += ["-A", "x64"] - elif system == "Linux": - args.append("-DCMAKE_POSITION_INDEPENDENT_CODE=ON") - args.append(f"-DCMAKE_INSTALL_PREFIX={config.tl_install_dir}") - args.append(f"-DCMAKE_CXX_FLAGS=-I{config.tl_extract_dir / '3rdparty' / 'utfcpp' / 'source'}") - args.append(".") - config.tl_install_dir.mkdir(exist_ok=True, parents=True) - call_cmake(config, *args) - - -def cmake_build(config: Configuration): - print("building taglib ...") - build_configuration = "Release" - call_cmake( - config, - "--build", - ".", - "--config", - build_configuration, - "--clean-first" if config.clean else None, - ) - print("installing cmake ...") - call_cmake(config, "--install", ".", "--config", build_configuration) - - -def to_abs_path(str_path: str) -> Path: - path = Path(str_path) - if not path.is_absolute(): - path = here / path - return path - - -def parse_args() -> Configuration: - parser = ArgumentParser() - config = Configuration() - parser.add_argument( - "--install-dest", - help="destination directory for taglib", - type=Path, - default=config.tl_install_dir, - ) - parser.add_argument("--clean", action="store_true") - args = parser.parse_args() - config.tl_install_dir = to_abs_path(args.install_dest) - config.clean = args.clean - return config - - -def run(): - print(f"building taglib on {sys_identifier} ...") - config = parse_args() - tag_lib = ( - config.tl_install_dir - / "lib" - / ("tag.lib" if system == "Windows" else "libtag.a") - ) - if tag_lib.exists() and not config.clean: - print("installed TagLib found, exiting") - return - download(config) - extract(config) - copy_utfcpp(config) - cmake_clean(config) - cmake_config(config) - cmake_build(config) - - -if __name__ == "__main__": - run() diff --git a/pyproject.toml b/pyproject.toml index c07b360..3514383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,5 +40,5 @@ package-dir = { "" = "src" } [tool.cibuildwheel] test-extras = ["tests"] test-command = "pytest {project}/tests" -before-build = "python build_taglib.py" +before-build = "python build_native_taglib.py" skip = "cp36-* cp37-*" diff --git a/setup.py b/setup.py index f16e107..eb04eeb 100644 --- a/setup.py +++ b/setup.py @@ -8,21 +8,18 @@ # import os -import platform import sys from pathlib import Path from Cython.Build import cythonize from setuptools import setup, Extension -sys_identifier = f"{platform.system()}-{platform.machine()}-{sys.implementation.name}-{platform.python_version()}" -here = Path(__file__).resolve().parent -default_taglib_path = here / "build" / sys_identifier / "taglib" - src = Path("src") def extension_kwargs(): + here = Path(__file__).resolve().parent + default_taglib_path = here / "lib" / "taglib-cpp" taglib_install_dir = Path(os.environ.get("TAGLIB_HOME", str(default_taglib_path))) if sys.platform.startswith("win"): # on Windows, we compile static taglib build into the python module