diff --git a/.github/workflows/publish-manual.yml b/.github/workflows/publish-manual.yml index 1a2a190..6600192 100644 --- a/.github/workflows/publish-manual.yml +++ b/.github/workflows/publish-manual.yml @@ -23,10 +23,23 @@ jobs: with: python-version: '3.11' - - name: Update version in setup.py - if: inputs.version != '' + - name: Verify requested version matches pyproject.toml + env: + REQUESTED_VERSION: ${{ inputs.version }} run: | - sed -i "s/version='[^']*'/version='${{ inputs.version }}'/" setup.py + python - <<'PY' + import os + import tomllib + from pathlib import Path + + requested = os.environ["REQUESTED_VERSION"].removeprefix("v") + actual = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"] + if requested != actual: + raise SystemExit( + f"Requested version {requested!r} does not match pyproject.toml ({actual}). " + "Update pyproject.toml before publishing." + ) + PY - name: Install build dependencies run: | @@ -47,4 +60,3 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | twine upload dist/* - diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f5a814a..946ce66 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,10 +25,23 @@ jobs: with: python-version: '3.11' - - name: Update version in setup.py (manual trigger) - if: github.event_name == 'workflow_dispatch' && inputs.version != '' + - name: Verify requested version matches pyproject.toml + env: + REQUESTED_VERSION: ${{ inputs.version || github.event.release.tag_name }} run: | - sed -i "s/version='[^']*'/version='${{ inputs.version }}'/" setup.py + python - <<'PY' + import os + import tomllib + from pathlib import Path + + requested = os.environ["REQUESTED_VERSION"].removeprefix("v") + actual = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"] + if requested != actual: + raise SystemExit( + f"Requested version {requested!r} does not match pyproject.toml ({actual}). " + "Update pyproject.toml before publishing." + ) + PY - name: Install build dependencies run: | @@ -65,4 +78,3 @@ jobs: fi echo "✅ PyPI token found. Uploading package to PyPI..." twine upload dist/* - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fef1a4..9936146 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,7 +111,7 @@ jobs: python -m venv /tmp/smoke /tmp/smoke/bin/pip install --upgrade pip /tmp/smoke/bin/pip install dist/*.whl - /tmp/smoke/bin/python -c "import fastapi_viewsets, sys; print('imported', fastapi_viewsets.__name__)" + /tmp/smoke/bin/python -I -c "from fastapi_viewsets import BaseViewset, AsyncBaseViewset; print('imported installed wheel')" - name: Upload distributions uses: actions/upload-artifact@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 494656c..8a10562 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} restore-keys: | ${{ runner.os }}-pip- @@ -83,3 +83,34 @@ jobs: run: | pytest tests/ -v --cov=fastapi_viewsets --cov-report=term-missing --cov-fail-under=70 + package: + name: Build and test installed wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build sdist and wheel + run: | + python -m pip install build twine + python -m build + python -m twine check --strict dist/* + + - name: Install wheel with the minimum SQLAlchemy version + run: | + python -m venv .venv + .venv/bin/python -m pip install dist/*.whl pytest pytest-asyncio httpx "SQLAlchemy==2.0.0" + .venv/bin/python -m pip check + .venv/bin/python -I -c "from fastapi_viewsets import BaseViewset, AsyncBaseViewset" + + - name: Exercise sync quickstarts without an async driver + run: | + .venv/bin/python -m pytest --noconftest -o addopts= tests/test_quickstarts.py -k "not async" + + - name: Exercise all quickstarts with SQLite async support + run: | + .venv/bin/python -m pip install aiosqlite + .venv/bin/python -m pytest --noconftest -o addopts= tests/test_quickstarts.py diff --git a/.gitignore b/.gitignore index 45ddf0a..c7fde5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ site/ +.venv/ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage* +coverage.xml +htmlcov/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a8112f6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include pytest.ini mkdocs.yml +recursive-include tests *.py +recursive-include docs *.md *.css diff --git a/README.md b/README.md index a2043bf..e43fbb4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,10 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint pip install fastapi-viewsets ``` -Optional extras (see `setup.py`): +SQLAlchemy 2.0 or newer is installed automatically. For a local SQLite app, +start with the [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed. + +Optional extras (see `pyproject.toml`): ```bash pip install "fastapi-viewsets[sqlalchemy]" @@ -377,6 +380,9 @@ an async-capable URL and use the lazy helpers from `db_conf`. The package auto-converts `sqlite://` to `sqlite+aiosqlite://`, `postgresql://` to `postgresql+asyncpg://`, etc. +Save the following as `main.py` in an empty folder, then run +`uvicorn main:app --reload`. Open `http://127.0.0.1:8000/docs` to try the API. + ```python from contextlib import asynccontextmanager @@ -691,9 +697,9 @@ class ItemsWithStats(BaseViewset): - Internal `register()` deduplicated between sync and async viewsets via a shared mixin. - PEP 621 `pyproject.toml`, `python_requires>=3.9`, FastAPI `>=0.110`, ruff/black/mypy preconfigured. -Previous release: [v1.1.0](RELEASE_1.1.0.md) introduced multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee), `ORMFactory` and environment-driven `ORM_TYPE` configuration. +Previous release: [v1.1.0](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.1.0.md) introduced multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee), `ORMFactory` and environment-driven `ORM_TYPE` configuration. -Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.md), [RELEASE_1.1.0.md](RELEASE_1.1.0.md). +Details: [RELEASE_NOTES.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_NOTES.md), [RELEASE_1.2.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.2.0.md), [RELEASE_1.1.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.1.0.md). ## Roadmap (planned) @@ -718,6 +724,7 @@ Released: server-side `search` (v1.5.0), declarative ordering and advanced filte From the repository root (see `pytest.ini`): ```bash +python -m pip install -e ".[test]" pytest ``` @@ -729,7 +736,7 @@ See [open issues](https://github.com/svalench/fastapi_viewsets/issues) to propos ## License -Distributed under the MIT License. See [LICENSE](LICENSE). +Distributed under the MIT License. See [LICENSE](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE). ## Author diff --git a/docs/getting-started.md b/docs/getting-started.md index 059c509..0cb3d0c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,7 @@ | Python | >= 3.9 | | FastAPI | >= 0.110 | | Pydantic | >= 2.5, < 3 | -| SQLAlchemy | >= 1.4.36 | +| SQLAlchemy | >= 2.0.0 | | python-dotenv | >= 0.19 | ## Install from PyPI diff --git a/docs/quickstart-async.md b/docs/quickstart-async.md index 822bcbb..81f773c 100644 --- a/docs/quickstart-async.md +++ b/docs/quickstart-async.md @@ -33,6 +33,9 @@ SQLALCHEMY_DATABASE_URL=sqlite:///./test.db ## Full example +Save as `main.py` in an empty folder and run `uvicorn main:app --reload`. +Open `http://127.0.0.1:8000/docs` to try the CRUD endpoints. + ```python from contextlib import asynccontextmanager diff --git a/pyproject.toml b/pyproject.toml index d616aea..0bd854d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,13 +28,13 @@ classifiers = [ dependencies = [ "fastapi>=0.110.0", "uvicorn>=0.17.6", - "SQLAlchemy>=1.4.36", + "SQLAlchemy>=2.0.0", "pydantic>=2.5,<3", "python-dotenv>=0.19.0", ] [project.optional-dependencies] -sqlalchemy = ["SQLAlchemy>=1.4.36"] +sqlalchemy = ["SQLAlchemy>=2.0.0"] tortoise = ["tortoise-orm>=0.20.0,<1.0", "asyncpg>=0.28.0"] peewee = ["peewee>=3.17.0,<4"] test = [ diff --git a/setup.cfg b/setup.cfg index 224a779..579263e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [metadata] -description-file = README.md \ No newline at end of file +# Project metadata, including the README, is declared in pyproject.toml. diff --git a/tests/test_quickstarts.py b/tests/test_quickstarts.py new file mode 100644 index 0000000..3b93abb --- /dev/null +++ b/tests/test_quickstarts.py @@ -0,0 +1,98 @@ +"""Exercise the published examples without substituting test-only app code.""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = [ + ("README.md", "Quickstart (SQLAlchemy, sync)"), + ("README.md", "Async quickstart (SQLAlchemy 2.x + Pydantic v2)"), + ("docs/quickstart-sync.md", "Full example"), + ("docs/quickstart-async.md", "Full example"), + ("docs/index.md", "30-second example"), +] + +CHECK_ENDPOINTS = """ +from fastapi.testclient import TestClient + +with TestClient(app) as client: + response = client.get("/items") + assert response.status_code == 200, response.text + assert response.json() == [] + + response = client.post("/items", json={"name": "apple"}) + assert response.status_code == 200, response.text + item = response.json() + assert isinstance(item["id"], int) + assert item["name"] == "apple" + url = f'/items/{item["id"]}' + + response = client.get(url) + assert response.status_code == 200, response.text + assert response.json() == item + + response = client.get("/items?limit=10&offset=0") + assert response.status_code == 200, response.text + assert response.json() == [item] + + response = client.patch(url, json={"name": "banana"}) + assert response.status_code == 200, response.text + assert response.json() == {"id": item["id"], "name": "banana"} + + response = client.delete(url) + assert response.status_code == 200, response.text + assert response.json() == {"status": True, "text": "successfully deleted"} + assert client.get(url).status_code == 404 + assert client.get("/items").json() == [] + + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + paths = response.json()["paths"] + assert {"get", "post"} <= paths["/items"].keys() + assert {"get", "patch", "delete"} <= paths["/items/{id}"].keys() +""" + + +@pytest.mark.parametrize( + "filename,heading", + EXAMPLES, + ids=["readme-sync", "readme-async", "docs-sync", "docs-async", "docs-index"], +) +def test_quickstart(filename, heading, tmp_path): + text = (ROOT / filename).read_text(encoding="utf-8") + section = text.split(f"## {heading}\n", 1)[1].split("\n## ", 1)[0] + snippet = re.search(r"^```python\n(.*?)^```", section, flags=re.M | re.S) + assert snippet is not None, f"No Python example in {filename}: {heading}" + + # A fresh process and directory prevent db_conf/ORMFactory caches, an + # existing database, or a developer's .env from masking startup failures. + # -I also prevents the source checkout from hiding a broken installed wheel. + env = os.environ.copy() + for key in ( + "ORM_TYPE", + "DATABASE_URL", + "SQLALCHEMY_DATABASE_URL", + "SQLALCHEMY_ASYNC_DATABASE_URL", + ): + env.pop(key, None) + # Execute checks in the same namespace, without running the example's + # __main__ block (which would start a long-lived Uvicorn server). + program = ( + "namespace = {'__name__': 'quickstart'}\n" + f"exec(compile({snippet.group(1)!r}, {filename!r}, 'exec'), namespace)\n" + f"exec({CHECK_ENDPOINTS!r}, namespace)\n" + ) + result = subprocess.run( + [sys.executable, "-I", "-c", program], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr