diff --git a/.github/workflows/build-rtc.yml b/.github/workflows/build-rtc.yml index d457eeef..52b8c10b 100644 --- a/.github/workflows/build-rtc.yml +++ b/.github/workflows/build-rtc.yml @@ -79,18 +79,13 @@ jobs: with: submodules: true - - uses: actions/setup-python@v4 - - - name: Install cibuildwheel - if: runner.os != 'macOS' - run: python3 -m pip install cibuildwheel==2.17.0 - - - name: Install cibuildwheel on macOS - if: runner.os == 'macOS' - run: python3 -m pip install --break-system-packages cibuildwheel==2.17.0 + - uses: actions/setup-python@v5 + id: setup-python + with: + python-version: "3.11" - name: Build wheels - run: python3 -m cibuildwheel --output-dir dist + run: pipx run --python '${{ steps.setup-python.outputs.python-path }}' cibuildwheel==3.3.1 --output-dir dist env: CIBW_ARCHS: ${{ matrix.archs }} @@ -120,9 +115,57 @@ jobs: name: rtc-release-sdist path: livekit-rtc/dist/*.tar.gz + + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) + needs: [build_wheels] + strategy: + fail-fast: false + matrix: + include: + # Linux x86_64 tests + - os: ubuntu-latest + python-version: "3.9" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.10" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.11" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.12" + artifact: rtc-release-ubuntu-latest + - os: ubuntu-latest + python-version: "3.13" + artifact: rtc-release-ubuntu-latest + # macOS tests (arm64 runner) + - os: macos-latest + python-version: "3.9" + artifact: rtc-release-macos-latest + - os: macos-latest + python-version: "3.12" + artifact: rtc-release-macos-latest + # Windows tests + - os: windows-latest + python-version: "3.9" + artifact: rtc-release-windows-latest + - os: windows-latest + python-version: "3.12" + artifact: rtc-release-windows-latest + uses: ./.github/workflows/tests.yml + with: + os: ${{ matrix.os }} + python-version: ${{ matrix.python-version }} + artifact-name: ${{ matrix.artifact }} + secrets: + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + publish: name: Publish RTC release - needs: [build_wheels, make_sdist] + needs: [build_wheels, make_sdist, test] runs-on: ubuntu-latest permissions: id-token: write diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96a82017..a7c74e10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,45 +1,140 @@ name: Tests on: - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: + workflow_call: + inputs: + os: + description: "Runner OS (e.g., ubuntu-latest, macos-latest, windows-latest)" + required: true + type: string + python-version: + description: "Python version to test" + required: true + type: string + artifact-name: + description: "Name of the wheel artifact to download" + required: true + type: string + run-id: + description: "Workflow run ID to download artifacts from (optional, uses current run if not specified)" + required: false + type: string + secrets: + LIVEKIT_URL: + required: true + LIVEKIT_API_KEY: + required: true + LIVEKIT_API_SECRET: + required: true jobs: - tests: - name: Run tests - runs-on: ubuntu-latest + test: + name: Test (${{ inputs.os }}, Python ${{ inputs.python-version }}) + runs-on: ${{ inputs.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: submodules: true lfs: true - - name: Install uv + - uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + + - name: Install uv uses: astral-sh/setup-uv@v5 with: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install the project - run: uv sync --all-extras --dev + - name: Download livekit-rtc wheel (current run) + if: ${{ inputs.run-id == '' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: rtc-wheel - - uses: actions/setup-python@v6 + - name: Download livekit-rtc wheel (from specific run) + if: ${{ inputs.run-id != '' }} + uses: actions/download-artifact@v4 with: - python-version: '3.13' + name: ${{ inputs.artifact-name }} + path: rtc-wheel + run-id: ${{ inputs.run-id }} + github-token: ${{ github.token }} + + - name: Select compatible wheel (macOS) + if: runner.os == 'macOS' + id: select-wheel-macos + run: | + # macOS artifacts contain both x86_64 and arm64 wheels, select the right one + WHEEL=$(python3 -c " + import glob + import platform + import sys + + wheels = glob.glob('rtc-wheel/*.whl') + machine = platform.machine().lower() + + arch_map = { + 'x86_64': ['x86_64'], + 'arm64': ['arm64'], + } + patterns = arch_map.get(machine, [machine]) + + for wheel in wheels: + wheel_lower = wheel.lower() + if any(p in wheel_lower for p in patterns): + print(wheel) + sys.exit(0) + + print(f'No matching wheel found for {machine}', file=sys.stderr) + sys.exit(1) + ") + echo "wheel=$WHEEL" >> $GITHUB_OUTPUT - - name: Run tests + - name: Create venv and install dependencies (Unix) + if: runner.os == 'Linux' + run: | + uv venv .test-venv + source .test-venv/bin/activate + uv pip install rtc-wheel/*.whl ./livekit-api ./livekit-protocol + uv pip install pytest pytest-asyncio numpy matplotlib + + - name: Create venv and install dependencies (macOS) + if: runner.os == 'macOS' + run: | + uv venv .test-venv + source .test-venv/bin/activate + uv pip install "${{ steps.select-wheel-macos.outputs.wheel }}" + uv pip install ./livekit-api ./livekit-protocol + uv pip install pytest pytest-asyncio numpy matplotlib + + - name: Create venv and install dependencies (Windows) + if: runner.os == 'Windows' + run: | + uv venv .test-venv + $wheel = (Get-ChildItem rtc-wheel\*.whl)[0].FullName + uv pip install --python .test-venv $wheel .\livekit-api .\livekit-protocol + uv pip install --python .test-venv pytest pytest-asyncio numpy matplotlib + shell: pwsh + + - name: Run tests (Unix) + if: runner.os != 'Windows' env: LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} run: | - - uv run python ./livekit-rtc/rust-sdks/download_ffi.py --output livekit-rtc/livekit/rtc/resources - uv add ./livekit-rtc ./livekit-api ./livekit-protocol - uv run pytest . --ignore=livekit-rtc/rust-sdks + source .test-venv/bin/activate + pytest tests/ + + - name: Run tests (Windows) + if: runner.os == 'Windows' + env: + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + run: .test-venv\Scripts\python.exe -m pytest tests/ + shell: pwsh \ No newline at end of file diff --git a/livekit-rtc/hatch_build.py b/livekit-rtc/hatch_build.py deleted file mode 100644 index 839d3072..00000000 --- a/livekit-rtc/hatch_build.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2023 LiveKit, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from hatchling.builders.hooks.plugin.interface import BuildHookInterface - - -class CustomBuildHook(BuildHookInterface): - def initialize(self, version, build_data): - """Force platform-specific wheel due to native libraries""" - build_data["pure_python"] = False - build_data["infer_tag"] = True diff --git a/livekit-rtc/pyproject.toml b/livekit-rtc/pyproject.toml index 6218730d..5ba944dd 100644 --- a/livekit-rtc/pyproject.toml +++ b/livekit-rtc/pyproject.toml @@ -1,6 +1,10 @@ [build-system] -requires = ["hatchling", "requests"] -build-backend = "hatchling.build" +requires = [ + "setuptools>=61", + "wheel", + "requests", +] +build-backend = "setuptools.build_meta" [project] name = "livekit" @@ -35,34 +39,34 @@ Documentation = "https://docs.livekit.io" Website = "https://livekit.io/" Source = "https://github.com/livekit/python-sdks/" -[tool.hatch.version] -path = "livekit/rtc/version.py" - -[tool.hatch.build.targets.wheel] -packages = ["livekit"] -artifacts = [ - "livekit/rtc/resources/*.so", - "livekit/rtc/resources/*.dylib", - "livekit/rtc/resources/*.dll", -] +[tool.setuptools.dynamic] +version = { attr = "livekit.rtc.version.__version__" } -[tool.hatch.build.targets.wheel.hooks.custom] -path = "hatch_build.py" +[tool.setuptools.packages.find] +include = ["livekit.*"] -[tool.hatch.build.targets.sdist] -include = ["/livekit", "/rust-sdks"] +[tool.setuptools.package-data] +"livekit.rtc" = ["_proto/*.py", "py.typed", "*.pyi", "**/*.pyi"] +"livekit.rtc.resources" = [ + "*.so", + "*.dylib", + "*.dll", + "LICENSE.md", + "*.h", + "jupyter-html/index.html", +] [tool.cibuildwheel] build = "cp39-*" -skip = "*-musllinux_*" # not supported (libwebrtc is using glibc) - +skip = "*-musllinux_*" # not supported (libwebrtc requires glibc) before-build = "pip install requests && python rust-sdks/download_ffi.py --output livekit/rtc/resources" -manylinux-x86_64-image = "manylinux_2_28" -manylinux-i686-image = "manylinux_2_28" -manylinux-aarch64-image = "manylinux_2_28" -manylinux-ppc64le-image = "manylinux_2_28" -manylinux-s390x-image = "manylinux_2_28" -manylinux-pypy_x86_64-image = "manylinux_2_28" -manylinux-pypy_i686-image = "manylinux_2_28" -manylinux-pypy_aarch64-image = "manylinux_2_28" \ No newline at end of file +# macOS deployment targets must match the FFI binaries (see rust-sdks/.github/workflows/ffi-builds.yml) +# x86_64 supports macOS 10.15+, arm64 requires macOS 11.0+ +[[tool.cibuildwheel.overrides]] +select = "*macosx_x86_64" +environment = { MACOSX_DEPLOYMENT_TARGET = "10.15" } + +[[tool.cibuildwheel.overrides]] +select = "*macosx_arm64" +environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" } diff --git a/livekit-rtc/setup.py b/livekit-rtc/setup.py new file mode 100644 index 00000000..279e8a19 --- /dev/null +++ b/livekit-rtc/setup.py @@ -0,0 +1,77 @@ +# Copyright 2023 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Custom setup.py for platform-specific wheel tagging. + +This file exists solely to customize the wheel platform tag. All package metadata +is defined in pyproject.toml. + +The native FFI libraries (.so/.dylib/.dll) require specific platform tags that +respect MACOSX_DEPLOYMENT_TARGET and ARCHFLAGS environment variables set by +cibuildwheel, rather than using sysconfig.get_platform() which returns Python's +compile-time values. +""" + +import os +import platform +import sys + +import setuptools # type: ignore +from wheel.bdist_wheel import bdist_wheel as _bdist_wheel # type: ignore + + +def get_platform_tag(): + """Get the wheel platform tag for the current/target platform.""" + if sys.platform == "darwin": + # Get deployment target from environment (set by cibuildwheel) or fall back + target = os.environ.get("MACOSX_DEPLOYMENT_TARGET") + if not target: + target = platform.mac_ver()[0] + parts = target.split(".") + target = f"{parts[0]}.{parts[1] if len(parts) > 1 else '0'}" + + version_tag = target.replace(".", "_") + + # Check ARCHFLAGS for cross-compilation (cibuildwheel sets this) + archflags = os.environ.get("ARCHFLAGS", "") + if "-arch arm64" in archflags: + arch = "arm64" + elif "-arch x86_64" in archflags: + arch = "x86_64" + else: + arch = platform.machine() + + return f"macosx_{version_tag}_{arch}" + elif sys.platform == "linux": + return f"linux_{platform.machine()}" + elif sys.platform == "win32": + arch = platform.machine() + if arch == "AMD64": + arch = "amd64" + return f"win_{arch}" + else: + return f"{platform.system().lower()}_{platform.machine()}" + + +class bdist_wheel(_bdist_wheel): + def finalize_options(self): + self.plat_name = get_platform_tag() + _bdist_wheel.finalize_options(self) + + +setuptools.setup( + cmdclass={ + "bdist_wheel": bdist_wheel, + }, +) diff --git a/pyproject.toml b/pyproject.toml index ef507d5f..467a9a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ convention = "google" [tool.pytest.ini_options] +testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" addopts = ["--import-mode=importlib", "--ignore=examples"] diff --git a/livekit-api/tests/test_access_token.py b/tests/api/test_access_token.py similarity index 100% rename from livekit-api/tests/test_access_token.py rename to tests/api/test_access_token.py diff --git a/livekit-api/tests/test_webhook.py b/tests/api/test_webhook.py similarity index 100% rename from livekit-api/tests/test_webhook.py rename to tests/api/test_webhook.py diff --git a/livekit-rtc/tests/.gitattributes b/tests/rtc/fixtures/.gitattributes similarity index 100% rename from livekit-rtc/tests/.gitattributes rename to tests/rtc/fixtures/.gitattributes diff --git a/livekit-rtc/tests/test_audio.wav b/tests/rtc/fixtures/test_audio.wav similarity index 100% rename from livekit-rtc/tests/test_audio.wav rename to tests/rtc/fixtures/test_audio.wav diff --git a/livekit-rtc/tests/test_echo_capture.wav b/tests/rtc/fixtures/test_echo_capture.wav similarity index 100% rename from livekit-rtc/tests/test_echo_capture.wav rename to tests/rtc/fixtures/test_echo_capture.wav diff --git a/livekit-rtc/tests/test_echo_render.wav b/tests/rtc/fixtures/test_echo_render.wav similarity index 100% rename from livekit-rtc/tests/test_echo_render.wav rename to tests/rtc/fixtures/test_echo_render.wav diff --git a/livekit-rtc/tests/test_processed.wav b/tests/rtc/fixtures/test_processed.wav similarity index 100% rename from livekit-rtc/tests/test_processed.wav rename to tests/rtc/fixtures/test_processed.wav diff --git a/livekit-rtc/tests/test_apm.py b/tests/rtc/test_apm.py similarity index 89% rename from livekit-rtc/tests/test_apm.py rename to tests/rtc/test_apm.py index da3d7ab1..94795e3c 100644 --- a/livekit-rtc/tests/test_apm.py +++ b/tests/rtc/test_apm.py @@ -4,16 +4,18 @@ from livekit.rtc import AudioProcessingModule, AudioFrame +# Test fixture directory +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + def test_audio_processing(): sample_rate = 48000 num_channels = 1 frames_per_chunk = sample_rate // 100 - current_dir = os.path.dirname(os.path.abspath(__file__)) - capture_wav = os.path.join(current_dir, "test_echo_capture.wav") - render_wav = os.path.join(current_dir, "test_echo_render.wav") - output_wav = os.path.join(current_dir, "test_processed.wav") + capture_wav = os.path.join(FIXTURES_DIR, "test_echo_capture.wav") + render_wav = os.path.join(FIXTURES_DIR, "test_echo_render.wav") + output_wav = os.path.join(FIXTURES_DIR, "test_processed.wav") # Initialize APM with echo cancellation enabled apm = AudioProcessingModule( diff --git a/livekit-rtc/tests/test_e2e.py b/tests/rtc/test_e2e.py similarity index 100% rename from livekit-rtc/tests/test_e2e.py rename to tests/rtc/test_e2e.py diff --git a/livekit-rtc/tests/test_emitter.py b/tests/rtc/test_emitter.py similarity index 100% rename from livekit-rtc/tests/test_emitter.py rename to tests/rtc/test_emitter.py diff --git a/livekit-rtc/tests/test_mixer.py b/tests/rtc/test_mixer.py similarity index 89% rename from livekit-rtc/tests/test_mixer.py rename to tests/rtc/test_mixer.py index 086fa0cf..1ab75f4b 100644 --- a/livekit-rtc/tests/test_mixer.py +++ b/tests/rtc/test_mixer.py @@ -2,7 +2,6 @@ import numpy as np import pytest -import matplotlib.pyplot as plt from livekit.rtc import AudioMixer from livekit.rtc.utils import sine_wave_generator @@ -43,13 +42,6 @@ async def test_mixer_two_sine_waves(): mixed_signal = np.concatenate(mixed_signals) - plt.figure(figsize=(10, 4)) - plt.plot(mixed_signal[:1000]) - plt.title("Mixed Signal") - plt.xlabel("Sample") - plt.ylabel("Amplitude") - plt.show() - # Use FFT to analyze frequency components. fft = np.fft.rfft(mixed_signal) freqs = np.fft.rfftfreq(len(mixed_signal), 1 / SAMPLE_RATE) diff --git a/livekit-rtc/tests/test_resampler.py b/tests/rtc/test_resampler.py similarity index 91% rename from livekit-rtc/tests/test_resampler.py rename to tests/rtc/test_resampler.py index 83108de3..25079da8 100644 --- a/livekit-rtc/tests/test_resampler.py +++ b/tests/rtc/test_resampler.py @@ -3,10 +3,12 @@ import wave import os +# Test fixture directory +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + def test_audio_resampler(): - current_dir = os.path.dirname(os.path.abspath(__file__)) - wav_file_path = os.path.join(current_dir, "test_audio.wav") + wav_file_path = os.path.join(FIXTURES_DIR, "test_audio.wav") # Open the wave file with wave.open(wav_file_path, "rb") as wf_in: