From cd262bc57756b50f055324e42cdd3024d687e0ba Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Thu, 7 May 2026 23:34:18 -0400 Subject: [PATCH] test: add pytest suite and ci coverage - Update python version to 3.10 - Update CI workflows too --- .github/workflows/dist.yml | 97 ++++++++--------- pyproject.toml | 18 ++- tests/conftest.py | 98 +++++++++++++++++ tests/packages/consumer-pkg/meson.build | 37 +++++++ tests/packages/consumer-pkg/pyproject.toml | 19 ++++ .../consumer-pkg/src/consumer_pkg/__init__.py | 2 + .../consumer-pkg/src/consumer_pkg/consumer.c | 6 + .../src/consumer_pkg/consumer_module.c | 29 +++++ .../src/consumer_pkg/include/consumer.h | 5 + .../src/consumer_pkg/include/consumer_dll.h | 9 ++ tests/packages/demo-basic/pyproject.toml | 14 +++ .../demo-basic/src/demo_pkg/__init__.py | 0 tests/packages/demo-enable-if/pyproject.toml | 19 ++++ .../demo-enable-if/src/demo_pkg/__init__.py | 0 .../packages/demo-missing-lib/pyproject.toml | 16 +++ .../demo-missing-lib/src/demo_pkg/__init__.py | 0 tests/packages/demo-multi-lib/pyproject.toml | 16 +++ .../demo-multi-lib/src/demo_pkg/__init__.py | 0 tests/packages/demo-shared/pyproject.toml | 21 ++++ .../demo-shared/src/demo_pkg/__init__.py | 0 .../demo-shared/src/demo_pkg/include/demo.h | 1 + tests/packages/provider-pkg/meson.build | 19 ++++ tests/packages/provider-pkg/pyproject.toml | 19 ++++ .../provider-pkg/src/provider_pkg/__init__.py | 0 .../src/provider_pkg/include/provider.h | 5 + .../src/provider_pkg/include/provider_dll.h | 9 ++ .../provider-pkg/src/provider_pkg/provider.c | 5 + tests/test_config.py | 58 ++++++++++ tests/test_integration_build_backend.py | 103 ++++++++++++++++++ tests/test_integration_dependency_chain.py | 34 ++++++ tests/test_integration_wheel.py | 45 ++++++++ tests/test_util.py | 32 ++++++ tests/test_validate.py | 34 ++++++ 33 files changed, 717 insertions(+), 53 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/packages/consumer-pkg/meson.build create mode 100644 tests/packages/consumer-pkg/pyproject.toml create mode 100644 tests/packages/consumer-pkg/src/consumer_pkg/__init__.py create mode 100644 tests/packages/consumer-pkg/src/consumer_pkg/consumer.c create mode 100644 tests/packages/consumer-pkg/src/consumer_pkg/consumer_module.c create mode 100644 tests/packages/consumer-pkg/src/consumer_pkg/include/consumer.h create mode 100644 tests/packages/consumer-pkg/src/consumer_pkg/include/consumer_dll.h create mode 100644 tests/packages/demo-basic/pyproject.toml create mode 100644 tests/packages/demo-basic/src/demo_pkg/__init__.py create mode 100644 tests/packages/demo-enable-if/pyproject.toml create mode 100644 tests/packages/demo-enable-if/src/demo_pkg/__init__.py create mode 100644 tests/packages/demo-missing-lib/pyproject.toml create mode 100644 tests/packages/demo-missing-lib/src/demo_pkg/__init__.py create mode 100644 tests/packages/demo-multi-lib/pyproject.toml create mode 100644 tests/packages/demo-multi-lib/src/demo_pkg/__init__.py create mode 100644 tests/packages/demo-shared/pyproject.toml create mode 100644 tests/packages/demo-shared/src/demo_pkg/__init__.py create mode 100644 tests/packages/demo-shared/src/demo_pkg/include/demo.h create mode 100644 tests/packages/provider-pkg/meson.build create mode 100644 tests/packages/provider-pkg/pyproject.toml create mode 100644 tests/packages/provider-pkg/src/provider_pkg/__init__.py create mode 100644 tests/packages/provider-pkg/src/provider_pkg/include/provider.h create mode 100644 tests/packages/provider-pkg/src/provider_pkg/include/provider_dll.h create mode 100644 tests/packages/provider-pkg/src/provider_pkg/provider.c create mode 100644 tests/test_config.py create mode 100644 tests/test_integration_build_backend.py create mode 100644 tests/test_integration_dependency_chain.py create mode 100644 tests/test_integration_wheel.py create mode 100644 tests/test_util.py create mode 100644 tests/test_validate.py diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index 04ab35f..daaf308 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -17,21 +17,21 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: psf/black@stable check-mypy: - runs-on: macos-13 + runs-on: macos-14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # - uses: jpetrucciani/mypy-check@0.930 # .. can't use that because we need to install pytest - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.10" - name: Install requirements run: | - pip --disable-pip-version-check install mypy . + pip --disable-pip-version-check install mypy .[test] - name: Run mypy run: | mypy . @@ -43,80 +43,73 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.10" - run: pipx run build - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist path: dist - # test: - # needs: [build] - # runs-on: ${{ matrix.os }} - # strategy: - # matrix: - # os: [windows-latest, macos-13, ubuntu-20.04] - # python_version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] - # architecture: [x86, x64] - # exclude: - # - os: macos-13 - # architecture: x86 - # - os: ubuntu-20.04 - # architecture: x86 + test: + needs: [build] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] - # steps: - # - uses: actions/checkout@v4 - # with: - # submodules: recursive - # fetch-depth: 0 + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 - # - uses: actions/setup-python@v5 - # with: - # python-version: ${{ matrix.python_version }} - # architecture: ${{ matrix.architecture }} + - uses: actions/setup-python@v6 + with: + python-version: "3.10" - # - name: Download build artifacts - # uses: actions/download-artifact@v4 - # with: - # name: dist - # path: dist - - # - name: Install test dependencies - # run: python -m pip --disable-pip-version-check install -r tests/requirements.txt + - name: Download build artifacts + uses: actions/download-artifact@v7 + with: + name: dist + path: dist - # - name: Setup MSVC compiler - # uses: ilammy/msvc-dev-cmd@v1 - # if: matrix.os == 'windows-latest' + - name: Setup MSVC + uses: bus1/cabuild/action/msdevshell@e22aba57d6e74891d059d66501b6b5aed8123c4d # v1 + with: + architecture: x64 + if: runner.os == 'Windows' - # - name: Test wheel - # shell: bash - # run: | - # cd dist - # python -m pip --disable-pip-version-check install *.whl - # cd ../tests - # python -m pytest + - name: Install wheel + shell: bash + run: | + cd dist + python -m pip --disable-pip-version-check install $(ls *.whl)[test] + + - name: Test wheel + run: python -m pytest -v publish: runs-on: ubuntu-latest # needs: [check, check-mypy, check-doc, build, test] - needs: [check, check-mypy, build] + needs: [check, check-mypy, build, test] permissions: id-token: write if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: dist path: dist diff --git a/pyproject.toml b/pyproject.toml index f58a6ed..00bfd2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "hatch-nativelib" dynamic = ["version"] description = "Hatchling plugin with utilities for native libraries" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = "BSD-3-Clause" authors = [ {name = "Dustin Spicuzza", email = "dustin@virtualroadside.com"}, @@ -29,6 +29,15 @@ dependencies = [ [project.urls] "Source code" = "https://github.com/robotpy/hatch-nativelib" +[project.optional-dependencies] +test = [ + "build", + "environment-helpers", + "hatch-meson", + "pytest", + "ninja" +] + [project.entry-points.hatch] nativelib = "hatch_nativelib.hooks" @@ -38,6 +47,13 @@ source = "vcs" [tool.hatch.build.hooks.vcs] version-file = "src/hatch_nativelib/_version.py" +[tool.mypy] +exclude = ["^tests/packages/"] + +[[tool.mypy.overrides]] +module = ["environment_helpers.*"] +follow_untyped_imports = true + [[tool.mypy.overrides]] module = ["pkgconf.*"] follow_untyped_imports = true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4f8a40e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +import pathlib +import shutil +import subprocess +import sys +import textwrap +import zipfile +from dataclasses import dataclass + +import pytest +from environment_helpers import Environment, VirtualEnvironment + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +TEST_PACKAGES_ROOT = REPO_ROOT / "tests" / "packages" + + +def shared_library_filename(name: str) -> str: + if sys.platform == "win32": + return f"{name}.dll" + if sys.platform == "darwin": + return f"lib{name}.dylib" + return f"lib{name}.so" + + +def _write_text(path: pathlib.Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +@dataclass +class TempProject: + root: pathlib.Path + env: Environment + + def write(self, relative_path: str, content: str) -> pathlib.Path: + path = self.root / relative_path + _write_text(path, content) + return path + + def read(self, relative_path: str) -> str: + return (self.root / relative_path).read_text(encoding="utf-8") + + def copy_package_fixture( + self, fixture_name: str, destination: str | pathlib.Path = "." + ) -> pathlib.Path: + src = TEST_PACKAGES_ROOT / fixture_name + if not src.is_dir(): + raise FileNotFoundError(f"package fixture not found: {fixture_name}") + + dst = self.root / destination + dst.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dst, dirs_exist_ok=True) + return dst + + def run( + self, *args: str, cwd: pathlib.Path | None = None + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(self.env.interpreter), *args], + cwd=cwd or self.root, + env=dict(self.env.env), + text=True, + capture_output=True, + ) + + def install_current_project(self) -> None: + self.env.install([str(REPO_ROOT), "build", "hatch-meson", "ninja"]) + + def build_wheel(self) -> subprocess.CompletedProcess[str]: + return self.run("-m", "build", "--wheel", "--no-isolation") + + def dist_wheels(self) -> list[pathlib.Path]: + return sorted((self.root / "dist").glob("*.whl")) + + def wheel_contents(self, wheel: pathlib.Path) -> list[str]: + with zipfile.ZipFile(wheel) as zf: + return sorted(zf.namelist()) + + def installed_package_root(self, package: str) -> pathlib.Path: + code = ( + "import importlib, pathlib; " + f"mod = importlib.import_module({package!r}); " + "print(pathlib.Path(mod.__file__).parent)" + ) + result = self.run("-c", code) + result.check_returncode() + return pathlib.Path(result.stdout.strip()) + + +@pytest.fixture +def project_factory(tmp_path: pathlib.Path) -> TempProject: + venv_path = tmp_path / ".venv" + env = VirtualEnvironment.create_venv(venv_path, with_pip=True) + project = TempProject(tmp_path, env) + project.install_current_project() + return project diff --git a/tests/packages/consumer-pkg/meson.build b/tests/packages/consumer-pkg/meson.build new file mode 100644 index 0000000..afd2744 --- /dev/null +++ b/tests/packages/consumer-pkg/meson.build @@ -0,0 +1,37 @@ +project('consumer-pkg', 'c') + +py = import('python').find_installation(pure: false) +consumer_inc = include_directories('src/consumer_pkg/include') +provider_dep = dependency('provider', method: 'pkg-config') + +if meson.get_compiler('c').get_id() in ['msvc', 'clang-cl', 'intel-cl'] + export_dll_args = ['-DCONSUMER_DLL_EXPORTS'] + import_dll_args = ['-DCONSUMER_DLL_IMPORTS'] +else + export_dll_args = [] + import_dll_args = [] +endif + +consumer_lib = shared_library( + 'consumer', + 'src/consumer_pkg/consumer.c', + c_args: export_dll_args, + dependencies: [provider_dep], + include_directories: consumer_inc, + install: true, + install_dir: py.get_install_dir() / 'consumer_pkg', +) + +consumer_dep = declare_dependency( + compile_args: import_dll_args, + include_directories: consumer_inc, + link_with: consumer_lib, +) + +py.extension_module( + '_consumer_ext', + 'src/consumer_pkg/consumer_module.c', + dependencies: [consumer_dep], + install: true, + subdir: 'consumer_pkg', +) diff --git a/tests/packages/consumer-pkg/pyproject.toml b/tests/packages/consumer-pkg/pyproject.toml new file mode 100644 index 0000000..4643ac6 --- /dev/null +++ b/tests/packages/consumer-pkg/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling", "hatch-meson", "hatch-nativelib", "ninja"] +build-backend = "hatchling.build" + +[project] +name = "consumer-pkg" +version = "1.0.0" +description = "Consumer package" + +[tool.hatch.build.targets.wheel] +packages = ["src/consumer_pkg"] + +[tool.hatch.build.hooks.meson] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/consumer_pkg/consumer.pc" +shared_libraries = ["consumer"] +requires = ["provider"] +extra_cflags = "-DCONSUMER_DLL_IMPORTS" diff --git a/tests/packages/consumer-pkg/src/consumer_pkg/__init__.py b/tests/packages/consumer-pkg/src/consumer_pkg/__init__.py new file mode 100644 index 0000000..0392e94 --- /dev/null +++ b/tests/packages/consumer-pkg/src/consumer_pkg/__init__.py @@ -0,0 +1,2 @@ +import consumer_pkg._init_consumer +from ._consumer_ext import consumer_twice_plus_one diff --git a/tests/packages/consumer-pkg/src/consumer_pkg/consumer.c b/tests/packages/consumer-pkg/src/consumer_pkg/consumer.c new file mode 100644 index 0000000..94f65ad --- /dev/null +++ b/tests/packages/consumer-pkg/src/consumer_pkg/consumer.c @@ -0,0 +1,6 @@ +#include "consumer.h" +#include "provider.h" + +int consumer_twice_plus_one(int value) { + return provider_add(value, value) + 1; +} diff --git a/tests/packages/consumer-pkg/src/consumer_pkg/consumer_module.c b/tests/packages/consumer-pkg/src/consumer_pkg/consumer_module.c new file mode 100644 index 0000000..c048050 --- /dev/null +++ b/tests/packages/consumer-pkg/src/consumer_pkg/consumer_module.c @@ -0,0 +1,29 @@ +#define PY_SSIZE_T_CLEAN +#include +#include "consumer.h" + +static PyObject *wrap_consumer_twice_plus_one(PyObject *self, PyObject *args) { + int value; + if (!PyArg_ParseTuple(args, "i", &value)) { + return NULL; + } + + return PyLong_FromLong(consumer_twice_plus_one(value)); +} + +static PyMethodDef consumer_methods[] = { + {"consumer_twice_plus_one", wrap_consumer_twice_plus_one, METH_VARARGS, "Compute a provider-backed value."}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef consumer_module = { + PyModuleDef_HEAD_INIT, + "_consumer_ext", + NULL, + -1, + consumer_methods, +}; + +PyMODINIT_FUNC PyInit__consumer_ext(void) { + return PyModule_Create(&consumer_module); +} diff --git a/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer.h b/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer.h new file mode 100644 index 0000000..33af995 --- /dev/null +++ b/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer.h @@ -0,0 +1,5 @@ +#pragma once + +#include "consumer_dll.h" + +CONSUMER_DLL int consumer_twice_plus_one(int value); diff --git a/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer_dll.h b/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer_dll.h new file mode 100644 index 0000000..feb128b --- /dev/null +++ b/tests/packages/consumer-pkg/src/consumer_pkg/include/consumer_dll.h @@ -0,0 +1,9 @@ +#pragma once + +#if defined(_WIN32) && defined(CONSUMER_DLL_EXPORTS) + #define CONSUMER_DLL __declspec(dllexport) +#elif defined(_WIN32) && defined(CONSUMER_DLL_IMPORTS) + #define CONSUMER_DLL __declspec(dllimport) +#else + #define CONSUMER_DLL +#endif diff --git a/tests/packages/demo-basic/pyproject.toml b/tests/packages/demo-basic/pyproject.toml new file mode 100644 index 0000000..dd16e31 --- /dev/null +++ b/tests/packages/demo-basic/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["hatchling", "hatch-nativelib"] +build-backend = "hatchling.build" + +[project] +name = "demo-pkg" +version = "1.2.3" +description = "Demo package" + +[tool.hatch.build.targets.wheel] +packages = ["src/demo_pkg"] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/demo.pc" diff --git a/tests/packages/demo-basic/src/demo_pkg/__init__.py b/tests/packages/demo-basic/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/demo-enable-if/pyproject.toml b/tests/packages/demo-enable-if/pyproject.toml new file mode 100644 index 0000000..f2929b5 --- /dev/null +++ b/tests/packages/demo-enable-if/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling", "hatch-nativelib"] +build-backend = "hatchling.build" + +[project] +name = "demo-pkg" +version = "1.2.3" +description = "Demo package" + +[tool.hatch.build.targets.wheel] +packages = ["src/demo_pkg"] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/enabled.pc" +enable_if = "python_version >= '3.8'" + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/skipped.pc" +enable_if = "python_version < '0'" diff --git a/tests/packages/demo-enable-if/src/demo_pkg/__init__.py b/tests/packages/demo-enable-if/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/demo-missing-lib/pyproject.toml b/tests/packages/demo-missing-lib/pyproject.toml new file mode 100644 index 0000000..771042b --- /dev/null +++ b/tests/packages/demo-missing-lib/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling", "hatch-nativelib"] +build-backend = "hatchling.build" + +[project] +name = "demo-pkg" +version = "1.2.3" +description = "Demo package" + +[tool.hatch.build.targets.wheel] +packages = ["src/demo_pkg"] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/demo.pc" +libdir = "src/demo_pkg/lib" +shared_libraries = ["missing"] diff --git a/tests/packages/demo-missing-lib/src/demo_pkg/__init__.py b/tests/packages/demo-missing-lib/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/demo-multi-lib/pyproject.toml b/tests/packages/demo-multi-lib/pyproject.toml new file mode 100644 index 0000000..1046bcf --- /dev/null +++ b/tests/packages/demo-multi-lib/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling", "hatch-nativelib"] +build-backend = "hatchling.build" + +[project] +name = "demo-pkg" +version = "1.2.3" +description = "Demo package" + +[tool.hatch.build.targets.wheel] +packages = ["src/demo_pkg"] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/demo.pc" +libdir = "src/demo_pkg/lib" +shared_libraries = ["demo", "helper"] diff --git a/tests/packages/demo-multi-lib/src/demo_pkg/__init__.py b/tests/packages/demo-multi-lib/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/demo-shared/pyproject.toml b/tests/packages/demo-shared/pyproject.toml new file mode 100644 index 0000000..ed812cc --- /dev/null +++ b/tests/packages/demo-shared/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling", "hatch-nativelib"] +build-backend = "hatchling.build" + +[project] +name = "demo-pkg" +version = "1.2.3" +description = "Demo package" + +[tool.hatch.build.targets.wheel] +packages = ["src/demo_pkg"] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/demo_pkg/demo.pc" +includedir = "src/demo_pkg/include" +libdir = "src/demo_pkg/lib" +shared_libraries = ["demo"] +requires = ["dep1", "dep2"] +requires_private = ["priv1", "priv2"] +libs_private = "-lm" +extra_cflags = "-DUSE_DEMO" diff --git a/tests/packages/demo-shared/src/demo_pkg/__init__.py b/tests/packages/demo-shared/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/demo-shared/src/demo_pkg/include/demo.h b/tests/packages/demo-shared/src/demo_pkg/include/demo.h new file mode 100644 index 0000000..8ffa207 --- /dev/null +++ b/tests/packages/demo-shared/src/demo_pkg/include/demo.h @@ -0,0 +1 @@ +#define DEMO 1 diff --git a/tests/packages/provider-pkg/meson.build b/tests/packages/provider-pkg/meson.build new file mode 100644 index 0000000..8e460d0 --- /dev/null +++ b/tests/packages/provider-pkg/meson.build @@ -0,0 +1,19 @@ +project('provider-pkg', 'c') + +py = import('python').find_installation(pure: false) +provider_inc = include_directories('src/provider_pkg/include') + +if meson.get_compiler('c').get_id() in ['msvc', 'clang-cl', 'intel-cl'] + export_dll_args = ['-DPROVIDER_DLL_EXPORTS'] +else + export_dll_args = [] +endif + +shared_library( + 'provider', + 'src/provider_pkg/provider.c', + c_args: export_dll_args, + include_directories: provider_inc, + install: true, + install_dir: py.get_install_dir() / 'provider_pkg', +) diff --git a/tests/packages/provider-pkg/pyproject.toml b/tests/packages/provider-pkg/pyproject.toml new file mode 100644 index 0000000..297fdb3 --- /dev/null +++ b/tests/packages/provider-pkg/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling", "hatch-meson", "hatch-nativelib", "ninja"] +build-backend = "hatchling.build" + +[project] +name = "provider-pkg" +version = "1.0.0" +description = "Provider package" + +[tool.hatch.build.targets.wheel] +packages = ["src/provider_pkg"] + +[tool.hatch.build.hooks.meson] + +[[tool.hatch.build.hooks.nativelib.pcfile]] +pcfile = "src/provider_pkg/provider.pc" +includedir = "src/provider_pkg/include" +shared_libraries = ["provider"] +extra_cflags = "-DPROVIDER_DLL_IMPORTS" diff --git a/tests/packages/provider-pkg/src/provider_pkg/__init__.py b/tests/packages/provider-pkg/src/provider_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/provider-pkg/src/provider_pkg/include/provider.h b/tests/packages/provider-pkg/src/provider_pkg/include/provider.h new file mode 100644 index 0000000..938ee94 --- /dev/null +++ b/tests/packages/provider-pkg/src/provider_pkg/include/provider.h @@ -0,0 +1,5 @@ +#pragma once + +#include "provider_dll.h" + +PROVIDER_DLL int provider_add(int a, int b); diff --git a/tests/packages/provider-pkg/src/provider_pkg/include/provider_dll.h b/tests/packages/provider-pkg/src/provider_pkg/include/provider_dll.h new file mode 100644 index 0000000..4741101 --- /dev/null +++ b/tests/packages/provider-pkg/src/provider_pkg/include/provider_dll.h @@ -0,0 +1,9 @@ +#pragma once + +#if defined(_WIN32) && defined(PROVIDER_DLL_EXPORTS) + #define PROVIDER_DLL __declspec(dllexport) +#elif defined(_WIN32) && defined(PROVIDER_DLL_IMPORTS) + #define PROVIDER_DLL __declspec(dllimport) +#else + #define PROVIDER_DLL +#endif diff --git a/tests/packages/provider-pkg/src/provider_pkg/provider.c b/tests/packages/provider-pkg/src/provider_pkg/provider.c new file mode 100644 index 0000000..2532646 --- /dev/null +++ b/tests/packages/provider-pkg/src/provider_pkg/provider.c @@ -0,0 +1,5 @@ +#include "provider.h" + +int provider_add(int a, int b) { + return a + b; +} diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f803dc6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pathlib + +import pytest + +from hatch_nativelib.config import PcFileConfig + + +def test_get_pc_path_accepts_relative_pcfile() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.pc") + + assert cfg.get_pc_path() == pathlib.Path("src/demo_pkg/demo.pc") + + +def test_get_pc_path_rejects_absolute_path() -> None: + cfg = PcFileConfig(pcfile="/tmp/demo.pc") + + with pytest.raises(ValueError, match="must not be absolute"): + cfg.get_pc_path() + + +def test_get_pc_path_requires_pc_extension() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.txt") + + with pytest.raises(ValueError, match="must end with .pc"): + cfg.get_pc_path() + + +def test_get_name_prefers_explicit_name() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.pc", name="custom-name") + + assert cfg.get_name() == "custom-name" + + +def test_get_name_defaults_to_pc_filename() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.pc") + + assert cfg.get_name() == "demo" + + +def test_get_init_module_auto_normalizes_filename() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo-name.v1.pc") + + assert cfg.get_init_module() == "_init_demo_name_v1" + + +def test_get_init_module_rejects_invalid_identifier() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.pc", init_module="not-valid-module") + + with pytest.raises(ValueError, match="valid python identifier"): + cfg.get_init_module() + + +def test_get_init_module_path_places_module_next_to_pcfile() -> None: + cfg = PcFileConfig(pcfile="src/demo_pkg/demo.pc") + + assert cfg.get_init_module_path() == pathlib.Path("src/demo_pkg/_init_demo.py") diff --git a/tests/test_integration_build_backend.py b/tests/test_integration_build_backend.py new file mode 100644 index 0000000..97486c2 --- /dev/null +++ b/tests/test_integration_build_backend.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import sys + + +def shared_library_filename(name: str) -> str: + if sys.platform == "win32": + return f"{name}.dll" + if sys.platform == "darwin": + return f"lib{name}.dylib" + return f"lib{name}.so" + + +def test_build_generates_pcfile(project_factory) -> None: + project_factory.copy_package_fixture("demo-basic") + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + assert project_factory.read("src/demo_pkg/demo.pc") == ( + "prefix=${pcfiledir}\n\n" + "Name: demo\n" + "Description: Demo package\n" + "Version: 1.2.3\n" + ) + + +def test_build_generates_pcfile_and_init_module_for_shared_library( + project_factory, +) -> None: + project_factory.copy_package_fixture("demo-shared") + project_factory.write( + f"src/demo_pkg/lib/{shared_library_filename('demo')}", + "not-a-real-library", + ) + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + assert project_factory.read("src/demo_pkg/demo.pc") == ( + "prefix=${pcfiledir}\n" + "includedir=${prefix}/include\n" + "libdir=${prefix}/lib\n" + "pkgconf_pypi_initpy=demo_pkg._init_demo\n\n" + "Name: demo\n" + "Description: Demo package\n" + "Version: 1.2.3\n" + "Requires: dep1 dep2\n" + "Requires.private: priv1 priv2\n" + "Libs: -L${libdir} -ldemo\n" + "Libs.private: -lm\n" + "Cflags: -I${includedir} -DUSE_DEMO\n" + ) + + init_module = project_factory.read("src/demo_pkg/_init_demo.py") + assert "def __load_library():" in init_module + assert shared_library_filename("demo") in init_module + assert ( + "cdll.LoadLibrary" in init_module + or "CDLL(lib_path, mode=RTLD_GLOBAL)" in init_module + ) + + +def test_build_skips_pcfile_when_enable_if_does_not_match(project_factory) -> None: + project_factory.copy_package_fixture("demo-enable-if") + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + assert (project_factory.root / "src/demo_pkg/enabled.pc").is_file() + assert not (project_factory.root / "src/demo_pkg/skipped.pc").exists() + + +def test_build_fails_when_declared_shared_library_is_missing(project_factory) -> None: + project_factory.copy_package_fixture("demo-missing-lib") + + result = project_factory.build_wheel() + + assert result.returncode != 0 + assert "shared library not found" in (result.stdout + result.stderr) + + +def test_build_generates_init_module_for_multiple_shared_libraries( + project_factory, +) -> None: + project_factory.copy_package_fixture("demo-multi-lib") + project_factory.write( + f"src/demo_pkg/lib/{shared_library_filename('demo')}", + "not-a-real-library", + ) + project_factory.write( + f"src/demo_pkg/lib/{shared_library_filename('helper')}", + "not-a-real-library", + ) + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + init_module = project_factory.read("src/demo_pkg/_init_demo.py") + assert "libs = []" in init_module + assert shared_library_filename("demo") in init_module + assert shared_library_filename("helper") in init_module + assert "return libs" in init_module diff --git a/tests/test_integration_dependency_chain.py b/tests/test_integration_dependency_chain.py new file mode 100644 index 0000000..7001e49 --- /dev/null +++ b/tests/test_integration_dependency_chain.py @@ -0,0 +1,34 @@ +from __future__ import annotations + + +def test_consumer_links_to_installed_provider_and_imports_work(project_factory) -> None: + provider_root = project_factory.copy_package_fixture("provider-pkg", "provider") + + provider_build = project_factory.run( + "-m", "build", "--wheel", "--no-isolation", cwd=provider_root + ) + assert provider_build.returncode == 0, provider_build.stdout + provider_build.stderr + provider_wheel = next((provider_root / "dist").glob("*.whl")) + project_factory.env.install_wheel(provider_wheel) + + consumer_root = project_factory.copy_package_fixture("consumer-pkg", "consumer") + + consumer_build = project_factory.run( + "-m", "build", "--wheel", "--no-isolation", cwd=consumer_root + ) + + assert consumer_build.returncode == 0, consumer_build.stdout + consumer_build.stderr + consumer_init = (consumer_root / "src/consumer_pkg/_init_consumer.py").read_text( + encoding="utf-8" + ) + assert "import provider_pkg._init_provider" in consumer_init + + consumer_wheel = next((consumer_root / "dist").glob("*.whl")) + project_factory.env.install_wheel(consumer_wheel) + + result = project_factory.run( + "-c", + "import consumer_pkg; print(consumer_pkg.consumer_twice_plus_one(10))", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "21" diff --git a/tests/test_integration_wheel.py b/tests/test_integration_wheel.py new file mode 100644 index 0000000..ea3fcc8 --- /dev/null +++ b/tests/test_integration_wheel.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys + + +def shared_library_filename(name: str) -> str: + if sys.platform == "win32": + return f"{name}.dll" + if sys.platform == "darwin": + return f"lib{name}.dylib" + return f"lib{name}.so" + + +def test_built_wheel_contains_generated_artifacts(project_factory) -> None: + project_factory.copy_package_fixture("demo-shared") + project_factory.write( + f"src/demo_pkg/lib/{shared_library_filename('demo')}", + "not-a-real-library", + ) + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + wheel = project_factory.dist_wheels()[0] + contents = project_factory.wheel_contents(wheel) + assert "demo_pkg/demo.pc" in contents + assert "demo_pkg/_init_demo.py" in contents + + +def test_installed_wheel_contains_generated_package_files(project_factory) -> None: + project_factory.copy_package_fixture("demo-shared") + project_factory.write( + f"src/demo_pkg/lib/{shared_library_filename('demo')}", + "not-a-real-library", + ) + + result = project_factory.build_wheel() + + assert result.returncode == 0, result.stdout + result.stderr + wheel = project_factory.dist_wheels()[0] + project_factory.env.install_wheel(wheel) + + package_root = project_factory.installed_package_root("demo_pkg") + assert (package_root / "demo.pc").is_file() + assert (package_root / "_init_demo.py").is_file() diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..4915a9d --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from hatch_nativelib.util import maybe_write_file + + +def test_maybe_write_file_writes_new_file(tmp_path) -> None: + path = tmp_path / "demo.txt" + + assert maybe_write_file(path, "hello") is True + assert path.read_text(encoding="utf-8") == "hello" + + +def test_maybe_write_file_creates_parent_directories(tmp_path) -> None: + path = tmp_path / "nested" / "demo.txt" + + assert maybe_write_file(path, "hello") is True + assert path.read_text(encoding="utf-8") == "hello" + + +def test_maybe_write_file_returns_false_when_content_is_unchanged(tmp_path) -> None: + path = tmp_path / "demo.txt" + path.write_text("hello", encoding="utf-8") + + assert maybe_write_file(path, "hello") is False + + +def test_maybe_write_file_returns_true_when_content_changes(tmp_path) -> None: + path = tmp_path / "demo.txt" + path.write_text("before", encoding="utf-8") + + assert maybe_write_file(path, "after") is True + assert path.read_text(encoding="utf-8") == "after" diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..c8f7346 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import pytest + +from hatch_nativelib.config import PcFileConfig +from hatch_nativelib.validate import ValidationError, parse_input + + +def test_parse_input_builds_pcfile_config() -> None: + cfg = parse_input( + {"pcfile": "src/demo_pkg/demo.pc", "description": "Demo package"}, + PcFileConfig, + "pyproject.toml", + "tool.hatch.build.hooks.nativelib.pcfile[0]", + ) + + assert cfg == PcFileConfig( + pcfile="src/demo_pkg/demo.pc", + description="Demo package", + ) + + +def test_parse_input_wraps_validation_errors_with_context() -> None: + with pytest.raises(ValidationError) as excinfo: + parse_input( + {"description": "missing pcfile"}, + PcFileConfig, + "pyproject.toml", + "tool.hatch.build.hooks.nativelib.pcfile[0]", + ) + + message = str(excinfo.value) + assert "pyproject.toml: tool.hatch.build.hooks.nativelib.pcfile[0]" in message + assert "pcfile" in message