Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ on:
types: [opened, reopened, synchronize, ready_for_review]

jobs:
# Typecheck and the offline tests. Needs no credentials, so this still runs on
# forks, where the integration job has no secrets and every test errors.
unit-tests:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
name: Checkout fragment-python

- name: Use Python 3.10.14
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.10.14'

- name: Install dependencies
run: |
pip install poetry
poetry install --with dev

- name: Typecheck
run: make typecheck

- name: Run offline tests
run: make unit

integration-tests:
runs-on: ubuntu-latest
permissions:
Expand All @@ -30,9 +57,6 @@ jobs:
pip install poetry
poetry install --with dev

- name: Typecheck
run: poetry run mypy -p fragment

- name: Run tests
run: poetry run pytest -v

Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ sort_order:
style:
poetry run black fragment/ tests/

# tests/ is checked too: tests/type_checks/ asserts what a caller sees when
# calling the generated client, which no runtime test can cover.
typecheck:
poetry run mypy -p fragment
poetry run mypy tests/

# Everything that needs no credentials and no network.
unit:
poetry run pytest -m "not integration"

# Integration tests. Requires CLIENT_ID, CLIENT_SECRET, SCOPE, AUTH_URL and
# API_URL in the environment; the tests fail if any are missing.
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@ testpaths = ["tests"]
# generated code rather than a hand-written approximation of it. Regenerate with
# `make snapshots`.
pythonpath = ["tests/snapshots/001-marketing-schema"]
# Everything unmarked runs offline. `make unit` deselects this marker.
markers = ["integration: needs live API credentials; see tests/conftest.py"]

[tool.mypy]
# Same path pytest uses, so `mypy tests/` can resolve the snapshotted `sdk`
# package and actually typecheck calls against the generated typed payloads.
mypy_path = "tests/snapshots/001-marketing-schema"
# tests/type_checks/ writes "this call is rejected" as `# type: ignore[...]`.
# This turns the day it stops being rejected into a failure, not a silent pass.
warn_unused_ignores = true

[tool.pylint.messages_control]
max-line-length = 88
Expand Down
34 changes: 31 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import AsyncIterator, Dict
from typing import AsyncIterator, TypedDict

import pytest
import pytest_asyncio
Expand All @@ -9,8 +9,23 @@
REQUIRED_ENV_VARS = ("CLIENT_ID", "CLIENT_SECRET", "SCOPE", "AUTH_URL", "API_URL")


class Credentials(TypedDict):
"""The `Client` keyword arguments read from the environment.

A TypedDict rather than `Dict[str, str]` so `Client(**credentials)`
typechecks: against a plain str mapping, a key could land on `http_client`,
which takes an `AsyncClient`.
"""

client_id: str
client_secret: str
auth_scope: str
auth_url: str
api_url: str


@pytest.fixture(scope="session")
def credentials() -> Dict[str, str]:
def credentials() -> Credentials:
missing = [name for name in REQUIRED_ENV_VARS if not os.environ.get(name)]
if missing:
pytest.fail(
Expand All @@ -29,6 +44,19 @@ def credentials() -> Dict[str, str]:


@pytest_asyncio.fixture
async def client(credentials: Dict[str, str]) -> AsyncIterator[Client]:
async def client(credentials: Credentials) -> AsyncIterator[Client]:
async with Client(**credentials) as graphql_client:
yield graphql_client


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"""Mark anything needing live credentials as `integration`.

Derived from fixture usage rather than written at the top of each module, so
a new integration test cannot forget it and a merge cannot drop it. Losing
one `pytestmark` line silently put two credential-bound tests into the
offline run, where they errored on missing environment variables.
"""
for item in items:
if "credentials" in getattr(item, "fixturenames", ()):
item.add_marker(pytest.mark.integration)
Loading
Loading