From 983e1c04847d0d843674551b814dacfbbbc0a884 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 20:12:56 -0400 Subject: [PATCH 1/3] fix(packaging): declare Apache-2.0 so the wheel stops shipping two licenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[project] license` said `MIT` and the license classifier said MIT, but the LICENSE file in this repo is the Apache 2.0 text and NOTICE carves the WAVE marks out of that specific grant. setuptools bundles LICENSE and NOTICE into `dist-info/licenses/`, so every built artifact carried `License: MIT` in its METADATA next to an Apache-2.0 LICENSE inside the same archive — two different grants in one distribution, which is what PyPI publishes as the package license. This is stale metadata, not a license change. `git log -S` shows the MIT line has been untouched since the initial commit (1b7be39); the repo adopted Apache-2.0 in 99d81d3 ("chore: adopt Apache-2.0 license + add NOTICE") and that commit did not update pyproject.toml. Every sibling WAVE SDK repo (sdk, api-spec, mcp-server, adk) ships Apache-2.0, so LICENSE + NOTICE are the authoritative pair and pyproject is corrected to match them. Kept as `license = {text = "Apache-2.0"}` rather than the bare PEP 639 SPDX string because the build-system requirement here is setuptools>=61 and the SPDX form needs setuptools>=77. Also adds `tomli` to the dev extra for Python < 3.11, so the new packaging tests can read this file back on the 3.9/3.10 floor where tomllib is not stdlib. Receipt, rebuilt wheel METADATA: Name: wave-sdk / Version: 2.1.0 License: Apache-2.0 Classifier: License :: OSI Approved :: Apache Software License bundled dist-info/licenses/LICENSE line 1: "Apache License" twine check: PASSED (wheel + sdist) Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eaa7500..243a6f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,14 @@ name = "wave-sdk" version = "2.1.0" description = "Official WAVE SDK for Python - 42 API modules for streaming, production, analytics, and more" readme = "README.md" -license = {text = "MIT"} +# Apache-2.0 is the repo's actual license: the LICENSE file is the Apache 2.0 +# text and NOTICE carves the WAVE marks out of that grant. This field said "MIT" +# from the initial commit (1b7be39) and was missed when the repo adopted +# Apache-2.0 (99d81d3), so every built wheel shipped `License: MIT` metadata +# next to an Apache-2.0 LICENSE file inside the SAME dist-info. Kept as +# `{text = ...}` rather than a bare PEP 639 SPDX string because the build +# requirement here is setuptools>=61, and the SPDX form needs setuptools>=77. +license = {text = "Apache-2.0"} requires-python = ">=3.9" authors = [ {name = "WAVE Online, LLC", email = "sdk@wave.online"} @@ -37,7 +44,7 @@ keywords = [ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", @@ -74,6 +81,9 @@ dev = [ "mypy>=1.0.0", "ruff>=0.1.0", "black>=23.0.0", + # tests/test_packaging.py reads this file back to assert the shipped metadata + # matches the repo (name/version/license). tomllib is stdlib from 3.11 only. + "tomli>=2.0.0; python_version < '3.11'", ] [project.urls] From dd6bad0add6852b9d2d12f3c5875a0ed6a52afdd Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 20:13:13 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20add=20MIGRATING.md=20=E2=80=94=20wa?= =?UTF-8?q?ve-av-sdk=20->=20wave-sdk,=20`import=20wave`=20->=20`wave=5Fsdk?= =?UTF-8?q?`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no migration note anywhere. CHANGELOG documents the rename for people reading the changelog; nothing told an installed 2.0.0 user what to change, and README never mentioned `wave-av-sdk` at all even though it is a published distribution name serving 2.0.0 on PyPI right now. MIGRATING.md covers the whole path: uninstall BOTH `wave-av-sdk` and `wave-sdk` first (they each drop a top-level `wave/` into site-packages, and leaving one behind leaves stale 2.0.0 modules on disk beside the new `wave_sdk`), install `wave-sdk>=2.1.0`, then rewrite the import. Nothing below the top-level name changed, so a one-line find-and-replace is the entire migration; the bulk `grep | xargs sed` form is included. It also explains WHY the rename was forced, with the reproduction: CPython ships `Lib/wave.py`, the stdlib directory precedes site-packages on sys.path, so `from wave import Wave` in a fresh install of 2.0.0 raised ImportError and the SDK on disk was unreachable. Verified live against the published wheel today. Two smaller doc defects fixed alongside: - README declared "MIT - WAVE Online, LLC" — a third place contradicting the Apache-2.0 LICENSE. Now points at LICENSE and NOTICE. - README API tables are written `wave.clips`, `wave.pipeline`, ... which reads as module paths in a repo whose defect was a module named `wave`. Both README and MIGRATING now state these are attributes of a client instance and that no importable `wave` module exists in this SDK. Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATING.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 26 +++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 MIGRATING.md diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..95eef1f --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,109 @@ +# Migrating to `wave-sdk` 2.1.0 + +Two things changed between the published `2.0.0` releases and `2.1.0`: the +**distribution you install** and the **package you import**. Both changes are +mechanical, and the second one is not optional — the old import never worked +outside the SDK's own repo. + +| | Old (`2.0.0`, published) | New (`2.1.0`) | +| --- | --- | --- | +| Install name(s) | `wave-av-sdk`, `wave-sdk` | `wave-sdk` | +| Import name | `wave` (broken — see below) | `wave_sdk` | +| Client class | `Wave` | `Wave` (unchanged) | +| Method surface | 35 `*API` classes | 42 `*API` classes | + +## 1. Install `wave-sdk` + +```bash +pip uninstall -y wave-av-sdk wave-sdk +pip install "wave-sdk>=2.1.0" +``` + +`wave-av-sdk` and `wave-sdk` were both published at `2.0.0` and contain the same +code. `wave-sdk` is the one name that continues; `wave-av-sdk` is not being +republished. Uninstall **both** before installing: they each drop a top-level +`wave/` directory into `site-packages`, and leaving one behind leaves that +directory (and its stale `2.0.0` modules) on disk next to the new `wave_sdk`. + +## 2. Change `import wave` to `import wave_sdk` + +```diff +-from wave import Wave ++from wave_sdk import Wave + +-from wave import WaveError, RateLimitError ++from wave_sdk import WaveError, RateLimitError + +-import wave +-client = wave.Wave(api_key=..., organization_id=...) ++import wave_sdk ++client = wave_sdk.Wave(api_key=..., organization_id=...) +``` + +Nothing below the top-level name changed. Every class, method, argument and +return type keeps its name, so a find-and-replace of the import line is the +whole migration: + +```bash +# from the root of your project +grep -rl --include='*.py' -E '^\s*(from|import)\s+wave(\W|$)' . \ + | xargs sed -i.bak -E 's/^(\s*)(from|import)(\s+)wave(\W|$)/\1\2\3wave_sdk\4/' +``` + +## Why the rename was required + +CPython ships a standard-library module called `wave` (`Lib/wave.py`, the WAV +audio reader/writer) in every install, on every supported version. The +standard-library directory sits **ahead of `site-packages`** on `sys.path`. + +So for anyone who ran `pip install wave-sdk==2.0.0`, `import wave` resolved to +the standard library, not to the SDK. The SDK's own files were on disk, in +`site-packages/wave/`, and were unreachable: + +```console +$ python -m venv v && ./v/bin/pip install wave-sdk==2.0.0 +$ ./v/bin/python -c "import wave; print(wave.__file__)" +/…/lib/python3.12/wave.py # the standard library, not the SDK +$ ./v/bin/python -c "from wave import Wave" +ImportError: cannot import name 'Wave' from 'wave' +``` + +The defect was invisible during development because the repo checkout is the +first entry on `sys.path`; inside the checkout, the local `wave/` directory won +`import wave` and the test suite passed. `wave_sdk` collides with nothing, and +`import wave` now correctly keeps meaning the standard library: + +```console +$ ./v/bin/python -c "import wave_sdk; print(wave_sdk.__version__)" +2.1.0 +$ ./v/bin/python -c "import wave; print(wave.__file__)" +/…/lib/python3.12/wave.py # still the standard library — no shadowing +``` + +Two gates keep this from recurring: `tests/test_packaging.py` fails any pull +request that reintroduces a top-level package named after a standard-library +module, and `.github/workflows/smoke-install.yml` builds the wheel and imports +it from a fresh virtualenv with no repo on `sys.path`. + +## A note on the `wave.` names in the README + +The README's API tables are written as `wave.clips`, `wave.pipeline`, and so on. +Those are **attributes of a client instance**, not module paths — they describe +the shape of the `Wave` facade whatever you name your variable: + +```python +from wave_sdk import Wave + +wave = Wave(api_key="…", organization_id="org_123") +wave.clips.list() # the `wave.clips` in the README table +``` + +There is no importable `wave` module in this SDK, and there will not be one. + +## License + +`2.1.0` also corrects the distribution's license metadata. The repo has been +Apache-2.0 since commit `99d81d3`, but `pyproject.toml` still declared `MIT`, so +`2.0.0` shipped `License: MIT` in its `METADATA` alongside an Apache-2.0 +`LICENSE` file in the same archive. The license itself did not change — the +metadata now matches the `LICENSE` and `NOTICE` files it ships with. diff --git a/README.md b/README.md index b577f9f..fc519d9 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,30 @@ except WaveError as e: - httpx - pydantic +## Migrating from 2.0.0 + +If you installed `wave-av-sdk` or `wave-sdk` at `2.0.0`, two names changed: + +- **Install** `wave-sdk` (not `wave-av-sdk`). +- **Import** `wave_sdk` (not `wave`). + +```diff +-from wave import Wave ++from wave_sdk import Wave +``` + +Nothing below the top-level name changed, so replacing the import line is the +whole migration. The old `wave` package collided with the Python standard +library's own `wave` module and was never importable from an installed +`2.0.0` — full detail, the uninstall step, and a bulk find-and-replace are in +[MIGRATING.md](MIGRATING.md). + +Note that the `wave.` names in the tables above are attributes of a client +instance, not module paths: name your client whatever you like +(`client = Wave(...)` in the quick start above), and `client.clips` is the row +the table writes as `wave.clips`. + ## License -MIT - WAVE Online, LLC +Apache-2.0 - WAVE Online, LLC. See [LICENSE](LICENSE) and [NOTICE](NOTICE); the +WAVE marks are not licensed under the Apache grant. From 2b66db2f46f25b20a722b66c824f6e06e9992455 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 20:13:38 -0400 Subject: [PATCH 3/3] test(packaging): guard the stdlib-shadow class, and actually run pytest in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, one commit, because the first is worthless without the second. 1. NOTHING in CI ran pytest. `python-lint` runs ruff, `foundation-gate` runs a secret scan plus a file-size gate, `smoke-install` builds and imports the wheel, `release` builds a distribution. None execute a test. The repo had 43 passing tests that a pull request could have deleted wholesale while showing all-green. `python-tests.yml` runs `pytest -q` on 3.9 / 3.12 / 3.13 — the same matrix as smoke-install, so a version that can install the wheel is a version whose behaviour is asserted. 2. `tests/test_packaging.py` adds the metadata guards no other gate covers: - no top-level package this repo ships may be named after a stdlib module (derived from the filesystem, not the pyproject include-globs, since the failure mode is someone re-adding a directory the globs would sweep up); - `import wave` must still resolve to the stdlib FROM the checkout — the one environment where the original bug was invisible, because the checkout is first on sys.path and the local `wave/` won; - `wave_sdk.__version__` == `[project] version`. release.yml checks the git TAG against pyproject, but its `__version__` assertion runs in the post-publish step, i.e. after an upload PyPI will never let us replace. This one runs on every pull request instead; - `[project] license` and the license classifier must match what the LICENSE file actually is, and NOTICE must still ship; - no shipped doc may hand a user `import wave` / `from wave import ...` — README 2.0.0 documented exactly the line that raised ImportError. A control test asserts the package scan sees `wave_sdk`, so a bug that made the scan return nothing cannot silently turn the shadow guard into a no-op. Proven by negative control, not just by passing: reintroducing a top-level `wave/` package in a scratch copy failed both shadow tests; reverting the license line to MIT failed the license test. 52 passed on 3.9.25, 3.12.12 and 3.13.13 (43 pre-existing + 9 new) ruff check: All checks passed! Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/python-tests.yml | 56 ++++++++ tests/test_packaging.py | 201 +++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 .github/workflows/python-tests.yml create mode 100644 tests/test_packaging.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..41e8587 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,56 @@ +name: python tests + +# Runs the pytest suite on every pull request and every push to main. +# +# WHY THIS EXISTS: before this workflow, NOTHING in CI ran pytest. The repo had +# six test files (SDK exports, parity APIs, contract coverage, README quickstart, +# x402, packaging) and 52 tests, and a pull request could delete or break every +# one of them and still show all-green — `python-lint` runs ruff only, +# `foundation-gate` runs a secret scan and a file-size gate, `smoke-install` +# builds and imports the wheel, and `release` builds a distribution. None of them +# execute a test. That gap is what let the stdlib-shadow defect reach PyPI as +# wave-sdk 2.0.0. +# +# The matrix mirrors smoke-install.yml (the floor and the two current versions in +# `requires-python = ">=3.9"`), so a version that can install the wheel is also a +# version whose behaviour is asserted. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.12", "3.13"] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the SDK with every extra it tests + # `realtime` and `x402` are optional extras with their own test modules, + # so the suite needs them present or those tests silently do less work. + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,realtime,x402]" + + - name: pytest + run: python -m pytest -q diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..dc5e569 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,201 @@ +""" +Packaging / distribution-metadata guards. + +These tests exist because of a class of defect that NO other gate in this repo +caught, and that only becomes visible once the package is on PyPI — where it is +unfixable, since PyPI refuses a re-upload of an already-published version. + +The published `wave-sdk==2.0.0` sdist/wheel installs a top-level package named +`wave`. CPython's standard library ships `Lib/wave.py` (WAV audio I/O), and the +stdlib directory sits AHEAD of `site-packages` on `sys.path`. So `import wave` +in a fresh install of 2.0.0 resolves to the stdlib module and the entire SDK is +unreachable — the artifact is 100% unimportable, on every Python version. The +repo checkout hid it: the checkout directory is first on `sys.path`, so the +local `wave/` package won `import wave` during development and under pytest. + +`.github/workflows/smoke-install.yml` guards the same class at the wheel level +(build -> fresh venv -> install -> import from elsewhere). These tests are the +cheap, always-on half: they fail at PR time, in the normal unit run, before a +wheel is ever built, and they extend the guard to the two metadata fields that +a wheel build cannot self-check — the license and the version. + +Guarded here: + 1. No top-level package this repo ships may shadow a stdlib module name. + 2. `import wave` must still resolve to the stdlib, from inside the checkout. + 3. `wave_sdk.__version__` must equal `[project] version` in pyproject.toml. + 4. `[project] license` must match the license the LICENSE file actually is. +""" + +import re +import sys +import sysconfig +from pathlib import Path + +import pytest + +try: # tomllib is stdlib from 3.11; `tomli` is a dev dependency below that. + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised on 3.9/3.10 only + import tomli as tomllib + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = REPO_ROOT / "pyproject.toml" + + +def _pyproject() -> dict: + with PYPROJECT.open("rb") as fh: + return tomllib.load(fh) + + +def _stdlib_top_level_names() -> set: + """Every name `import ` could resolve to from the standard library. + + `sys.stdlib_module_names` is 3.10+, so on 3.9 fall back to reading the + stdlib directory. Both paths are unioned so the guard never gets weaker on + a newer interpreter than the one that wrote it. + """ + names = set(sys.builtin_module_names) + names |= set(getattr(sys, "stdlib_module_names", ())) + stdlib_dir = Path(sysconfig.get_paths()["stdlib"]) + if stdlib_dir.is_dir(): + for entry in stdlib_dir.iterdir(): + if entry.suffix == ".py": + names.add(entry.stem) + elif entry.is_dir() and (entry / "__init__.py").exists(): + names.add(entry.name) + return names + + +def _shipped_top_level_packages() -> list: + """Top-level importable packages in the checkout that setuptools will ship. + + Derived from the filesystem (any root-level directory with an `__init__.py`) + rather than from the pyproject include-globs, because the failure mode being + guarded is exactly someone re-adding a directory the globs would sweep up. + `tests` is excluded: it is not in `[tool.setuptools.packages.find] include`, + so it is never part of the distribution. + """ + skip = {"tests"} + return sorted( + p.name + for p in REPO_ROOT.iterdir() + if p.is_dir() + and not p.name.startswith((".", "_")) + and p.name not in skip + and (p / "__init__.py").exists() + ) + + +def test_repo_ships_the_wave_sdk_package(): + """Control for the two shadow tests below: prove the scan sees anything at all. + + Without this, a bug that made `_shipped_top_level_packages()` return `[]` + would turn the shadow guard into a test that can never fail. + """ + assert "wave_sdk" in _shipped_top_level_packages() + + +def test_no_shipped_package_shadows_a_stdlib_module(): + """A distribution package named after a stdlib module is permanently unimportable. + + site-packages is AFTER the stdlib on sys.path, so the stdlib always wins. + """ + stdlib = _stdlib_top_level_names() + collisions = [name for name in _shipped_top_level_packages() if name in stdlib] + assert collisions == [], ( + f"top-level package(s) {collisions} collide with a Python standard-library " + f"module name. The stdlib precedes site-packages on sys.path, so a user who " + f"runs `pip install wave-sdk` could never import them. Rename the package " + f"(this is exactly the defect that shipped as wave-sdk 2.0.0's `wave`)." + ) + + +def test_import_wave_still_resolves_to_the_standard_library(): + """The specific regression: re-adding a top-level `wave/` here would break users. + + Run from the repo checkout, the checkout is first on sys.path — so if a + `wave/` package reappears, this assertion fails HERE, which is the one place + the old bug was invisible. + """ + import wave # noqa: F401 - imported for its resolved location, not its API + + stdlib_dir = Path(sysconfig.get_paths()["stdlib"]).resolve() + resolved = Path(wave.__file__).resolve() + assert stdlib_dir in resolved.parents, ( + f"`import wave` resolved to {resolved}, not the standard library at " + f"{stdlib_dir}. A top-level `wave` package has been reintroduced." + ) + assert REPO_ROOT not in resolved.parents, f"`import wave` resolved into this repo: {resolved}" + + +def test_dunder_version_matches_pyproject_version(): + """`wave_sdk.__version__` is what users print; pyproject is what PyPI records. + + `.github/workflows/release.yml` checks the git TAG against pyproject, but it + does so on the publish job — and its `__version__` assertion runs only AFTER + the irreversible upload. This check runs on every pull request instead. + """ + import wave_sdk + + assert wave_sdk.__version__ == _pyproject()["project"]["version"] + + +def test_distribution_name_is_wave_sdk(): + """`pip install wave-sdk` is what README, CHANGELOG and MIGRATING all promise.""" + assert _pyproject()["project"]["name"] == "wave-sdk" + + +def test_declared_license_matches_the_license_file(): + """Shipped metadata must not contradict the LICENSE bundled beside it. + + A wheel built before this guard carried `License: MIT` and + `Classifier: License :: OSI Approved :: MIT License` in dist-info/METADATA + while dist-info/licenses/LICENSE was the Apache 2.0 text — two different + grants in one artifact. LICENSE + NOTICE are the authoritative pair, so + pyproject is asserted against them, never the reverse. + """ + license_text = (REPO_ROOT / "LICENSE").read_text(encoding="utf-8") + first_lines = "\n".join(license_text.splitlines()[:5]) + assert "Apache License" in first_lines and "Version 2.0" in first_lines, ( + "LICENSE is no longer Apache-2.0; update this guard and pyproject together." + ) + + project = _pyproject()["project"] + assert project["license"] == {"text": "Apache-2.0"}, ( + f"[project] license is {project['license']!r} but LICENSE is Apache-2.0" + ) + + license_classifiers = [c for c in project["classifiers"] if c.startswith("License ::")] + assert license_classifiers == ["License :: OSI Approved :: Apache Software License"], ( + f"license classifier(s) {license_classifiers} contradict the Apache-2.0 LICENSE file" + ) + + +def test_notice_file_is_shipped_alongside_the_license(): + """Apache-2.0 section 4(d): a NOTICE file must travel with the distribution. + + setuptools>=69 auto-includes root LICENSE* and NOTICE* into + `dist-info/licenses/`. This asserts the file still exists and still carries + the trademark carve-out, so it cannot be silently dropped. + """ + notice = (REPO_ROOT / "NOTICE").read_text(encoding="utf-8") + assert "WAVE Online, LLC" in notice + assert "trademark" in notice.lower() + + +@pytest.mark.parametrize("doc", ["README.md", "MIGRATING.md"]) +def test_docs_do_not_tell_users_to_import_wave(doc): + """No shipped doc may hand a user `import wave` / `from wave import ...`. + + README 2.0.0 documented `from wave import Wave`, which is precisely the line + that raised ImportError for every installed user. + """ + text = (REPO_ROOT / doc).read_text(encoding="utf-8") + offenders = [ + line.strip() + for line in text.splitlines() + # `wave_sdk` / `wave_av_sdk` must not trip this; a bare `wave` and a + # submodule path like `from wave.realtime import ...` both must. + if re.match(r"^\s*(import\s+wave|from\s+wave)(?!\w)", line) + ] + assert offenders == [], f"{doc} instructs users to import the stdlib `wave`: {offenders}"