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
13 changes: 8 additions & 5 deletions fragment/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,20 @@ def __init__(
super().__init__(url=api_url, http_client=http_client)

self.auth_url = auth_url
self.expiration_time = None
self.token = None
self.expiration_time: Optional[float] = None
self.token: Optional[Dict[str, Any]] = None
self.oauth2_client = AsyncOAuth2Client(
client_id, client_secret, scope=auth_scope
)

async def refresh_token(self):
async def refresh_token(self) -> None:
now = time.time()
if self.expiration_time is None or self.expiration_time <= now:
self.token = await self.oauth2_client.fetch_token(self.auth_url)
self.expiration_time = now + self.token["expires_in"]
# Held in a local because `self.token` is declared `dict | None`,
# so reading the attribute back is not narrowed by the assignment.
token = await self.oauth2_client.fetch_token(self.auth_url)
self.token = token
self.expiration_time = now + token["expires_in"]

async def execute(
self,
Expand Down
13 changes: 8 additions & 5 deletions fragment/client/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ def __init__(
super().__init__(url=api_url, http_client=http_client)

self.auth_url = auth_url
self.expiration_time = None
self.token = None
self.expiration_time: Optional[float] = None
self.token: Optional[Dict[str, Any]] = None
self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope)

def refresh_token(self):
def refresh_token(self) -> None:
now = time.time()
if self.expiration_time is None or self.expiration_time <= now:
self.token = self.oauth2_client.fetch_token(self.auth_url)
self.expiration_time = now + self.token["expires_in"]
# Held in a local because `self.token` is declared `dict | None`,
# so reading the attribute back is not narrowed by the assignment.
token = self.oauth2_client.fetch_token(self.auth_url)
self.token = token
self.expiration_time = now + token["expires_in"]

def execute(
self,
Expand Down
4 changes: 2 additions & 2 deletions fragment/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
class MissingTokenException(ValueError):
"""Token not found."""

def __init__(self):
def __init__(self) -> None:
super().__init__("Token is None")


class MissingArgumentException(ValueError):
"""Argument not present."""

def __init__(self, argument: str):
def __init__(self, argument: str) -> None:
super().__init__(f"{argument} must be provided")
Empty file added fragment/py.typed
Empty file.
13 changes: 8 additions & 5 deletions fragment/sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,20 @@ def __init__(
super().__init__(url=api_url, http_client=http_client)

self.auth_url = auth_url
self.expiration_time = None
self.token = None
self.expiration_time: Optional[float] = None
self.token: Optional[Dict[str, Any]] = None
self.oauth2_client = AsyncOAuth2Client(
client_id, client_secret, scope=auth_scope
)

async def refresh_token(self):
async def refresh_token(self) -> None:
now = time.time()
if self.expiration_time is None or self.expiration_time <= now:
self.token = await self.oauth2_client.fetch_token(self.auth_url)
self.expiration_time = now + self.token["expires_in"]
# Held in a local because `self.token` is declared `dict | None`,
# so reading the attribute back is not narrowed by the assignment.
token = await self.oauth2_client.fetch_token(self.auth_url)
self.token = token
self.expiration_time = now + token["expires_in"]

async def execute(
self,
Expand Down
13 changes: 8 additions & 5 deletions fragment/sync_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,20 @@ def __init__(
super().__init__(url=api_url, http_client=http_client)

self.auth_url = auth_url
self.expiration_time = None
self.token = None
self.expiration_time: Optional[float] = None
self.token: Optional[Dict[str, Any]] = None
self.oauth2_client = AsyncOAuth2Client(
client_id, client_secret, scope=auth_scope
)

async def refresh_token(self):
async def refresh_token(self) -> None:
now = time.time()
if self.expiration_time is None or self.expiration_time <= now:
self.token = await self.oauth2_client.fetch_token(self.auth_url)
self.expiration_time = now + self.token["expires_in"]
# Held in a local because `self.token` is declared `dict | None`,
# so reading the attribute back is not narrowed by the assignment.
token = await self.oauth2_client.fetch_token(self.auth_url)
self.token = token
self.expiration_time = now + token["expires_in"]

async def execute(
self,
Expand Down
13 changes: 8 additions & 5 deletions fragment/sync_sdk/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ def __init__(
super().__init__(url=api_url, http_client=http_client)

self.auth_url = auth_url
self.expiration_time = None
self.token = None
self.expiration_time: Optional[float] = None
self.token: Optional[Dict[str, Any]] = None
self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope)

def refresh_token(self):
def refresh_token(self) -> None:
now = time.time()
if self.expiration_time is None or self.expiration_time <= now:
self.token = self.oauth2_client.fetch_token(self.auth_url)
self.expiration_time = now + self.token["expires_in"]
# Held in a local because `self.token` is declared `dict | None`,
# so reading the attribute back is not narrowed by the assignment.
token = self.oauth2_client.fetch_token(self.auth_url)
self.token = token
self.expiration_time = now + token["expires_in"]

def execute(
self,
Expand Down
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,37 @@ 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

# The SDK is what customers typecheck against, so it is held to strict. The
# codegen package is build tooling and stays on the default settings. Spelled
# out rather than `strict = true`, which mypy only honours globally.
[[tool.mypy.overrides]]
module = [
"fragment.sdk.*",
"fragment.sync_sdk.*",
"fragment.client.*",
"fragment.exceptions",
]
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_decorators = true
no_implicit_reexport = true
warn_return_any = true
strict_equality = true
extra_checks = 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)
13 changes: 8 additions & 5 deletions tests/snapshots/001-marketing-schema/sdk/async_client.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Tests for what the built distribution contains.

`poetry install` puts the source tree on the path, so `fragment/py.typed` is
found whether or not the build is configured to ship it. Only the built wheel
shows what a customer gets, and without the marker a type checker skips the
installed package entirely -- every call into the SDK goes unchecked, and the
typed batch payloads become decoration.

Offline; needs poetry on PATH.
"""

import shutil
import subprocess
import zipfile
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).parent.parent
MARKER = "fragment/py.typed"

pytestmark = pytest.mark.skipif(
shutil.which("poetry") is None, reason="needs poetry to build the wheel"
)


@pytest.fixture(scope="module")
def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path:
output = tmp_path_factory.mktemp("dist")
subprocess.run(
["poetry", "build", "--format", "wheel", "--output", str(output)],
cwd=REPO_ROOT,
check=True,
capture_output=True,
)
built = list(output.glob("*.whl"))
assert len(built) == 1, built
return built[0]


def test_the_marker_file_exists_in_the_source_tree() -> None:
assert (REPO_ROOT / MARKER).is_file()


def test_the_wheel_ships_the_marker(wheel: Path) -> None:
with zipfile.ZipFile(wheel) as archive:
assert MARKER in archive.namelist(), archive.namelist()[:20]


def test_the_wheel_ships_both_sdks(wheel: Path) -> None:
"""A marker only helps for modules that are actually packaged."""
with zipfile.ZipFile(wheel) as archive:
names = set(archive.namelist())
assert "fragment/sdk/typed_entries.py" in names
assert "fragment/sync_sdk/typed_entries.py" in names
Loading
Loading