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/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. 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] 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}"