From a9c2ba0709b21a9101b65ab14658fe127fc4533d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:12:03 +0000 Subject: [PATCH 1/7] py: split moq-ffi bindings from the ergonomic wrapper into two packages Publish the raw uniffi bindings (`moq-ffi`, import `moq_ffi`) and the ergonomic wrapper (`moq`, import `moq`) as separate PyPI distributions so the wrapper can be versioned independently of the moq-ffi crate. Previously a single `moq-rs` wheel bundled both layers, forcing a wrapper re-release on every moq-ffi crate bump and forcing a crate bump to ship any wrapper change. The "can't split uniffi libraries across wheels" constraint only forbids splitting the native library itself; a pure-source wrapper layered on top of one FFI package is fine. - py/moq-ffi: maturin project, dist `moq-ffi`, tracks rs/moq-ffi via the existing moq-ffi-v* tag (release-py.yml). - py/moq: pure-python wrapper, dist `moq`, depends on `moq-ffi ~= 0.2.16` so it floats to the latest moq-ffi patch without a re-release. Versioned by hand; released on a moq-py-v* tag (new release-py-wrapper.yml). - Workspace root renamed to moq-workspace to free the `moq` name; pytest asyncio config moved to the root so both members share it; members listed explicitly so build output (py/dist) isn't treated as a member. - Updated CLAUDE.md, doc/lib/py, kt/README, test/smoke.sh, and the py justfile (new `package` recipe) for the two-package layout. https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/workflows/release-py-wrapper.yml | 98 ++++++++++++++++++++++ .github/workflows/release-py.yml | 23 ++--- .gitignore | 4 +- CLAUDE.md | 20 +++-- doc/.vitepress/config.ts | 2 +- doc/lib/index.md | 2 +- doc/lib/py/index.md | 35 ++++---- doc/lib/py/{moq-net.md => moq.md} | 19 +++-- kt/README.md | 2 +- py/justfile | 39 ++++++--- py/moq-ffi/README.md | 28 +++++++ py/moq-ffi/moq_ffi/__init__.py | 11 +++ py/{moq-rs => moq-ffi}/pyproject.toml | 17 ++-- py/moq-ffi/tests/test_smoke.py | 15 ++++ py/{moq-rs => moq}/README.md | 14 ++-- py/{moq-rs => moq}/examples/announced.py | 4 +- py/{moq-rs => moq}/examples/clock.py | 4 +- py/{moq-rs => moq}/examples/serve_clock.py | 4 +- py/{moq-rs => moq}/examples/smoke.py | 0 py/{moq-rs => moq}/moq/__init__.py | 3 +- py/{moq-rs => moq}/moq/client.py | 3 +- py/{moq-rs => moq}/moq/origin.py | 3 +- py/{moq-rs => moq}/moq/publish.py | 3 +- py/{moq-rs => moq}/moq/py.typed | 0 py/{moq-rs => moq}/moq/server.py | 3 +- py/{moq-rs => moq}/moq/subscribe.py | 3 +- py/{moq-rs => moq}/moq/types.py | 22 ++--- py/moq/pyproject.toml | 34 ++++++++ py/{moq-rs => moq}/tests/test_local.py | 0 py/{moq-rs => moq}/tests/test_server.py | 2 +- pyproject.toml | 13 ++- test/smoke.sh | 10 ++- uv.lock | 22 +++-- 33 files changed, 349 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/release-py-wrapper.yml rename doc/lib/py/{moq-net.md => moq.md} (77%) create mode 100644 py/moq-ffi/README.md create mode 100644 py/moq-ffi/moq_ffi/__init__.py rename py/{moq-rs => moq-ffi}/pyproject.toml (67%) create mode 100644 py/moq-ffi/tests/test_smoke.py rename py/{moq-rs => moq}/README.md (87%) rename py/{moq-rs => moq}/examples/announced.py (86%) rename py/{moq-rs => moq}/examples/clock.py (93%) rename py/{moq-rs => moq}/examples/serve_clock.py (95%) rename py/{moq-rs => moq}/examples/smoke.py (100%) rename py/{moq-rs => moq}/moq/__init__.py (97%) rename py/{moq-rs => moq}/moq/client.py (99%) rename py/{moq-rs => moq}/moq/origin.py (95%) rename py/{moq-rs => moq}/moq/publish.py (99%) rename py/{moq-rs => moq}/moq/py.typed (100%) rename py/{moq-rs => moq}/moq/server.py (99%) rename py/{moq-rs => moq}/moq/subscribe.py (99%) rename py/{moq-rs => moq}/moq/types.py (72%) create mode 100644 py/moq/pyproject.toml rename py/{moq-rs => moq}/tests/test_local.py (100%) rename py/{moq-rs => moq}/tests/test_server.py (99%) diff --git a/.github/workflows/release-py-wrapper.yml b/.github/workflows/release-py-wrapper.yml new file mode 100644 index 000000000..a025a2c95 --- /dev/null +++ b/.github/workflows/release-py-wrapper.yml @@ -0,0 +1,98 @@ +name: Release Py Wrapper + +# Builds the pure-python `moq` wrapper wheel. Versioned independently of the +# moq-ffi crate: bump py/moq/pyproject.toml by hand and push a `moq-py-v` +# tag. The wrapper depends on `moq-ffi ~= 0.2.x`, so it floats to the latest +# compatible moq-ffi patch without a re-release. + +on: + push: + tags: + - "moq-py-v*" + pull_request: + paths: + - ".github/workflows/release-py-wrapper.yml" + - ".github/scripts/release.sh" + - "py/moq/**" + +permissions: + contents: read + +concurrency: + group: release-py-wrapper + cancel-in-progress: false + +jobs: + build: + name: Build wheel + sdist + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Parse version + id: parse + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [[ "$EVENT_NAME" == "pull_request" ]]; then + PY_VERSION=$(grep '^version' py/moq/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$PY_VERSION" >> "$GITHUB_OUTPUT" + echo "Using version from pyproject.toml (PR dry-run): $PY_VERSION" + else + .github/scripts/release.sh parse-version moq-py + fi + + # The wrapper version is static in pyproject.toml. If a tag is pushed + # without bumping it, the wheel would ship the previous version and PyPI + # would reject the duplicate upload. + - name: Verify pyproject.toml matches tag + if: github.event_name == 'push' + env: + TAG_VERSION: ${{ steps.parse.outputs.version }} + run: | + PY_VERSION=$(grep '^version' py/moq/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [[ "$TAG_VERSION" != "$PY_VERSION" ]]; then + echo "::error::Tag version ($TAG_VERSION) does not match py/moq/pyproject.toml version ($PY_VERSION). Bump pyproject.toml before tagging." + exit 1 + fi + echo "pyproject.toml version matches tag: $TAG_VERSION" + + - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main + with: + determinate: false + - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main + + - name: Build + run: nix develop --command just py package + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-wrapper + path: py/dist/* + + publish: + name: Publish to PyPI + needs: [build] + runs-on: ubuntu-latest + if: github.event_name == 'push' + environment: + name: pypi + url: https://pypi.org/p/moq + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-wrapper + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index b5b3793ef..f86ee96e1 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -1,9 +1,10 @@ -name: Release Py +name: Release Py FFI -# Driven by the `moq-ffi-v*` tag that release-plz pushes when it bumps -# rs/moq-ffi/Cargo.toml. The wheel inherits its version from that same -# Cargo.toml (via maturin's dynamic version), so the moq-rs package -# stays lockstep with the moq-ffi crate without a separate bump. +# Builds the `moq-ffi` wheel (raw uniffi bindings). Driven by the +# `moq-ffi-v*` tag that release-plz pushes when it bumps rs/moq-ffi/Cargo.toml. +# The wheel inherits its version from that same Cargo.toml (via maturin's +# dynamic version), so the moq-ffi package stays lockstep with the moq-ffi +# crate. The ergonomic `moq` wrapper is released separately (release-py-wrapper.yml). on: push: @@ -99,9 +100,9 @@ jobs: - name: Build wheels uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 with: - # py/moq-rs/pyproject.toml drives the build; its [tool.maturin] + # py/moq-ffi/pyproject.toml drives the build; its [tool.maturin] # block points at rs/moq-ffi/Cargo.toml. - working-directory: py/moq-rs + working-directory: py/moq-ffi args: --release --out dist target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux || 'auto' }} @@ -111,7 +112,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-${{ matrix.target }} - path: py/moq-rs/dist/*.whl + path: py/moq-ffi/dist/*.whl sdist: name: Build sdist @@ -130,7 +131,7 @@ jobs: - name: Build sdist uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 with: - working-directory: py/moq-rs + working-directory: py/moq-ffi command: sdist args: --out dist @@ -138,7 +139,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-sdist - path: py/moq-rs/dist/*.tar.gz + path: py/moq-ffi/dist/*.tar.gz publish: name: Publish to PyPI @@ -147,7 +148,7 @@ jobs: if: github.event_name == 'push' environment: name: pypi - url: https://pypi.org/p/moq-rs + url: https://pypi.org/p/moq-ffi permissions: id-token: write diff --git a/.gitignore b/.gitignore index 7517ef099..5b4bdd9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,5 @@ __pycache__/ .venv/ # maturin develop drops uniffi bindings + cdylib here during editable -# installs of py/moq-rs. -/py/moq-rs/moq/_uniffi/ +# installs of the py/moq-ffi package. +/py/moq-ffi/moq_ffi/_uniffi/ diff --git a/CLAUDE.md b/CLAUDE.md index d505fcf76..601204fc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,14 +66,18 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi moq-boy/ # MoQ Boy web viewer (published as @moq/boy) /py/ # Python packages (uv workspace) - moq-rs/ # Maturin project: bundles rs/moq-ffi cdylib + uniffi - # bindings as moq._uniffi. Distribution name is - # moq-rs (PyPI); import name is `moq`. Version tracks - # rs/moq-ffi (release-py.yml fires on moq-ffi-v* - # tags). One umbrella wheel covers every crate - # exposed via moq-ffi because uniffi-linked - # libraries can't be split across separately - # packaged Python wheels. + moq-ffi/ # Maturin project: rs/moq-ffi cdylib + uniffi bindings. + # Distribution `moq-ffi` (PyPI); import `moq_ffi`. One + # wheel covers every crate exposed via moq-ffi because + # uniffi-linked libraries can't be split across separate + # wheels. Version tracks rs/moq-ffi (release-py.yml fires + # on moq-ffi-v* tags). Most callers want `moq`, not this. + moq/ # Pure-python ergonomic wrapper. Distribution `moq` + # (PyPI); import `moq`. Depends on moq-ffi via a + # compatible-release pin (~=0.2.x) so it floats to the + # latest moq-ffi patch. Versioned independently: bump + # py/moq/pyproject.toml by hand, push a moq-py-v* tag + # (release-py-wrapper.yml). /swift/ # Swift wrapper over rs/moq-ffi (SwiftPM) /kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP) diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 9b550e835..951d837bc 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -215,7 +215,7 @@ export default defineConfig({ { text: "Python", link: "/lib/py/", - items: [{ text: "moq-net", link: "/lib/py/moq-net" }], + items: [{ text: "moq", link: "/lib/py/moq" }], }, { text: "Kotlin", diff --git a/doc/lib/index.md b/doc/lib/index.md index 20b233219..63bad2a5b 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -39,7 +39,7 @@ Raw C bindings via `libmoq`. The lowest-level entry point and the foundation for ### [Python](/lib/py/) -Async/await with `asyncio`. Published as [`moq-net`](https://pypi.org/project/moq-net/) on PyPI. +Async/await with `asyncio`. Published as [`moq`](https://pypi.org/project/moq/) on PyPI (the ergonomic wrapper), atop the raw [`moq-ffi`](https://pypi.org/project/moq-ffi/) bindings. ### [Kotlin](/lib/kt/) diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 2c1c10fff..95bd92123 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -9,34 +9,35 @@ The Python bindings expose [Media over QUIC](/) to scripts, services, and protot ## Packages -### moq-net +Two packages, split so the ergonomic API can evolve on its own cadence: -[![PyPI](https://img.shields.io/pypi/v/moq-net)](https://pypi.org/project/moq-net/) +### moq -The networking layer for MoQ in Python: real-time pub/sub with built-in caching, fan-out, and prioritization on top of QUIC. At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. +[![PyPI](https://img.shields.io/pypi/v/moq)](https://pypi.org/project/moq/) -**Features:** +The package you want. Real-time pub/sub with built-in caching, fan-out, and prioritization on top of QUIC, with a Pythonic API (no `Moq` prefixes, async context managers, async iterators). At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. -- Async context managers and async iterators throughout -- Native QUIC via the Rust [`moq-net`](/lib/rs/crate/moq-net) crate -- WebCodecs-style catalog + container format via [`hang`](/lib/rs/crate/hang) -- Pythonic API with no `Moq` prefixes ([more details](/lib/py/moq-net)) +It is pure Python and depends on `moq-ffi` via a compatible-release pin, so it floats to the latest `moq-ffi` patch automatically. It is versioned independently of the Rust crates. -[Learn more](/lib/py/moq-net) +### moq-ffi + +[![PyPI](https://img.shields.io/pypi/v/moq-ffi)](https://pypi.org/project/moq-ffi/) + +The raw UniFFI bindings (the `Moq`-prefixed classes), tracking the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate one-to-one. `moq` pulls this in for you. Install it directly only if you need the unwrapped API or are building your own wrapper. ## Installation ```bash -pip install moq-net +pip install moq ``` -Prebuilt wheels are published for: +This pulls in `moq-ffi`, for which prebuilt wheels are published for: - Linux x86_64 / aarch64 (manylinux_2_28) - macOS x86_64 / aarch64 - Windows x86_64 -For other platforms (Alpine, BSD, etc.) `pip` falls back to building from source via the published sdist. You'll need a Rust toolchain and a C compiler. +For other platforms (Alpine, BSD, etc.) `pip` falls back to building `moq-ffi` from source via the published sdist. You'll need a Rust toolchain and a C compiler. ## Quickstart @@ -44,7 +45,7 @@ For other platforms (Alpine, BSD, etc.) `pip` falls back to building from source ```python import asyncio -import moq_net as moq +import moq async def main(): async with moq.Client("https://relay.quic.video") as client: @@ -62,7 +63,7 @@ asyncio.run(main()) ```python import asyncio -import moq_net as moq +import moq async def main(): async with moq.Client("https://relay.quic.video") as client: @@ -81,6 +82,6 @@ asyncio.run(main()) ## Source and issues -- Source: [py/moq-net](https://github.com/moq-dev/moq/tree/main/py/moq-net) -- README: [py/moq-net/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-net/README.md) -- Example scripts: [py/moq-net/examples](https://github.com/moq-dev/moq/tree/main/py/moq-net/examples) +- Source: [py/moq](https://github.com/moq-dev/moq/tree/main/py/moq) (wrapper), [py/moq-ffi](https://github.com/moq-dev/moq/tree/main/py/moq-ffi) (raw bindings) +- README: [py/moq/README.md](https://github.com/moq-dev/moq/blob/main/py/moq/README.md) +- Example scripts: [py/moq/examples](https://github.com/moq-dev/moq/tree/main/py/moq/examples) diff --git a/doc/lib/py/moq-net.md b/doc/lib/py/moq.md similarity index 77% rename from doc/lib/py/moq-net.md rename to doc/lib/py/moq.md index 5d1a7ae54..465aaa7c6 100644 --- a/doc/lib/py/moq-net.md +++ b/doc/lib/py/moq.md @@ -1,23 +1,23 @@ --- -title: moq-net (Python) +title: moq (Python) description: Python pub/sub for Media over QUIC --- -# moq-net +# moq -[![PyPI](https://img.shields.io/pypi/v/moq-net)](https://pypi.org/project/moq-net/) +[![PyPI](https://img.shields.io/pypi/v/moq)](https://pypi.org/project/moq/) Async pub/sub for [Media over QUIC](/) in Python. -The underlying transport is the Rust [`moq-net`](/lib/rs/crate/moq-net) crate, exposed through UniFFI and wrapped in a Pythonic API: no `Moq` prefixes on user-facing types, async iterators for streams, async context managers for sessions. +The underlying transport is the Rust [`moq-net`](/lib/rs/crate/moq-net) crate, exposed through UniFFI (the [`moq-ffi`](https://pypi.org/project/moq-ffi/) package) and wrapped in a Pythonic API: no `Moq` prefixes on user-facing types, async iterators for streams, async context managers for sessions. `moq` is versioned independently of `moq-ffi` and floats to the latest compatible patch. ## Install ```bash -pip install moq-net +pip install moq ``` -Requires Python 3.10+. +Requires Python 3.10+. This pulls in the `moq-ffi` native bindings automatically. ## Concepts @@ -92,13 +92,14 @@ broadcast = await client.announced_broadcast("live/cam1") ## Examples -The repo ships [example scripts](https://github.com/moq-dev/moq/tree/main/py/moq-net/examples) you can run end-to-end: +The repo ships [example scripts](https://github.com/moq-dev/moq/tree/main/py/moq/examples) you can run end-to-end: - `clock.py` — publishes / subscribes a clock track (one frame per second, one group per minute). - `announced.py` — lists broadcasts under a prefix as they're announced. ## See also -- Source: [py/moq-net](https://github.com/moq-dev/moq/tree/main/py/moq-net) -- README: [py/moq-net/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-net/README.md) +- Source: [py/moq](https://github.com/moq-dev/moq/tree/main/py/moq) +- README: [py/moq/README.md](https://github.com/moq-dev/moq/blob/main/py/moq/README.md) +- Raw bindings: [moq-ffi](https://pypi.org/project/moq-ffi/) - The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net) diff --git a/kt/README.md b/kt/README.md index 545e254dd..dcf76e276 100644 --- a/kt/README.md +++ b/kt/README.md @@ -66,7 +66,7 @@ kt/ scripts/ check.sh, package.sh, publish.sh ``` -The Kotlin module stays as a single `moq-ffi` artifact because uniffi-linked libraries can't be split across separately packaged wheels/artifacts. The Python wheel (`moq-rs`) follows the same umbrella shape for the same reason. +The Kotlin module stays as a single `moq-ffi` artifact because uniffi-linked libraries can't be split across separately packaged wheels/artifacts. That constraint is about splitting the native library itself; a pure-source wrapper layered on top is fine. Python already does this: the `moq-ffi` wheel carries the native bindings and the pure-python `moq` wheel wraps it with an independently versioned ergonomic API. Kotlin could follow the same shape (a `dev.moq:moq-ffi` artifact plus a `dev.moq:moq` wrapper) if it wants independent wrapper versioning. ## Publishing to Maven Central diff --git a/py/justfile b/py/justfile index c572b4efb..3df9dd4c6 100644 --- a/py/justfile +++ b/py/justfile @@ -2,21 +2,31 @@ # # Language-specific recipes for the Python packages under py/. Invoked # from the repo root as `just py ` via the `mod py` import. +# +# Two workspace members: +# moq-ffi/ maturin project: rs/moq-ffi cdylib + uniffi bindings (dist `moq-ffi`) +# moq/ pure-python ergonomic wrapper that depends on moq-ffi (dist `moq`) set working-directory := '.' default: just check -# Lint + format + maturin source build + pyright. `--no-install-workspace` -# installs the root dev group (ruff, maturin, pyright, pytest) without -# trying to pip-build moq-rs; `maturin develop` then installs moq-rs -# as an editable wheel with the rs/moq-ffi cdylib + uniffi bindings. +# Build moq-ffi (maturin cdylib + bindings) and install the pure-python moq +# wrapper, both editable into the workspace venv. `--no-deps` on the wrapper +# keeps uv from fetching moq-ffi off PyPI; maturin just installed it locally. +_develop: + cd moq-ffi && uv run --no-sync maturin develop --uv + uv pip install --no-deps -e moq + +# Lint + format + editable build + pyright. `--no-install-workspace` installs +# the root dev group (ruff, maturin, pyright, pytest) without trying to +# pip-build the workspace members; `_develop` then installs them editable. check: uv sync --no-install-workspace uv run --no-sync ruff check . uv run --no-sync ruff format --check . - cd moq-rs && uv run --no-sync maturin develop --uv + just _develop uv run --no-sync pyright fix: @@ -26,18 +36,25 @@ fix: test: uv sync --no-install-workspace - cd moq-rs && uv run --no-sync maturin develop --uv - uv run --no-sync pytest moq-rs/tests/ + just _develop + uv run --no-sync pytest moq/tests/ moq-ffi/tests/ -# Local dev build: produces an editable install of moq-rs (with the -# moq-ffi cdylib + uniffi bindings) into the workspace venv. +# Local dev build: editable install of moq-ffi (with the cdylib + uniffi +# bindings) and the moq wrapper into the workspace venv. build: uv sync --no-install-workspace - cd moq-rs && uv run --no-sync maturin develop --uv + just _develop + +# Build the pure-python moq wrapper sdist + wheel into py/dist (for release). +# moq-ffi is built separately by maturin (see release-py.yml); the wrapper is +# pure python so it needs no compilation, just a metadata-correct wheel. +package: + rm -rf dist + uv build --package moq --out-dir dist # Full Python CI: lint + tests + build. Takes a newline-separated list # of changed files; skips if FILES is non-empty and none match the -# Python scope (which includes rs/moq-ffi because py bundles it via +# Python scope (which includes rs/moq-ffi because moq-ffi bundles it via # maturin). Run `just py ci` (no FILES) to force-run everything. ci FILES="": #!/usr/bin/env bash diff --git a/py/moq-ffi/README.md b/py/moq-ffi/README.md new file mode 100644 index 000000000..ea8879d34 --- /dev/null +++ b/py/moq-ffi/README.md @@ -0,0 +1,28 @@ +# moq-ffi + +Raw [UniFFI](https://mozilla.github.io/uniffi-rs/) bindings for the [Media over QUIC](https://github.com/moq-dev/moq) Rust crates. + +This package is the native foundation: the compiled `moq-ffi` cdylib plus the auto-generated Python bindings, exposed exactly as uniffi-bindgen emits them (the `Moq`-prefixed classes). It tracks the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate version one-to-one. + +**Most callers want [`moq`](https://pypi.org/project/moq/) instead**, the ergonomic wrapper with a Pythonic API (no `Moq` prefixes, async iterators, context managers). Use `moq-ffi` directly only if you need the unwrapped API or are building your own wrapper. + +## Installation + +```bash +pip install moq-ffi +``` + +The distribution is `moq-ffi`; the import name is `moq_ffi`. + +```python +import moq_ffi + +client = moq_ffi.MoqClient() +session = await client.connect("https://relay.quic.video") +``` + +## See Also + +- [`moq`](https://pypi.org/project/moq/). The ergonomic wrapper most callers want. +- [`moq-ffi`](https://crates.io/crates/moq-ffi). The Rust crate that produces these bindings. +- [MoQ project](https://github.com/moq-dev/moq). Full monorepo with Rust server, TypeScript browser lib, and more. diff --git a/py/moq-ffi/moq_ffi/__init__.py b/py/moq-ffi/moq_ffi/__init__.py new file mode 100644 index 000000000..54d9a8dbf --- /dev/null +++ b/py/moq-ffi/moq_ffi/__init__.py @@ -0,0 +1,11 @@ +"""Raw UniFFI bindings for the Media over QUIC Rust crates. + +This package exposes the auto-generated bindings exactly as uniffi-bindgen +emits them (the `Moq`-prefixed classes). It is the native foundation that the +ergonomic `moq` wrapper builds on. Most callers want `moq`, not this. + +The compiled cdylib plus generated bindings live in the private `_uniffi` +submodule; everything public is re-exported here. +""" + +from ._uniffi import * # noqa: F401,F403 diff --git a/py/moq-rs/pyproject.toml b/py/moq-ffi/pyproject.toml similarity index 67% rename from py/moq-rs/pyproject.toml rename to py/moq-ffi/pyproject.toml index d76469545..3b7f6f532 100644 --- a/py/moq-rs/pyproject.toml +++ b/py/moq-ffi/pyproject.toml @@ -3,12 +3,12 @@ requires = ["maturin>=1.7,<2.0"] build-backend = "maturin" [project] -name = "moq-rs" +name = "moq-ffi" # Version is injected at build time from the moq-ffi-v* tag (see # .github/workflows/release-py.yml). For local dev `maturin develop` # reads it from rs/moq-ffi/Cargo.toml. dynamic = ["version"] -description = "Python bindings for the Media over QUIC Rust crates: real-time pub/sub with built-in caching, fan-out, and prioritization." +description = "Raw UniFFI bindings for the Media over QUIC Rust crates. Most callers want the ergonomic `moq` wrapper instead." readme = "README.md" license = "MIT OR Apache-2.0" requires-python = ">=3.10" @@ -26,16 +26,13 @@ classifiers = [ Homepage = "https://github.com/moq-dev/moq" Repository = "https://github.com/moq-dev/moq" -[tool.pytest.ini_options] -asyncio_mode = "auto" - # Maturin builds the rs/moq-ffi crate, runs uniffi-bindgen, and packages -# the cdylib + generated bindings as a private `moq._uniffi` subpackage -# (the leading underscore signals "internal"; the public API lives at the -# top of moq). The PyPI distribution is `moq-rs` but the import name is -# `moq` for ergonomics. +# the cdylib + generated bindings as a private `moq_ffi._uniffi` subpackage +# (the leading underscore signals "internal"; moq_ffi/__init__.py re-exports +# the raw classes). The ergonomic `moq` wrapper depends on this package and +# is versioned independently. [tool.maturin] bindings = "uniffi" manifest-path = "../../rs/moq-ffi/Cargo.toml" python-source = "." -module-name = "moq._uniffi" +module-name = "moq_ffi._uniffi" diff --git a/py/moq-ffi/tests/test_smoke.py b/py/moq-ffi/tests/test_smoke.py new file mode 100644 index 000000000..df2547f25 --- /dev/null +++ b/py/moq-ffi/tests/test_smoke.py @@ -0,0 +1,15 @@ +"""Smoke test: the raw uniffi bindings import and expose the core classes.""" + +import moq_ffi + + +def test_exports_core_classes(): + # A representative slice of the generated surface. If uniffi-bindgen output + # or module-name wiring breaks, these attributes disappear. + for name in ("MoqClient", "MoqServer", "MoqBroadcastProducer", "MoqError"): + assert hasattr(moq_ffi, name), f"moq_ffi missing {name}" + + +def test_client_constructs(): + client = moq_ffi.MoqClient() + assert client is not None diff --git a/py/moq-rs/README.md b/py/moq/README.md similarity index 87% rename from py/moq-rs/README.md rename to py/moq/README.md index c8c1eb2a0..efb868394 100644 --- a/py/moq-rs/README.md +++ b/py/moq/README.md @@ -1,16 +1,16 @@ -# moq-rs +# moq -Python bindings for the [Media over QUIC](https://github.com/moq-dev/moq) Rust crates: real-time pub/sub with built-in caching, fan-out, and prioritization, on top of QUIC. +Python bindings for [Media over QUIC](https://github.com/moq-dev/moq): real-time pub/sub with built-in caching, fan-out, and prioritization, on top of QUIC. -`moq-rs` wraps the auto-generated [moq-ffi](https://crates.io/crates/moq-ffi) UniFFI bindings with a Pythonic API: no `Moq` prefixes, async iterators, context managers, and simplified connection setup. At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. +`moq` wraps the auto-generated [`moq-ffi`](https://pypi.org/project/moq-ffi/) UniFFI bindings with a Pythonic API: no `Moq` prefixes, async iterators, context managers, and simplified connection setup. At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. ## Installation ```bash -pip install moq-rs +pip install moq ``` -The distribution is `moq-rs`; the import name is `moq`. +This pulls in the `moq-ffi` native bindings automatically. `moq` is pure Python and is versioned independently of `moq-ffi`; it floats to the latest compatible `moq-ffi` patch. ## Quick Start @@ -61,7 +61,7 @@ asyncio.run(main()) ```python import asyncio -import moq_lite as moq +import moq async def main(): async with moq.Server("127.0.0.1:4443", tls_generate=["localhost"]) as server: @@ -148,5 +148,5 @@ client = moq.Client( ## See Also -- [moq-ffi](https://crates.io/crates/moq-ffi). The Rust crate that produces the UniFFI bindings vendored as `moq._uniffi`. +- [`moq-ffi`](https://pypi.org/project/moq-ffi/). The raw UniFFI bindings this package wraps. Use it directly only if you need the unwrapped `Moq`-prefixed API. - [MoQ project](https://github.com/moq-dev/moq). Full monorepo with Rust server, TypeScript browser lib, and more. diff --git a/py/moq-rs/examples/announced.py b/py/moq/examples/announced.py similarity index 86% rename from py/moq-rs/examples/announced.py rename to py/moq/examples/announced.py index c6b28c0f7..369a2a457 100644 --- a/py/moq-rs/examples/announced.py +++ b/py/moq/examples/announced.py @@ -1,7 +1,7 @@ """List broadcasts announced on a relay under a given prefix. -python py/moq-rs/examples/announced.py --url https://relay.example.com -python py/moq-rs/examples/announced.py --url https://relay.example.com --prefix live/ +python py/moq/examples/announced.py --url https://relay.example.com +python py/moq/examples/announced.py --url https://relay.example.com --prefix live/ """ import argparse diff --git a/py/moq-rs/examples/clock.py b/py/moq/examples/clock.py similarity index 93% rename from py/moq-rs/examples/clock.py rename to py/moq/examples/clock.py index aa5e6e78c..e25c7615e 100644 --- a/py/moq-rs/examples/clock.py +++ b/py/moq/examples/clock.py @@ -4,8 +4,8 @@ frame of every group is the "YYYY-MM-DD HH:MM:" prefix, followed by one "SS" frame per second. - python py/moq-rs/examples/clock.py publish --url https://relay.example.com --broadcast clock - python py/moq-rs/examples/clock.py subscribe --url https://relay.example.com --broadcast clock + python py/moq/examples/clock.py publish --url https://relay.example.com --broadcast clock + python py/moq/examples/clock.py subscribe --url https://relay.example.com --broadcast clock """ import argparse diff --git a/py/moq-rs/examples/serve_clock.py b/py/moq/examples/serve_clock.py similarity index 95% rename from py/moq-rs/examples/serve_clock.py rename to py/moq/examples/serve_clock.py index a6400a7f1..08f710ae4 100644 --- a/py/moq-rs/examples/serve_clock.py +++ b/py/moq/examples/serve_clock.py @@ -6,8 +6,8 @@ Run a subscriber against it with TLS verification disabled, e.g.: - python py/moq-rs/examples/serve_clock.py --bind 127.0.0.1:4443 - python py/moq-rs/examples/clock.py subscribe \\ + python py/moq/examples/serve_clock.py --bind 127.0.0.1:4443 + python py/moq/examples/clock.py subscribe \\ --url https://127.0.0.1:4443 --broadcast clock --no-tls-verify """ diff --git a/py/moq-rs/examples/smoke.py b/py/moq/examples/smoke.py similarity index 100% rename from py/moq-rs/examples/smoke.py rename to py/moq/examples/smoke.py diff --git a/py/moq-rs/moq/__init__.py b/py/moq/moq/__init__.py similarity index 97% rename from py/moq-rs/moq/__init__.py rename to py/moq/moq/__init__.py index 14ca5e764..000059864 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq/moq/__init__.py @@ -3,7 +3,8 @@ Real-time pub/sub with built-in caching, fan-out, and prioritization. """ -from ._uniffi import MoqSession as Session +from moq_ffi import MoqSession as Session + from .client import Client from .origin import Announced, AnnouncedBroadcast, Announcement, OriginConsumer, OriginProducer from .publish import AudioProducer, BroadcastProducer, GroupProducer, MediaProducer, TrackProducer diff --git a/py/moq-rs/moq/client.py b/py/moq/moq/client.py similarity index 99% rename from py/moq-rs/moq/client.py rename to py/moq/moq/client.py index 22055fb85..996bc5b9a 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq/moq/client.py @@ -2,7 +2,8 @@ from __future__ import annotations -from ._uniffi import MoqClient +from moq_ffi import MoqClient + from .origin import Announced, AnnouncedBroadcast, OriginConsumer, OriginProducer from .publish import BroadcastProducer diff --git a/py/moq-rs/moq/origin.py b/py/moq/moq/origin.py similarity index 95% rename from py/moq-rs/moq/origin.py rename to py/moq/moq/origin.py index 05f1c4024..6fad5db20 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq/moq/origin.py @@ -2,7 +2,8 @@ from __future__ import annotations -from ._uniffi import MoqAnnounced, MoqAnnouncedBroadcast, MoqAnnouncement, MoqOriginConsumer, MoqOriginProducer +from moq_ffi import MoqAnnounced, MoqAnnouncedBroadcast, MoqAnnouncement, MoqOriginConsumer, MoqOriginProducer + from .publish import BroadcastProducer from .subscribe import BroadcastConsumer diff --git a/py/moq-rs/moq/publish.py b/py/moq/moq/publish.py similarity index 99% rename from py/moq-rs/moq/publish.py rename to py/moq/moq/publish.py index d1f357ca6..d30ebd18a 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq/moq/publish.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from ._uniffi import ( +from moq_ffi import ( MoqAudioProducer, MoqBroadcastProducer, MoqGroupProducer, @@ -12,6 +12,7 @@ MoqMediaStreamProducer, MoqTrackProducer, ) + from .types import AudioEncoderInput, AudioEncoderOutput, AudioFrame if TYPE_CHECKING: diff --git a/py/moq-rs/moq/py.typed b/py/moq/moq/py.typed similarity index 100% rename from py/moq-rs/moq/py.typed rename to py/moq/moq/py.typed diff --git a/py/moq-rs/moq/server.py b/py/moq/moq/server.py similarity index 99% rename from py/moq-rs/moq/server.py rename to py/moq/moq/server.py index 977741940..af78b5fe4 100644 --- a/py/moq-rs/moq/server.py +++ b/py/moq/moq/server.py @@ -6,7 +6,8 @@ from collections.abc import Awaitable, Callable, Sequence from typing import Literal -from ._uniffi import MoqRequest, MoqServer, MoqSession +from moq_ffi import MoqRequest, MoqServer, MoqSession + from .origin import OriginProducer from .publish import BroadcastProducer diff --git a/py/moq-rs/moq/subscribe.py b/py/moq/moq/subscribe.py similarity index 99% rename from py/moq-rs/moq/subscribe.py rename to py/moq/moq/subscribe.py index e09a29f3e..fe96f308a 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq/moq/subscribe.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ._uniffi import ( +from moq_ffi import ( Container, MoqAudioConsumer, MoqBroadcastConsumer, @@ -11,6 +11,7 @@ MoqMediaConsumer, MoqTrackConsumer, ) + from .types import Audio, AudioDecoderOutput, AudioFrame, Catalog, Frame diff --git a/py/moq-rs/moq/types.py b/py/moq/moq/types.py similarity index 72% rename from py/moq-rs/moq/types.py rename to py/moq/moq/types.py index ed6e3263e..fe3c320a6 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq/moq/types.py @@ -1,36 +1,36 @@ """Re-export moq-ffi record types without the Moq prefix.""" -from ._uniffi import ( +from moq_ffi import ( MoqAudio as Audio, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioCodec as AudioCodec, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioDecoderOutput as AudioDecoderOutput, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioEncoderInput as AudioEncoderInput, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioEncoderOutput as AudioEncoderOutput, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioFormat as AudioFormat, ) -from ._uniffi import ( +from moq_ffi import ( MoqAudioFrame as AudioFrame, ) -from ._uniffi import ( +from moq_ffi import ( MoqCatalog as Catalog, ) -from ._uniffi import ( +from moq_ffi import ( MoqDimensions as Dimensions, ) -from ._uniffi import ( +from moq_ffi import ( MoqFrame as Frame, ) -from ._uniffi import ( +from moq_ffi import ( MoqVideo as Video, ) diff --git a/py/moq/pyproject.toml b/py/moq/pyproject.toml new file mode 100644 index 000000000..90ed80b2e --- /dev/null +++ b/py/moq/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "moq" +# Versioned independently of the moq-ffi crate. Bump this by hand and push a +# `moq-py-v` tag to release (see .github/workflows/release-py-wrapper.yml). +version = "0.1.0" +description = "Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API." +readme = "README.md" +license = "MIT OR Apache-2.0" +requires-python = ">=3.10" +authors = [{ name = "Luke Curley", email = "kixelated@gmail.com" }] +keywords = ["quic", "http3", "webtransport", "media", "live", "streaming"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Video", +] +# Compatible-release pin: floats to the latest 0.2.x moq-ffi patch without a +# wrapper re-release. A moq-ffi minor bump (0.3) is the only thing that forces +# bumping this constraint. +dependencies = ["moq-ffi ~= 0.2.16"] + +[project.urls] +Homepage = "https://github.com/moq-dev/moq" +Repository = "https://github.com/moq-dev/moq" + +# Local dev resolves moq-ffi from the workspace member rather than PyPI. +[tool.uv.sources] +moq-ffi = { workspace = true } diff --git a/py/moq-rs/tests/test_local.py b/py/moq/tests/test_local.py similarity index 100% rename from py/moq-rs/tests/test_local.py rename to py/moq/tests/test_local.py diff --git a/py/moq-rs/tests/test_server.py b/py/moq/tests/test_server.py similarity index 99% rename from py/moq-rs/tests/test_server.py rename to py/moq/tests/test_server.py index 4635f9c9f..4aaafe713 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq/tests/test_server.py @@ -4,7 +4,7 @@ import struct import moq -import moq._uniffi as moq_ffi +import moq_ffi import pytest diff --git a/pyproject.toml b/pyproject.toml index 66513cd59..90436d619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,23 @@ +# Non-published workspace root: holds shared dev tooling + uv workspace config. +# Named distinctly from the published `moq` wrapper under py/moq. [project] -name = "moq" +name = "moq-workspace" version = "0.0.0" requires-python = ">=3.10" +# Listed explicitly (not `py/*`) so build output like py/dist isn't mistaken +# for a workspace member. [tool.uv.workspace] -members = ["py/*"] +members = ["py/moq-ffi", "py/moq"] [dependency-groups] dev = ["ruff", "maturin", "pyright", "pytest", "pytest-asyncio"] +# Tests span both workspace members (py/moq, py/moq-ffi). pytest resolves its +# rootdir to this workspace root, so the shared asyncio config lives here. +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.pyright] pythonVersion = "3.10" include = ["py"] diff --git a/test/smoke.sh b/test/smoke.sh index 0999fa85e..bd4de402b 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -114,9 +114,11 @@ cargo build -q -p moq-relay -p moq-cli if needs python; then echo "preparing python bindings..." # sync the workspace dev group (ruff/maturin/...) from py/, then build the - # moq-rs editable wheel (moq-ffi cdylib + uniffi bindings) from py/moq-rs. + # moq-ffi editable wheel (cdylib + uniffi bindings) and install the pure + # python moq wrapper that depends on it. (cd "$REPO_ROOT/py" && uv sync --no-install-workspace) - (cd "$REPO_ROOT/py/moq-rs" && uv run --no-sync maturin develop --uv) + (cd "$REPO_ROOT/py/moq-ffi" && uv run --no-sync maturin develop --uv) + (cd "$REPO_ROOT/py" && uv pip install --no-deps -e moq) fi if needs js-browser; then @@ -167,7 +169,7 @@ start_publisher() { (ffmpeg_h264 | "$MOQ" publish --url "$URL" --broadcast "$broadcast" avc3) >"$log" 2>&1 & ;; python) - (ffmpeg_h264 | (cd "$REPO_ROOT/py/moq-rs" && uv run --no-sync python examples/smoke.py \ + (ffmpeg_h264 | (cd "$REPO_ROOT/py/moq" && uv run --no-sync python examples/smoke.py \ publish --url "$URL" --broadcast "$broadcast")) >"$log" 2>&1 & ;; js-browser) @@ -196,7 +198,7 @@ run_subscriber() { [[ "${n:-0}" -ge 1 ]] ;; python) - (cd "$REPO_ROOT/py/moq-rs" && uv run --no-sync python examples/smoke.py \ + (cd "$REPO_ROOT/py/moq" && uv run --no-sync python examples/smoke.py \ subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") ;; js-browser) diff --git a/uv.lock b/uv.lock index 545db4c7a..ddcae8e41 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,8 @@ requires-python = ">=3.10" [manifest] members = [ "moq", - "moq-rs", + "moq-ffi", + "moq-workspace", ] [[package]] @@ -73,6 +74,21 @@ wheels = [ [[package]] name = "moq" +version = "0.1.0" +source = { editable = "py/moq" } +dependencies = [ + { name = "moq-ffi" }, +] + +[package.metadata] +requires-dist = [{ name = "moq-ffi", editable = "py/moq-ffi" }] + +[[package]] +name = "moq-ffi" +source = { editable = "py/moq-ffi" } + +[[package]] +name = "moq-workspace" version = "0.0.0" source = { virtual = "." } @@ -96,10 +112,6 @@ dev = [ { name = "ruff" }, ] -[[package]] -name = "moq-rs" -source = { editable = "py/moq-rs" } - [[package]] name = "nodeenv" version = "1.10.0" From 324ec04eefac12fdf8b43dda959bb2e1a7dafb84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:36:13 +0000 Subject: [PATCH 2/7] py: drop duplicate [tool.hatch.build.targets.wheel] in moq-rs pyproject A stray edit added the hatchling packages block twice, which made the TOML fail to parse and broke uv/maturin. Keep the single block near the top. https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/workflows/release-py-wrapper.yml | 18 ++++++------ CLAUDE.md | 14 +++++----- doc/.vitepress/config.ts | 2 +- doc/lib/index.md | 2 +- doc/lib/py/index.md | 16 +++++------ doc/lib/py/{moq.md => moq-rs.md} | 18 ++++++------ kt/README.md | 2 +- py/justfile | 30 ++++++-------------- py/moq-ffi/README.md | 4 +-- py/{moq => moq-rs}/README.md | 0 py/{moq => moq-rs}/examples/announced.py | 0 py/{moq => moq-rs}/examples/clock.py | 0 py/{moq => moq-rs}/examples/serve_clock.py | 0 py/{moq => moq-rs}/examples/smoke.py | 0 py/{moq => moq-rs}/moq/__init__.py | 0 py/{moq => moq-rs}/moq/client.py | 0 py/{moq => moq-rs}/moq/origin.py | 0 py/{moq => moq-rs}/moq/publish.py | 0 py/{moq => moq-rs}/moq/py.typed | 0 py/{moq => moq-rs}/moq/server.py | 0 py/{moq => moq-rs}/moq/subscribe.py | 0 py/{moq => moq-rs}/moq/types.py | 0 py/{moq => moq-rs}/pyproject.toml | 17 ++++++++---- py/{moq => moq-rs}/tests/test_local.py | 0 py/{moq => moq-rs}/tests/test_server.py | 0 pyproject.toml | 9 +++--- test/smoke.sh | 8 +++--- uv.lock | 32 +++++++++++----------- 28 files changed, 82 insertions(+), 90 deletions(-) rename doc/lib/py/{moq.md => moq-rs.md} (81%) rename py/{moq => moq-rs}/README.md (100%) rename py/{moq => moq-rs}/examples/announced.py (100%) rename py/{moq => moq-rs}/examples/clock.py (100%) rename py/{moq => moq-rs}/examples/serve_clock.py (100%) rename py/{moq => moq-rs}/examples/smoke.py (100%) rename py/{moq => moq-rs}/moq/__init__.py (100%) rename py/{moq => moq-rs}/moq/client.py (100%) rename py/{moq => moq-rs}/moq/origin.py (100%) rename py/{moq => moq-rs}/moq/publish.py (100%) rename py/{moq => moq-rs}/moq/py.typed (100%) rename py/{moq => moq-rs}/moq/server.py (100%) rename py/{moq => moq-rs}/moq/subscribe.py (100%) rename py/{moq => moq-rs}/moq/types.py (100%) rename py/{moq => moq-rs}/pyproject.toml (63%) rename py/{moq => moq-rs}/tests/test_local.py (100%) rename py/{moq => moq-rs}/tests/test_server.py (100%) diff --git a/.github/workflows/release-py-wrapper.yml b/.github/workflows/release-py-wrapper.yml index a025a2c95..650bf9d56 100644 --- a/.github/workflows/release-py-wrapper.yml +++ b/.github/workflows/release-py-wrapper.yml @@ -1,9 +1,9 @@ name: Release Py Wrapper -# Builds the pure-python `moq` wrapper wheel. Versioned independently of the -# moq-ffi crate: bump py/moq/pyproject.toml by hand and push a `moq-py-v` -# tag. The wrapper depends on `moq-ffi ~= 0.2.x`, so it floats to the latest -# compatible moq-ffi patch without a re-release. +# Builds the pure-python `moq-rs` wrapper wheel (import name `moq`). Versioned +# independently of the moq-ffi crate: bump py/moq-rs/pyproject.toml by hand and +# push a `moq-py-v` tag. The wrapper depends on `moq-ffi ~= 0.2.x`, so it +# floats to the latest compatible moq-ffi patch without a re-release. on: push: @@ -13,7 +13,7 @@ on: paths: - ".github/workflows/release-py-wrapper.yml" - ".github/scripts/release.sh" - - "py/moq/**" + - "py/moq-rs/**" permissions: contents: read @@ -40,7 +40,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} run: | if [[ "$EVENT_NAME" == "pull_request" ]]; then - PY_VERSION=$(grep '^version' py/moq/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + PY_VERSION=$(grep '^version' py/moq-rs/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') echo "version=$PY_VERSION" >> "$GITHUB_OUTPUT" echo "Using version from pyproject.toml (PR dry-run): $PY_VERSION" else @@ -55,9 +55,9 @@ jobs: env: TAG_VERSION: ${{ steps.parse.outputs.version }} run: | - PY_VERSION=$(grep '^version' py/moq/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + PY_VERSION=$(grep '^version' py/moq-rs/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') if [[ "$TAG_VERSION" != "$PY_VERSION" ]]; then - echo "::error::Tag version ($TAG_VERSION) does not match py/moq/pyproject.toml version ($PY_VERSION). Bump pyproject.toml before tagging." + echo "::error::Tag version ($TAG_VERSION) does not match py/moq-rs/pyproject.toml version ($PY_VERSION). Bump pyproject.toml before tagging." exit 1 fi echo "pyproject.toml version matches tag: $TAG_VERSION" @@ -83,7 +83,7 @@ jobs: if: github.event_name == 'push' environment: name: pypi - url: https://pypi.org/p/moq + url: https://pypi.org/p/moq-rs permissions: id-token: write diff --git a/CLAUDE.md b/CLAUDE.md index 601204fc4..27e021124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,13 +71,13 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # wheel covers every crate exposed via moq-ffi because # uniffi-linked libraries can't be split across separate # wheels. Version tracks rs/moq-ffi (release-py.yml fires - # on moq-ffi-v* tags). Most callers want `moq`, not this. - moq/ # Pure-python ergonomic wrapper. Distribution `moq` - # (PyPI); import `moq`. Depends on moq-ffi via a - # compatible-release pin (~=0.2.x) so it floats to the - # latest moq-ffi patch. Versioned independently: bump - # py/moq/pyproject.toml by hand, push a moq-py-v* tag - # (release-py-wrapper.yml). + # on moq-ffi-v* tags). Most callers want `moq-rs`, not this. + moq-rs/ # Pure-python ergonomic wrapper. Distribution `moq-rs` + # (PyPI, since `moq` is taken); import `moq`. Depends on + # moq-ffi via a compatible-release pin (~=0.2.x) so it + # floats to the latest moq-ffi patch. Versioned + # independently: bump py/moq-rs/pyproject.toml by hand, + # push a moq-py-v* tag (release-py-wrapper.yml). /swift/ # Swift wrapper over rs/moq-ffi (SwiftPM) /kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP) diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 951d837bc..7679bcf50 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -215,7 +215,7 @@ export default defineConfig({ { text: "Python", link: "/lib/py/", - items: [{ text: "moq", link: "/lib/py/moq" }], + items: [{ text: "moq-rs", link: "/lib/py/moq-rs" }], }, { text: "Kotlin", diff --git a/doc/lib/index.md b/doc/lib/index.md index 63bad2a5b..26c01e3f2 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -39,7 +39,7 @@ Raw C bindings via `libmoq`. The lowest-level entry point and the foundation for ### [Python](/lib/py/) -Async/await with `asyncio`. Published as [`moq`](https://pypi.org/project/moq/) on PyPI (the ergonomic wrapper), atop the raw [`moq-ffi`](https://pypi.org/project/moq-ffi/) bindings. +Async/await with `asyncio`. Published as [`moq-rs`](https://pypi.org/project/moq-rs/) on PyPI (the ergonomic wrapper, imported as `moq`), atop the raw [`moq-ffi`](https://pypi.org/project/moq-ffi/) bindings. ### [Kotlin](/lib/kt/) diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 95bd92123..40915481d 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -11,11 +11,11 @@ The Python bindings expose [Media over QUIC](/) to scripts, services, and protot Two packages, split so the ergonomic API can evolve on its own cadence: -### moq +### moq-rs -[![PyPI](https://img.shields.io/pypi/v/moq)](https://pypi.org/project/moq/) +[![PyPI](https://img.shields.io/pypi/v/moq-rs)](https://pypi.org/project/moq-rs/) -The package you want. Real-time pub/sub with built-in caching, fan-out, and prioritization on top of QUIC, with a Pythonic API (no `Moq` prefixes, async context managers, async iterators). At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. +The package you want. Install `moq-rs` (the `moq` name is taken on PyPI), import `moq`. Real-time pub/sub with built-in caching, fan-out, and prioritization on top of QUIC, with a Pythonic API (no `Moq` prefixes, async context managers, async iterators). At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol. It is pure Python and depends on `moq-ffi` via a compatible-release pin, so it floats to the latest `moq-ffi` patch automatically. It is versioned independently of the Rust crates. @@ -23,12 +23,12 @@ It is pure Python and depends on `moq-ffi` via a compatible-release pin, so it f [![PyPI](https://img.shields.io/pypi/v/moq-ffi)](https://pypi.org/project/moq-ffi/) -The raw UniFFI bindings (the `Moq`-prefixed classes), tracking the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate one-to-one. `moq` pulls this in for you. Install it directly only if you need the unwrapped API or are building your own wrapper. +The raw UniFFI bindings (the `Moq`-prefixed classes), tracking the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate one-to-one. `moq-rs` pulls this in for you. Install it directly only if you need the unwrapped API or are building your own wrapper. ## Installation ```bash -pip install moq +pip install moq-rs ``` This pulls in `moq-ffi`, for which prebuilt wheels are published for: @@ -82,6 +82,6 @@ asyncio.run(main()) ## Source and issues -- Source: [py/moq](https://github.com/moq-dev/moq/tree/main/py/moq) (wrapper), [py/moq-ffi](https://github.com/moq-dev/moq/tree/main/py/moq-ffi) (raw bindings) -- README: [py/moq/README.md](https://github.com/moq-dev/moq/blob/main/py/moq/README.md) -- Example scripts: [py/moq/examples](https://github.com/moq-dev/moq/tree/main/py/moq/examples) +- Source: [py/moq-rs](https://github.com/moq-dev/moq/tree/main/py/moq-rs) (wrapper), [py/moq-ffi](https://github.com/moq-dev/moq/tree/main/py/moq-ffi) (raw bindings) +- README: [py/moq-rs/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-rs/README.md) +- Example scripts: [py/moq-rs/examples](https://github.com/moq-dev/moq/tree/main/py/moq-rs/examples) diff --git a/doc/lib/py/moq.md b/doc/lib/py/moq-rs.md similarity index 81% rename from doc/lib/py/moq.md rename to doc/lib/py/moq-rs.md index 465aaa7c6..b8cafc298 100644 --- a/doc/lib/py/moq.md +++ b/doc/lib/py/moq-rs.md @@ -1,23 +1,23 @@ --- -title: moq (Python) +title: moq-rs (Python) description: Python pub/sub for Media over QUIC --- -# moq +# moq-rs -[![PyPI](https://img.shields.io/pypi/v/moq)](https://pypi.org/project/moq/) +[![PyPI](https://img.shields.io/pypi/v/moq-rs)](https://pypi.org/project/moq-rs/) Async pub/sub for [Media over QUIC](/) in Python. -The underlying transport is the Rust [`moq-net`](/lib/rs/crate/moq-net) crate, exposed through UniFFI (the [`moq-ffi`](https://pypi.org/project/moq-ffi/) package) and wrapped in a Pythonic API: no `Moq` prefixes on user-facing types, async iterators for streams, async context managers for sessions. `moq` is versioned independently of `moq-ffi` and floats to the latest compatible patch. +The underlying transport is the Rust [`moq-net`](/lib/rs/crate/moq-net) crate, exposed through UniFFI (the [`moq-ffi`](https://pypi.org/project/moq-ffi/) package) and wrapped in a Pythonic API: no `Moq` prefixes on user-facing types, async iterators for streams, async context managers for sessions. `moq-rs` is versioned independently of `moq-ffi` and floats to the latest compatible patch. ## Install ```bash -pip install moq +pip install moq-rs ``` -Requires Python 3.10+. This pulls in the `moq-ffi` native bindings automatically. +Requires Python 3.10+. The distribution is `moq-rs` (the `moq` name is taken on PyPI); the import name is `moq`. Installing it pulls in the `moq-ffi` native bindings automatically. ## Concepts @@ -92,14 +92,14 @@ broadcast = await client.announced_broadcast("live/cam1") ## Examples -The repo ships [example scripts](https://github.com/moq-dev/moq/tree/main/py/moq/examples) you can run end-to-end: +The repo ships [example scripts](https://github.com/moq-dev/moq/tree/main/py/moq-rs/examples) you can run end-to-end: - `clock.py` — publishes / subscribes a clock track (one frame per second, one group per minute). - `announced.py` — lists broadcasts under a prefix as they're announced. ## See also -- Source: [py/moq](https://github.com/moq-dev/moq/tree/main/py/moq) -- README: [py/moq/README.md](https://github.com/moq-dev/moq/blob/main/py/moq/README.md) +- Source: [py/moq-rs](https://github.com/moq-dev/moq/tree/main/py/moq-rs) +- README: [py/moq-rs/README.md](https://github.com/moq-dev/moq/blob/main/py/moq-rs/README.md) - Raw bindings: [moq-ffi](https://pypi.org/project/moq-ffi/) - The Rust crate this wraps: [moq-net](/lib/rs/crate/moq-net) diff --git a/kt/README.md b/kt/README.md index dcf76e276..1ea07aec1 100644 --- a/kt/README.md +++ b/kt/README.md @@ -66,7 +66,7 @@ kt/ scripts/ check.sh, package.sh, publish.sh ``` -The Kotlin module stays as a single `moq-ffi` artifact because uniffi-linked libraries can't be split across separately packaged wheels/artifacts. That constraint is about splitting the native library itself; a pure-source wrapper layered on top is fine. Python already does this: the `moq-ffi` wheel carries the native bindings and the pure-python `moq` wheel wraps it with an independently versioned ergonomic API. Kotlin could follow the same shape (a `dev.moq:moq-ffi` artifact plus a `dev.moq:moq` wrapper) if it wants independent wrapper versioning. +The Kotlin module stays as a single `moq-ffi` artifact because uniffi-linked libraries can't be split across separately packaged wheels/artifacts. That constraint is about splitting the native library itself; a pure-source wrapper layered on top is fine. Python already does this: the `moq-ffi` wheel carries the native bindings and the pure-python `moq-rs` wheel (imported as `moq`) wraps it with an independently versioned ergonomic API. Kotlin could follow the same shape (a `dev.moq:moq-ffi` artifact plus a `dev.moq:moq` wrapper) if it wants independent wrapper versioning. ## Publishing to Maven Central diff --git a/py/justfile b/py/justfile index 3df9dd4c6..babc8889c 100644 --- a/py/justfile +++ b/py/justfile @@ -5,19 +5,20 @@ # # Two workspace members: # moq-ffi/ maturin project: rs/moq-ffi cdylib + uniffi bindings (dist `moq-ffi`) -# moq/ pure-python ergonomic wrapper that depends on moq-ffi (dist `moq`) +# moq-rs/ pure-python ergonomic wrapper depending on moq-ffi (dist `moq-rs`, +# import `moq`) set working-directory := '.' default: just check -# Build moq-ffi (maturin cdylib + bindings) and install the pure-python moq +# Build moq-ffi (maturin cdylib + bindings) and install the pure-python moq-rs # wrapper, both editable into the workspace venv. `--no-deps` on the wrapper # keeps uv from fetching moq-ffi off PyPI; maturin just installed it locally. _develop: cd moq-ffi && uv run --no-sync maturin develop --uv - uv pip install --no-deps -e moq + uv pip install --no-deps -e moq-rs # Lint + format + editable build + pyright. `--no-install-workspace` installs # the root dev group (ruff, maturin, pyright, pytest) without trying to @@ -37,32 +38,17 @@ fix: test: uv sync --no-install-workspace just _develop - uv run --no-sync pytest moq/tests/ moq-ffi/tests/ + uv run --no-sync pytest moq-rs/tests/ moq-ffi/tests/ # Local dev build: editable install of moq-ffi (with the cdylib + uniffi -# bindings) and the moq wrapper into the workspace venv. +# bindings) and the moq-rs wrapper into the workspace venv. build: uv sync --no-install-workspace just _develop -# Build the pure-python moq wrapper sdist + wheel into py/dist (for release). +# Build the pure-python moq-rs wrapper sdist + wheel into py/dist (for release). # moq-ffi is built separately by maturin (see release-py.yml); the wrapper is # pure python so it needs no compilation, just a metadata-correct wheel. package: rm -rf dist - uv build --package moq --out-dir dist - -# Full Python CI: lint + tests + build. Takes a newline-separated list -# of changed files; skips if FILES is non-empty and none match the -# Python scope (which includes rs/moq-ffi because moq-ffi bundles it via -# maturin). Run `just py ci` (no FILES) to force-run everything. -ci FILES="": - #!/usr/bin/env bash - set -euo pipefail - if [[ -n "{{ FILES }}" ]] && ! echo "{{ FILES }}" | grep -qE '^(py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/)'; then - echo "py: no Python changes; skipping." - exit 0 - fi - just check - just test - just build + uv build --package moq-rs --out-dir dist diff --git a/py/moq-ffi/README.md b/py/moq-ffi/README.md index ea8879d34..cb3b2185b 100644 --- a/py/moq-ffi/README.md +++ b/py/moq-ffi/README.md @@ -4,7 +4,7 @@ Raw [UniFFI](https://mozilla.github.io/uniffi-rs/) bindings for the [Media over This package is the native foundation: the compiled `moq-ffi` cdylib plus the auto-generated Python bindings, exposed exactly as uniffi-bindgen emits them (the `Moq`-prefixed classes). It tracks the [`moq-ffi`](https://crates.io/crates/moq-ffi) Rust crate version one-to-one. -**Most callers want [`moq`](https://pypi.org/project/moq/) instead**, the ergonomic wrapper with a Pythonic API (no `Moq` prefixes, async iterators, context managers). Use `moq-ffi` directly only if you need the unwrapped API or are building your own wrapper. +**Most callers want [`moq-rs`](https://pypi.org/project/moq-rs/) instead** (imported as `moq`), the ergonomic wrapper with a Pythonic API (no `Moq` prefixes, async iterators, context managers). Use `moq-ffi` directly only if you need the unwrapped API or are building your own wrapper. ## Installation @@ -23,6 +23,6 @@ session = await client.connect("https://relay.quic.video") ## See Also -- [`moq`](https://pypi.org/project/moq/). The ergonomic wrapper most callers want. +- [`moq-rs`](https://pypi.org/project/moq-rs/). The ergonomic wrapper most callers want (imported as `moq`). - [`moq-ffi`](https://crates.io/crates/moq-ffi). The Rust crate that produces these bindings. - [MoQ project](https://github.com/moq-dev/moq). Full monorepo with Rust server, TypeScript browser lib, and more. diff --git a/py/moq/README.md b/py/moq-rs/README.md similarity index 100% rename from py/moq/README.md rename to py/moq-rs/README.md diff --git a/py/moq/examples/announced.py b/py/moq-rs/examples/announced.py similarity index 100% rename from py/moq/examples/announced.py rename to py/moq-rs/examples/announced.py diff --git a/py/moq/examples/clock.py b/py/moq-rs/examples/clock.py similarity index 100% rename from py/moq/examples/clock.py rename to py/moq-rs/examples/clock.py diff --git a/py/moq/examples/serve_clock.py b/py/moq-rs/examples/serve_clock.py similarity index 100% rename from py/moq/examples/serve_clock.py rename to py/moq-rs/examples/serve_clock.py diff --git a/py/moq/examples/smoke.py b/py/moq-rs/examples/smoke.py similarity index 100% rename from py/moq/examples/smoke.py rename to py/moq-rs/examples/smoke.py diff --git a/py/moq/moq/__init__.py b/py/moq-rs/moq/__init__.py similarity index 100% rename from py/moq/moq/__init__.py rename to py/moq-rs/moq/__init__.py diff --git a/py/moq/moq/client.py b/py/moq-rs/moq/client.py similarity index 100% rename from py/moq/moq/client.py rename to py/moq-rs/moq/client.py diff --git a/py/moq/moq/origin.py b/py/moq-rs/moq/origin.py similarity index 100% rename from py/moq/moq/origin.py rename to py/moq-rs/moq/origin.py diff --git a/py/moq/moq/publish.py b/py/moq-rs/moq/publish.py similarity index 100% rename from py/moq/moq/publish.py rename to py/moq-rs/moq/publish.py diff --git a/py/moq/moq/py.typed b/py/moq-rs/moq/py.typed similarity index 100% rename from py/moq/moq/py.typed rename to py/moq-rs/moq/py.typed diff --git a/py/moq/moq/server.py b/py/moq-rs/moq/server.py similarity index 100% rename from py/moq/moq/server.py rename to py/moq-rs/moq/server.py diff --git a/py/moq/moq/subscribe.py b/py/moq-rs/moq/subscribe.py similarity index 100% rename from py/moq/moq/subscribe.py rename to py/moq-rs/moq/subscribe.py diff --git a/py/moq/moq/types.py b/py/moq-rs/moq/types.py similarity index 100% rename from py/moq/moq/types.py rename to py/moq-rs/moq/types.py diff --git a/py/moq/pyproject.toml b/py/moq-rs/pyproject.toml similarity index 63% rename from py/moq/pyproject.toml rename to py/moq-rs/pyproject.toml index 90ed80b2e..8b5b96b51 100644 --- a/py/moq/pyproject.toml +++ b/py/moq-rs/pyproject.toml @@ -2,12 +2,19 @@ requires = ["hatchling"] build-backend = "hatchling.build" +# Dist name is moq-rs but the import package is `moq`, so point hatchling at it. +[tool.hatch.build.targets.wheel] +packages = ["moq"] + [project] -name = "moq" -# Versioned independently of the moq-ffi crate. Bump this by hand and push a -# `moq-py-v` tag to release (see .github/workflows/release-py-wrapper.yml). -version = "0.1.0" -description = "Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API." +name = "moq-rs" +# Distribution is `moq-rs` (the `moq` name is taken on PyPI); the import name +# stays `moq`. Versioned independently of the moq-ffi crate: bump this by hand +# and push a `moq-py-v` tag to release (see release-py-wrapper.yml). +# Starts at 0.3.0 to open a new line above the old lockstep 0.2.x releases that +# bundled the bindings. +version = "0.3.0" +description = "Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API. Import as `moq`." readme = "README.md" license = "MIT OR Apache-2.0" requires-python = ">=3.10" diff --git a/py/moq/tests/test_local.py b/py/moq-rs/tests/test_local.py similarity index 100% rename from py/moq/tests/test_local.py rename to py/moq-rs/tests/test_local.py diff --git a/py/moq/tests/test_server.py b/py/moq-rs/tests/test_server.py similarity index 100% rename from py/moq/tests/test_server.py rename to py/moq-rs/tests/test_server.py diff --git a/pyproject.toml b/pyproject.toml index 90436d619..45ebcf31b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,19 @@ # Non-published workspace root: holds shared dev tooling + uv workspace config. -# Named distinctly from the published `moq` wrapper under py/moq. [project] -name = "moq-workspace" +name = "moq" version = "0.0.0" requires-python = ">=3.10" # Listed explicitly (not `py/*`) so build output like py/dist isn't mistaken # for a workspace member. [tool.uv.workspace] -members = ["py/moq-ffi", "py/moq"] +members = ["py/moq-ffi", "py/moq-rs"] [dependency-groups] dev = ["ruff", "maturin", "pyright", "pytest", "pytest-asyncio"] -# Tests span both workspace members (py/moq, py/moq-ffi). pytest resolves its -# rootdir to this workspace root, so the shared asyncio config lives here. +# Tests span both workspace members (py/moq-rs, py/moq-ffi). pytest resolves +# its rootdir to this workspace root, so the shared asyncio config lives here. [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/test/smoke.sh b/test/smoke.sh index bd4de402b..a827b5ebc 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -115,10 +115,10 @@ if needs python; then echo "preparing python bindings..." # sync the workspace dev group (ruff/maturin/...) from py/, then build the # moq-ffi editable wheel (cdylib + uniffi bindings) and install the pure - # python moq wrapper that depends on it. + # python moq-rs wrapper (import `moq`) that depends on it. (cd "$REPO_ROOT/py" && uv sync --no-install-workspace) (cd "$REPO_ROOT/py/moq-ffi" && uv run --no-sync maturin develop --uv) - (cd "$REPO_ROOT/py" && uv pip install --no-deps -e moq) + (cd "$REPO_ROOT/py" && uv pip install --no-deps -e moq-rs) fi if needs js-browser; then @@ -169,7 +169,7 @@ start_publisher() { (ffmpeg_h264 | "$MOQ" publish --url "$URL" --broadcast "$broadcast" avc3) >"$log" 2>&1 & ;; python) - (ffmpeg_h264 | (cd "$REPO_ROOT/py/moq" && uv run --no-sync python examples/smoke.py \ + (ffmpeg_h264 | (cd "$REPO_ROOT/py/moq-rs" && uv run --no-sync python examples/smoke.py \ publish --url "$URL" --broadcast "$broadcast")) >"$log" 2>&1 & ;; js-browser) @@ -198,7 +198,7 @@ run_subscriber() { [[ "${n:-0}" -ge 1 ]] ;; python) - (cd "$REPO_ROOT/py/moq" && uv run --no-sync python examples/smoke.py \ + (cd "$REPO_ROOT/py/moq-rs" && uv run --no-sync python examples/smoke.py \ subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") ;; js-browser) diff --git a/uv.lock b/uv.lock index ddcae8e41..fca69461d 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,7 @@ requires-python = ">=3.10" members = [ "moq", "moq-ffi", - "moq-workspace", + "moq-rs", ] [[package]] @@ -74,21 +74,6 @@ wheels = [ [[package]] name = "moq" -version = "0.1.0" -source = { editable = "py/moq" } -dependencies = [ - { name = "moq-ffi" }, -] - -[package.metadata] -requires-dist = [{ name = "moq-ffi", editable = "py/moq-ffi" }] - -[[package]] -name = "moq-ffi" -source = { editable = "py/moq-ffi" } - -[[package]] -name = "moq-workspace" version = "0.0.0" source = { virtual = "." } @@ -112,6 +97,21 @@ dev = [ { name = "ruff" }, ] +[[package]] +name = "moq-ffi" +source = { editable = "py/moq-ffi" } + +[[package]] +name = "moq-rs" +version = "0.3.0" +source = { editable = "py/moq-rs" } +dependencies = [ + { name = "moq-ffi" }, +] + +[package.metadata] +requires-dist = [{ name = "moq-ffi", editable = "py/moq-ffi" }] + [[package]] name = "nodeenv" version = "1.10.0" From a349d334e4d5e333fefba0b3615cab078f7e7231 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:33:39 +0000 Subject: [PATCH 3/7] py: restore dropped `ci` recipe in justfile; fix review nits The CI "Check" job failed because the py/justfile rewrite accidentally dropped the `ci` recipe, which the root `just ci` dispatch invokes via `just py ci "$files"`. Restore it (lint + tests + build with the scope-skip guard), matching the original contract. Also address CodeRabbit review nits: - Fix stale `py/moq/examples/` -> `py/moq-rs/examples/` docstring paths in the three example scripts. - Wrap the moq-ffi README snippet in `async def main()` + `asyncio.run` so it isn't a module-scope `await` (SyntaxError on copy-paste). https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- py/justfile | 15 +++++++++++++++ py/moq-ffi/README.md | 11 +++++++++-- py/moq-rs/examples/announced.py | 4 ++-- py/moq-rs/examples/clock.py | 4 ++-- py/moq-rs/examples/serve_clock.py | 4 ++-- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/py/justfile b/py/justfile index babc8889c..bda2152f1 100644 --- a/py/justfile +++ b/py/justfile @@ -52,3 +52,18 @@ build: package: rm -rf dist uv build --package moq-rs --out-dir dist + +# Full Python CI: lint + tests + build. Takes a newline-separated list +# of changed files; skips if FILES is non-empty and none match the +# Python scope (which includes rs/moq-ffi because moq-ffi bundles it via +# maturin). Run `just py ci` (no FILES) to force-run everything. +ci FILES="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "{{ FILES }}" ]] && ! echo "{{ FILES }}" | grep -qE '^(py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/)'; then + echo "py: no Python changes; skipping." + exit 0 + fi + just check + just test + just build diff --git a/py/moq-ffi/README.md b/py/moq-ffi/README.md index cb3b2185b..a92896db1 100644 --- a/py/moq-ffi/README.md +++ b/py/moq-ffi/README.md @@ -15,10 +15,17 @@ pip install moq-ffi The distribution is `moq-ffi`; the import name is `moq_ffi`. ```python +import asyncio + import moq_ffi -client = moq_ffi.MoqClient() -session = await client.connect("https://relay.quic.video") + +async def main() -> None: + client = moq_ffi.MoqClient() + session = await client.connect("https://relay.quic.video") + + +asyncio.run(main()) ``` ## See Also diff --git a/py/moq-rs/examples/announced.py b/py/moq-rs/examples/announced.py index 369a2a457..c6b28c0f7 100644 --- a/py/moq-rs/examples/announced.py +++ b/py/moq-rs/examples/announced.py @@ -1,7 +1,7 @@ """List broadcasts announced on a relay under a given prefix. -python py/moq/examples/announced.py --url https://relay.example.com -python py/moq/examples/announced.py --url https://relay.example.com --prefix live/ +python py/moq-rs/examples/announced.py --url https://relay.example.com +python py/moq-rs/examples/announced.py --url https://relay.example.com --prefix live/ """ import argparse diff --git a/py/moq-rs/examples/clock.py b/py/moq-rs/examples/clock.py index e25c7615e..aa5e6e78c 100644 --- a/py/moq-rs/examples/clock.py +++ b/py/moq-rs/examples/clock.py @@ -4,8 +4,8 @@ frame of every group is the "YYYY-MM-DD HH:MM:" prefix, followed by one "SS" frame per second. - python py/moq/examples/clock.py publish --url https://relay.example.com --broadcast clock - python py/moq/examples/clock.py subscribe --url https://relay.example.com --broadcast clock + python py/moq-rs/examples/clock.py publish --url https://relay.example.com --broadcast clock + python py/moq-rs/examples/clock.py subscribe --url https://relay.example.com --broadcast clock """ import argparse diff --git a/py/moq-rs/examples/serve_clock.py b/py/moq-rs/examples/serve_clock.py index 08f710ae4..a6400a7f1 100644 --- a/py/moq-rs/examples/serve_clock.py +++ b/py/moq-rs/examples/serve_clock.py @@ -6,8 +6,8 @@ Run a subscriber against it with TLS verification disabled, e.g.: - python py/moq/examples/serve_clock.py --bind 127.0.0.1:4443 - python py/moq/examples/clock.py subscribe \\ + python py/moq-rs/examples/serve_clock.py --bind 127.0.0.1:4443 + python py/moq-rs/examples/clock.py subscribe \\ --url https://127.0.0.1:4443 --broadcast clock --no-tls-verify """ From 1bb881001dd13cf90736325cb0e16766ec8a299a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:51:44 +0000 Subject: [PATCH 4/7] py: auto-publish moq-rs wrapper on merge when version is new The wrapper release was gated on remembering to push a `moq-py-v*` tag separately from the version bump, so a merged bump could sit unreleased. Replace that with the same model release-plz uses for crates: the registry is the source of truth. release-py-wrapper.yml now runs on merge to main (py/moq-rs changes), reads the static version from pyproject.toml, and publishes to PyPI only if that version isn't already there. No tag to remember; bumping the version in a PR and merging is the whole release flow. Adds two reusable helpers to release.sh (`read-version`, `pypi-exists`) so the same gate drops in for future split wrappers. https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/scripts/release.sh | 57 ++++++++++++++++++-- .github/workflows/release-py-wrapper.yml | 66 +++++++++++------------- CLAUDE.md | 5 +- py/moq-rs/pyproject.toml | 3 +- 4 files changed, 89 insertions(+), 42 deletions(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 8028f7a10..888c87030 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -2,9 +2,11 @@ # # Shared release helpers for GitHub Actions workflows. # Usage: -# release.sh parse-version — extract SemVer from GITHUB_REF given a tag prefix -# release.sh prev-tag — find the tag immediately before the current one -# release.sh create — create or update a GitHub release with artifacts +# release.sh parse-version — extract SemVer from GITHUB_REF given a tag prefix +# release.sh prev-tag — find the tag immediately before the current one +# release.sh create — create or update a GitHub release with artifacts +# release.sh read-version — read `version = "x.y.z"` from a manifest +# release.sh pypi-exists — check whether == is already on PyPI # # Environment: # GITHUB_REF — set by GitHub Actions (e.g. refs/tags/moq-relay-v1.2.3) @@ -77,13 +79,60 @@ create_release() { fi } +# Read the static `version = "x.y.z"` from a pyproject.toml (or any TOML with a +# top-level version key). Writes version= to $GITHUB_OUTPUT and stdout. +read_version() { + local manifest="$1" + local version + version=$(grep -m1 '^version' "$manifest" | sed 's/.*"\(.*\)".*/\1/') + if [[ -z "$version" ]]; then + echo "Could not read version from $manifest" >&2 + exit 1 + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "version=${version}" >>"$GITHUB_OUTPUT" + fi + echo "$version" +} + +# Check whether a distribution+version is already published on PyPI. This is the +# release gate (like release-plz checking the registry): the git tag is just a +# record, the registry is the source of truth. Writes exists=true|false to +# $GITHUB_OUTPUT. A non-200/404 response is treated as fatal rather than +# silently re-publishing. +pypi_exists() { + local dist="$1" + local version="$2" + local url="https://pypi.org/pypi/${dist}/${version}/json" + + local code + code=$(curl -fsS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true) + + local exists + case "$code" in + 200) exists=true ;; + 404) exists=false ;; + *) + echo "Unexpected status $code querying $url" >&2 + exit 1 + ;; + esac + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "exists=${exists}" >>"$GITHUB_OUTPUT" + fi + echo "PyPI ${dist}==${version}: exists=${exists}" +} + # Dispatch subcommands case "${1:-}" in parse-version) parse_version "$2" ;; prev-tag) prev_tag "$2" ;; create) create_release "$2" ;; + read-version) read_version "$2" ;; + pypi-exists) pypi_exists "$2" "$3" ;; *) - echo "Usage: $0 {parse-version|prev-tag|create} " >&2 + echo "Usage: $0 {parse-version|prev-tag|create|read-version|pypi-exists} " >&2 exit 1 ;; esac diff --git a/.github/workflows/release-py-wrapper.yml b/.github/workflows/release-py-wrapper.yml index 650bf9d56..8cdec7d91 100644 --- a/.github/workflows/release-py-wrapper.yml +++ b/.github/workflows/release-py-wrapper.yml @@ -1,19 +1,28 @@ name: Release Py Wrapper -# Builds the pure-python `moq-rs` wrapper wheel (import name `moq`). Versioned -# independently of the moq-ffi crate: bump py/moq-rs/pyproject.toml by hand and -# push a `moq-py-v` tag. The wrapper depends on `moq-ffi ~= 0.2.x`, so it -# floats to the latest compatible moq-ffi patch without a re-release. +# Publishes the pure-python `moq-rs` wrapper wheel (import name `moq`) to PyPI. +# +# Versioned independently of the moq-ffi crate: bump `version` in +# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to main this reads +# that version and publishes only if it isn't already on PyPI. That registry +# check is the release gate (the same model release-plz uses for crates), so +# there's no separate tag to remember. The wrapper depends on `moq-ffi ~= 0.2.x` +# and floats to the latest compatible moq-ffi patch, so a moq-ffi patch needs no +# wrapper re-release. on: push: - tags: - - "moq-py-v*" - pull_request: + branches: + - main paths: + - "py/moq-rs/**" - ".github/workflows/release-py-wrapper.yml" - ".github/scripts/release.sh" + pull_request: + paths: - "py/moq-rs/**" + - ".github/workflows/release-py-wrapper.yml" + - ".github/scripts/release.sh" permissions: contents: read @@ -26,41 +35,28 @@ jobs: build: name: Build wheel + sdist runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'moq-dev' }} outputs: - version: ${{ steps.parse.outputs.version }} + version: ${{ steps.version.outputs.version }} + # On PRs we always "publish" into the dry-run sense (build only). On main + # we publish only when the version isn't already on PyPI. + publish: ${{ github.event_name == 'push' && steps.pypi.outputs.exists == 'false' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Parse version - id: parse - env: - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "pull_request" ]]; then - PY_VERSION=$(grep '^version' py/moq-rs/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - echo "version=$PY_VERSION" >> "$GITHUB_OUTPUT" - echo "Using version from pyproject.toml (PR dry-run): $PY_VERSION" - else - .github/scripts/release.sh parse-version moq-py - fi + - name: Read version + id: version + run: .github/scripts/release.sh read-version py/moq-rs/pyproject.toml - # The wrapper version is static in pyproject.toml. If a tag is pushed - # without bumping it, the wheel would ship the previous version and PyPI - # would reject the duplicate upload. - - name: Verify pyproject.toml matches tag - if: github.event_name == 'push' - env: - TAG_VERSION: ${{ steps.parse.outputs.version }} - run: | - PY_VERSION=$(grep '^version' py/moq-rs/pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - if [[ "$TAG_VERSION" != "$PY_VERSION" ]]; then - echo "::error::Tag version ($TAG_VERSION) does not match py/moq-rs/pyproject.toml version ($PY_VERSION). Bump pyproject.toml before tagging." - exit 1 - fi - echo "pyproject.toml version matches tag: $TAG_VERSION" + # The release gate: skip publishing a version that's already on PyPI + # (re-uploads are rejected anyway, but this keeps the run green and makes + # the no-op explicit). Runs on PRs too, as an informational dry-run. + - name: Check PyPI + id: pypi + run: .github/scripts/release.sh pypi-exists moq-rs "${{ steps.version.outputs.version }}" - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main with: @@ -80,7 +76,7 @@ jobs: name: Publish to PyPI needs: [build] runs-on: ubuntu-latest - if: github.event_name == 'push' + if: ${{ needs.build.outputs.publish == 'true' }} environment: name: pypi url: https://pypi.org/p/moq-rs diff --git a/CLAUDE.md b/CLAUDE.md index 27e021124..32f3974f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,9 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # (PyPI, since `moq` is taken); import `moq`. Depends on # moq-ffi via a compatible-release pin (~=0.2.x) so it # floats to the latest moq-ffi patch. Versioned - # independently: bump py/moq-rs/pyproject.toml by hand, - # push a moq-py-v* tag (release-py-wrapper.yml). + # independently: bump py/moq-rs/pyproject.toml by hand; on + # merge to main release-py-wrapper.yml publishes to PyPI if + # that version isn't already there (registry is the gate). /swift/ # Swift wrapper over rs/moq-ffi (SwiftPM) /kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP) diff --git a/py/moq-rs/pyproject.toml b/py/moq-rs/pyproject.toml index 8b5b96b51..ec11730e9 100644 --- a/py/moq-rs/pyproject.toml +++ b/py/moq-rs/pyproject.toml @@ -10,7 +10,8 @@ packages = ["moq"] name = "moq-rs" # Distribution is `moq-rs` (the `moq` name is taken on PyPI); the import name # stays `moq`. Versioned independently of the moq-ffi crate: bump this by hand -# and push a `moq-py-v` tag to release (see release-py-wrapper.yml). +# in a PR. On merge to main, release-py-wrapper.yml publishes to PyPI if this +# version isn't already there (no tag needed; the registry is the gate). # Starts at 0.3.0 to open a new line above the old lockstep 0.2.x releases that # bundled the bindings. version = "0.3.0" From 6cd517efd220d079861ef9832f18838bbe24c9d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:06:05 +0000 Subject: [PATCH 5/7] ci: make release-py.yml the wrapper, release-py-ffi.yml the bindings `moq-rs` (the wrapper, import `moq`) is the package most callers install, so it gets the headline `release-py.yml` name. The raw `moq-ffi` bindings build moves to `release-py-ffi.yml`, alongside the per-language FFI release files. Updates the self-path filters, concurrency groups, and doc references to match. Also folds in two CodeRabbit findings on these files: - read_version now reads the version from the [project] table specifically, so a `version` key in another table can't be picked up by mistake. - The wrapper workflow's concurrency group keys on event + ref, so a PR build can't queue ahead of (and delay) a release on main. https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/scripts/release.sh | 12 +- .github/workflows/release-py-ffi.yml | 164 ++++++++++++++++++++++ .github/workflows/release-py-wrapper.yml | 94 ------------- .github/workflows/release-py.yml | 170 +++++++---------------- CLAUDE.md | 8 +- py/moq-ffi/pyproject.toml | 2 +- py/moq-rs/pyproject.toml | 4 +- 7 files changed, 231 insertions(+), 223 deletions(-) create mode 100644 .github/workflows/release-py-ffi.yml delete mode 100644 .github/workflows/release-py-wrapper.yml diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 888c87030..38aaab617 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -79,14 +79,18 @@ create_release() { fi } -# Read the static `version = "x.y.z"` from a pyproject.toml (or any TOML with a -# top-level version key). Writes version= to $GITHUB_OUTPUT and stdout. +# Read the static `version = "x.y.z"` from the [project] table of a +# pyproject.toml. Scoped to [project] so a `version` key in another table +# (e.g. a [tool.*] section) can't be picked up by mistake. Writes +# version= to $GITHUB_OUTPUT and stdout. read_version() { local manifest="$1" local version - version=$(grep -m1 '^version' "$manifest" | sed 's/.*"\(.*\)".*/\1/') + version=$(sed -n '/^\[project\]/,/^\[/{ + s/^[[:space:]]*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p + }' "$manifest" | head -n1) if [[ -z "$version" ]]; then - echo "Could not read version from $manifest" >&2 + echo "Could not read version from [project] in $manifest" >&2 exit 1 fi if [[ -n "${GITHUB_OUTPUT:-}" ]]; then diff --git a/.github/workflows/release-py-ffi.yml b/.github/workflows/release-py-ffi.yml new file mode 100644 index 000000000..ed3da41b9 --- /dev/null +++ b/.github/workflows/release-py-ffi.yml @@ -0,0 +1,164 @@ +name: Release Py FFI + +# Builds the `moq-ffi` wheel (raw uniffi bindings). Driven by the +# `moq-ffi-v*` tag that release-plz pushes when it bumps rs/moq-ffi/Cargo.toml. +# The wheel inherits its version from that same Cargo.toml (via maturin's +# dynamic version), so the moq-ffi package stays lockstep with the moq-ffi +# crate. The ergonomic `moq-rs` wrapper is released separately (release-py.yml). + +on: + push: + tags: + - "moq-ffi-v*" + pull_request: + paths: + - ".github/workflows/release-py-ffi.yml" + - ".github/scripts/release.sh" + +permissions: + contents: read + +concurrency: + group: release-py-ffi + cancel-in-progress: false + +jobs: + parse-version: + name: Parse version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Parse version + id: parse + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [[ "$EVENT_NAME" == "pull_request" ]]; then + CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION" + else + .github/scripts/release.sh parse-version moq-ffi + fi + + # Maturin reads the wheel version from rs/moq-ffi/Cargo.toml (via + # `dynamic = ["version"]` in pyproject.toml). If a tag is pushed by + # hand without bumping Cargo.toml first, the wheel would ship with + # the previous version and PyPI would reject the duplicate upload. + - name: Verify Cargo.toml matches tag + if: github.event_name == 'push' + env: + TAG_VERSION: ${{ steps.parse.outputs.version }} + run: | + CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [[ "$TAG_VERSION" != "$CARGO_VERSION" ]]; then + echo "::error::Tag version ($TAG_VERSION) does not match rs/moq-ffi/Cargo.toml version ($CARGO_VERSION). Bump Cargo.toml before tagging." + exit 1 + fi + echo "Cargo.toml version matches tag: $TAG_VERSION" + + build: + name: Build wheels (${{ matrix.target }}) + needs: [parse-version] + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + # Use manylinux_2_28 for aarch64-linux to get a newer GCC. + # manylinux2014 ships GCC 4.8.5 which can't compile aws-lc-sys. + manylinux: 2_28 + # The cross image sets TARGET_CC/CXX which leaks into host builds. + before-script-linux: "unset TARGET_CC TARGET_CXX" + - target: x86_64-apple-darwin + os: macos-15-intel + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + + - name: Build wheels + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 + with: + # py/moq-ffi/pyproject.toml drives the build; its [tool.maturin] + # block points at rs/moq-ffi/Cargo.toml. + working-directory: py/moq-ffi + args: --release --out dist + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + before-script-linux: ${{ matrix.before-script-linux || '' }} + + - name: Upload wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-${{ matrix.target }} + path: py/moq-ffi/dist/*.whl + + sdist: + name: Build sdist + needs: [parse-version] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + + - name: Build sdist + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 + with: + working-directory: py/moq-ffi + command: sdist + args: --out dist + + - name: Upload sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-sdist + path: py/moq-ffi/dist/*.tar.gz + + publish: + name: Publish to PyPI + needs: [build, sdist] + runs-on: ubuntu-latest + if: github.event_name == 'push' + environment: + name: pypi + url: https://pypi.org/p/moq-ffi + permissions: + id-token: write + + steps: + - name: Download wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: python-* + path: dist + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/.github/workflows/release-py-wrapper.yml b/.github/workflows/release-py-wrapper.yml deleted file mode 100644 index 8cdec7d91..000000000 --- a/.github/workflows/release-py-wrapper.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Release Py Wrapper - -# Publishes the pure-python `moq-rs` wrapper wheel (import name `moq`) to PyPI. -# -# Versioned independently of the moq-ffi crate: bump `version` in -# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to main this reads -# that version and publishes only if it isn't already on PyPI. That registry -# check is the release gate (the same model release-plz uses for crates), so -# there's no separate tag to remember. The wrapper depends on `moq-ffi ~= 0.2.x` -# and floats to the latest compatible moq-ffi patch, so a moq-ffi patch needs no -# wrapper re-release. - -on: - push: - branches: - - main - paths: - - "py/moq-rs/**" - - ".github/workflows/release-py-wrapper.yml" - - ".github/scripts/release.sh" - pull_request: - paths: - - "py/moq-rs/**" - - ".github/workflows/release-py-wrapper.yml" - - ".github/scripts/release.sh" - -permissions: - contents: read - -concurrency: - group: release-py-wrapper - cancel-in-progress: false - -jobs: - build: - name: Build wheel + sdist - runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'moq-dev' }} - outputs: - version: ${{ steps.version.outputs.version }} - # On PRs we always "publish" into the dry-run sense (build only). On main - # we publish only when the version isn't already on PyPI. - publish: ${{ github.event_name == 'push' && steps.pypi.outputs.exists == 'false' }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Read version - id: version - run: .github/scripts/release.sh read-version py/moq-rs/pyproject.toml - - # The release gate: skip publishing a version that's already on PyPI - # (re-uploads are rejected anyway, but this keeps the run green and makes - # the no-op explicit). Runs on PRs too, as an informational dry-run. - - name: Check PyPI - id: pypi - run: .github/scripts/release.sh pypi-exists moq-rs "${{ steps.version.outputs.version }}" - - - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main - with: - determinate: false - - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main - - - name: Build - run: nix develop --command just py package - - - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-wrapper - path: py/dist/* - - publish: - name: Publish to PyPI - needs: [build] - runs-on: ubuntu-latest - if: ${{ needs.build.outputs.publish == 'true' }} - environment: - name: pypi - url: https://pypi.org/p/moq-rs - permissions: - id-token: write - - steps: - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: python-wrapper - path: dist - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index f86ee96e1..c38f333b5 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -1,164 +1,98 @@ -name: Release Py FFI - -# Builds the `moq-ffi` wheel (raw uniffi bindings). Driven by the -# `moq-ffi-v*` tag that release-plz pushes when it bumps rs/moq-ffi/Cargo.toml. -# The wheel inherits its version from that same Cargo.toml (via maturin's -# dynamic version), so the moq-ffi package stays lockstep with the moq-ffi -# crate. The ergonomic `moq` wrapper is released separately (release-py-wrapper.yml). +name: Release Py + +# Publishes the pure-python `moq-rs` wrapper wheel (import name `moq`) to PyPI. +# This is the package most callers install; the raw `moq-ffi` bindings it +# depends on are built separately (release-py-ffi.yml). +# +# Versioned independently of the moq-ffi crate: bump `version` in +# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to main this reads +# that version and publishes only if it isn't already on PyPI. That registry +# check is the release gate (the same model release-plz uses for crates), so +# there's no separate tag to remember. The wrapper depends on `moq-ffi ~= 0.2.x` +# and floats to the latest compatible moq-ffi patch, so a moq-ffi patch needs no +# wrapper re-release. on: push: - tags: - - "moq-ffi-v*" + branches: + - main + paths: + - "py/moq-rs/**" + - ".github/workflows/release-py.yml" + - ".github/scripts/release.sh" pull_request: paths: + - "py/moq-rs/**" - ".github/workflows/release-py.yml" - ".github/scripts/release.sh" permissions: contents: read +# Separate PR builds from mainline publishes so a PR run can't queue ahead of +# (and delay) a release on main. concurrency: - group: release-py + group: release-py-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: - parse-version: - name: Parse version + build: + name: Build wheel + sdist runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'moq-dev' }} outputs: - version: ${{ steps.parse.outputs.version }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Parse version - id: parse - env: - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "pull_request" ]]; then - CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" - echo "Using version from Cargo.toml (PR dry-run): $CARGO_VERSION" - else - .github/scripts/release.sh parse-version moq-ffi - fi - - # Maturin reads the wheel version from rs/moq-ffi/Cargo.toml (via - # `dynamic = ["version"]` in pyproject.toml). If a tag is pushed by - # hand without bumping Cargo.toml first, the wheel would ship with - # the previous version and PyPI would reject the duplicate upload. - - name: Verify Cargo.toml matches tag - if: github.event_name == 'push' - env: - TAG_VERSION: ${{ steps.parse.outputs.version }} - run: | - CARGO_VERSION=$(grep '^version' rs/moq-ffi/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - if [[ "$TAG_VERSION" != "$CARGO_VERSION" ]]; then - echo "::error::Tag version ($TAG_VERSION) does not match rs/moq-ffi/Cargo.toml version ($CARGO_VERSION). Bump Cargo.toml before tagging." - exit 1 - fi - echo "Cargo.toml version matches tag: $TAG_VERSION" - - build: - name: Build wheels (${{ matrix.target }}) - needs: [parse-version] - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest - # Use manylinux_2_28 for aarch64-linux to get a newer GCC. - # manylinux2014 ships GCC 4.8.5 which can't compile aws-lc-sys. - manylinux: 2_28 - # The cross image sets TARGET_CC/CXX which leaks into host builds. - before-script-linux: "unset TARGET_CC TARGET_CXX" - - target: x86_64-apple-darwin - os: macos-15-intel - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-pc-windows-msvc - os: windows-latest + version: ${{ steps.version.outputs.version }} + # On PRs we always "publish" into the dry-run sense (build only). On main + # we publish only when the version isn't already on PyPI. + publish: ${{ github.event_name == 'push' && steps.pypi.outputs.exists == 'false' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: "3.12" - - - name: Build wheels - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 - with: - # py/moq-ffi/pyproject.toml drives the build; its [tool.maturin] - # block points at rs/moq-ffi/Cargo.toml. - working-directory: py/moq-ffi - args: --release --out dist - target: ${{ matrix.target }} - manylinux: ${{ matrix.manylinux || 'auto' }} - before-script-linux: ${{ matrix.before-script-linux || '' }} - - - name: Upload wheels - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-${{ matrix.target }} - path: py/moq-ffi/dist/*.whl - - sdist: - name: Build sdist - needs: [parse-version] - runs-on: ubuntu-latest + - name: Read version + id: version + run: .github/scripts/release.sh read-version py/moq-rs/pyproject.toml - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false + # The release gate: skip publishing a version that's already on PyPI + # (re-uploads are rejected anyway, but this keeps the run green and makes + # the no-op explicit). Runs on PRs too, as an informational dry-run. + - name: Check PyPI + id: pypi + run: .github/scripts/release.sh pypi-exists moq-rs "${{ steps.version.outputs.version }}" - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main with: - python-version: "3.12" + determinate: false + - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main - - name: Build sdist - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 - with: - working-directory: py/moq-ffi - command: sdist - args: --out dist + - name: Build + run: nix develop --command just py package - - name: Upload sdist + - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-sdist - path: py/moq-ffi/dist/*.tar.gz + name: python-wrapper + path: py/dist/* publish: name: Publish to PyPI - needs: [build, sdist] + needs: [build] runs-on: ubuntu-latest - if: github.event_name == 'push' + if: ${{ needs.build.outputs.publish == 'true' }} environment: name: pypi - url: https://pypi.org/p/moq-ffi + url: https://pypi.org/p/moq-rs permissions: id-token: write steps: - - name: Download wheels + - name: Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: python-* + name: python-wrapper path: dist - merge-multiple: true - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/CLAUDE.md b/CLAUDE.md index 32f3974f5..cbb87bca5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,15 +70,15 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # Distribution `moq-ffi` (PyPI); import `moq_ffi`. One # wheel covers every crate exposed via moq-ffi because # uniffi-linked libraries can't be split across separate - # wheels. Version tracks rs/moq-ffi (release-py.yml fires - # on moq-ffi-v* tags). Most callers want `moq-rs`, not this. + # wheels. Version tracks rs/moq-ffi (release-py-ffi.yml + # fires on moq-ffi-v* tags). Most callers want `moq-rs`. moq-rs/ # Pure-python ergonomic wrapper. Distribution `moq-rs` # (PyPI, since `moq` is taken); import `moq`. Depends on # moq-ffi via a compatible-release pin (~=0.2.x) so it # floats to the latest moq-ffi patch. Versioned # independently: bump py/moq-rs/pyproject.toml by hand; on - # merge to main release-py-wrapper.yml publishes to PyPI if - # that version isn't already there (registry is the gate). + # merge to main release-py.yml publishes to PyPI if that + # version isn't already there (registry is the gate). /swift/ # Swift wrapper over rs/moq-ffi (SwiftPM) /kt/ # Kotlin wrapper over rs/moq-ffi (Gradle, KMP) diff --git a/py/moq-ffi/pyproject.toml b/py/moq-ffi/pyproject.toml index 3b7f6f532..0fa642ff6 100644 --- a/py/moq-ffi/pyproject.toml +++ b/py/moq-ffi/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "moq-ffi" # Version is injected at build time from the moq-ffi-v* tag (see -# .github/workflows/release-py.yml). For local dev `maturin develop` +# .github/workflows/release-py-ffi.yml). For local dev `maturin develop` # reads it from rs/moq-ffi/Cargo.toml. dynamic = ["version"] description = "Raw UniFFI bindings for the Media over QUIC Rust crates. Most callers want the ergonomic `moq` wrapper instead." diff --git a/py/moq-rs/pyproject.toml b/py/moq-rs/pyproject.toml index ec11730e9..446a343b6 100644 --- a/py/moq-rs/pyproject.toml +++ b/py/moq-rs/pyproject.toml @@ -10,8 +10,8 @@ packages = ["moq"] name = "moq-rs" # Distribution is `moq-rs` (the `moq` name is taken on PyPI); the import name # stays `moq`. Versioned independently of the moq-ffi crate: bump this by hand -# in a PR. On merge to main, release-py-wrapper.yml publishes to PyPI if this -# version isn't already there (no tag needed; the registry is the gate). +# in a PR. On merge to main, release-py.yml publishes to PyPI if this version +# isn't already there (no tag needed; the registry is the gate). # Starts at 0.3.0 to open a new line above the old lockstep 0.2.x releases that # bundled the bindings. version = "0.3.0" From d508e9a742cd505c16471a08c40d426668a6d395 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:11:43 +0000 Subject: [PATCH 6/7] ci: scope release-py-ffi concurrency; pass version via env Two CodeRabbit quick wins: - release-py-ffi.yml concurrency group now keys on event + ref (matching release-py.yml), so a PR dry-run can't queue ahead of a tag publish. - release-py.yml's PyPI check passes the version through `env:` instead of interpolating ${{ }} straight into the run string (zizmor injection nit). https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/workflows/release-py-ffi.yml | 3 ++- .github/workflows/release-py.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-py-ffi.yml b/.github/workflows/release-py-ffi.yml index ed3da41b9..a9f891563 100644 --- a/.github/workflows/release-py-ffi.yml +++ b/.github/workflows/release-py-ffi.yml @@ -18,8 +18,9 @@ on: permissions: contents: read +# Scope by event + ref so a PR dry-run can't queue ahead of a tag publish. concurrency: - group: release-py-ffi + group: release-py-ffi-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index c38f333b5..baab2ae62 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -60,7 +60,9 @@ jobs: # the no-op explicit). Runs on PRs too, as an informational dry-run. - name: Check PyPI id: pypi - run: .github/scripts/release.sh pypi-exists moq-rs "${{ steps.version.outputs.version }}" + env: + VERSION: ${{ steps.version.outputs.version }} + run: .github/scripts/release.sh pypi-exists moq-rs "$VERSION" - uses: DeterminateSystems/nix-installer-action@00199f951aeb9404028a6e4b95ad42546f73296a # main with: From 8dc653248006966fc11f6131b4a1f9aaf7f91e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:20:34 +0000 Subject: [PATCH 7/7] ci: retry the PyPI release-gate check on transient failures Match the robustness the repo's earlier per-package publisher (py/common/release.sh) had: a network blip on the PyPI existence check shouldn't fail the release. Add --max-time 10 --retry 3 --retry-connrefused to the pypi-exists curl. https://claude.ai/code/session_01EegH6PKeUcknQJ2TP7D7RX --- .github/scripts/release.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 38aaab617..67cfb5cfe 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -109,8 +109,9 @@ pypi_exists() { local version="$2" local url="https://pypi.org/pypi/${dist}/${version}/json" + # Retry transient failures so a network blip doesn't fail the release gate. local code - code=$(curl -fsS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true) + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 --retry 3 --retry-connrefused "$url" 2>/dev/null || true) local exists case "$code" in