diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index faf0da2..0475309 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -18,4 +18,4 @@ jobs: python-version-file: ".python-version" - name: Run Tests - run: make uv-test + run: make uv-test-isolated diff --git a/Makefile b/Makefile index f931e9d..035bbf5 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ DOCKER_COMPOSE := docker compose -f docker-compose.yml DOCKER_COMPOSE_DEV := $(DOCKER_COMPOSE) -f docker-compose.local.yml PYTEST := $(UV) run pytest RUFF := $(UV) run ruff +TEST_PKG := ./scripts/test-package.py # Aggregate check: can be run in parallel with -j check: lint test format-check lock-check @@ -21,8 +22,31 @@ lock-check: uv-lock-check uv-lint: uv-dev $(RUFF) check +# All workspace packages with tests +TEST_PACKAGES := stitch-api stitch-models + +define newline + + +endef + +# --- local: full sync, no --exact (fast, no venv mutation) --- +ifdef pkg +uv-test: uv-dev + $(TEST_PKG) $(pkg) +else uv-test: uv-dev - $(PYTEST) deployments/api + $(foreach p,$(TEST_PACKAGES),$(TEST_PKG) $(p)$(newline)) +endif + +# --- isolated (CI): per-package --exact deps only --- +ifdef pkg +uv-test-isolated: + $(TEST_PKG) --exact $(pkg) +else +uv-test-isolated: + $(foreach p,$(TEST_PACKAGES),$(TEST_PKG) --exact $(p)$(newline)) +endif uv-format: uv-dev $(RUFF) format @@ -127,7 +151,7 @@ prod-docker: .PHONY: all build clean \ build-python \ check lint test format format-check \ - uv-lint uv-test uv-format uv-format-check \ + uv-lint uv-test uv-test-isolated uv-format uv-format-check \ uv-sync uv-sync-dev uv-sync-all \ uv-dev \ clean-build clean-cache \ diff --git a/packages/stitch-models/tests/conftest.py b/packages/stitch-models/tests/conftest.py index 4087288..530ed11 100644 --- a/packages/stitch-models/tests/conftest.py +++ b/packages/stitch-models/tests/conftest.py @@ -3,8 +3,9 @@ from __future__ import annotations from typing import Literal, Mapping -from uuid import UUID +from uuid import UUID, uuid4 +from pydantic import Field import pytest from stitch.models import ( @@ -24,7 +25,7 @@ class FooSource(Source[int, Literal["foo"]]): value: float -class BarSource(Source[str, Literal["bar"]]): +class BarSource(Source[UUID, Literal["bar"]]): source: Literal["bar"] = "bar" label: str @@ -39,16 +40,16 @@ class UuidSource(Source[UUID, Literal["uuid_src"]]): class FooPayload(SourcePayload): - foos: Mapping[int, FooSource] = {} + foos: Mapping[int, FooSource] = Field(default_factory=dict) class MultiPayload(SourcePayload): - foos: Mapping[int, FooSource] = {} - bars: Mapping[str, BarSource] = {} + foos: Mapping[int, FooSource] = Field(default_factory=dict) + bars: Mapping[str, BarSource] = Field(default_factory=dict) class UuidPayload(SourcePayload): - uuids: Mapping[UUID, UuidSource] = {} + uuids: Mapping[UUID, UuidSource] = Field(default_factory=dict) # --------------------------------------------------------------------------- @@ -60,15 +61,15 @@ class EmptyPayload(SourcePayload): pass -class FooResource(Resource[int, int, str, FooPayload]): +class FooResource(Resource[int, FooPayload]): pass -class MultiResource(Resource[int, int, str, MultiPayload]): +class MultiResource(Resource[int, MultiPayload]): pass -class ExtendedResource(Resource[int, int, str, EmptyPayload]): +class ExtendedResource(Resource[int, EmptyPayload]): extra: str @@ -85,7 +86,7 @@ def __init__(self, id: int, source: str, value: float): class BarSourceORM: - def __init__(self, id: str, source: str, label: str): + def __init__(self, id: UUID, source: str, label: str): self.id = id self.source = source self.label = label @@ -103,7 +104,7 @@ def foo_source(): @pytest.fixture def bar_source(): - return BarSource(id="abc", label="test") + return BarSource(id=uuid4(), label="test") @pytest.fixture diff --git a/packages/stitch-models/tests/test_resource.py b/packages/stitch-models/tests/test_resource.py index 5bdfaa0..6c3d1fd 100644 --- a/packages/stitch-models/tests/test_resource.py +++ b/packages/stitch-models/tests/test_resource.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from uuid import UUID +from uuid import UUID, uuid4 import pytest from pydantic import ValidationError @@ -112,7 +112,7 @@ def test_rejects_missing_source_data(self): assert "source_data" in missing_locs def test_rejects_wrong_source_in_payload(self): - bar = BarSource(id="abc", label="test") + bar = BarSource(id=uuid4(), label="test") with pytest.raises(ValidationError) as exc_info: FooPayload(foos={1: bar}) # type: ignore[arg-type] errors = exc_info.value.errors() diff --git a/packages/stitch-models/tests/test_source.py b/packages/stitch-models/tests/test_source.py index 3aa1e21..d6aaae0 100644 --- a/packages/stitch-models/tests/test_source.py +++ b/packages/stitch-models/tests/test_source.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from uuid import UUID +from uuid import UUID, uuid4 import pytest from pydantic import ValidationError @@ -42,7 +42,7 @@ def test_source_default_from_subclass(self): foo = FooSource(id=1, value=1.0) assert foo.source == "foo" - bar = BarSource(id="abc", label="test") + bar = BarSource(id=uuid4(), label="test") assert bar.source == "bar" def test_id_type_specialization(self): @@ -51,10 +51,11 @@ def test_id_type_specialization(self): assert foo.id == 42 assert isinstance(foo.id, int) - # str id - bar = BarSource(id="abc", label="test") - assert bar.id == "abc" - assert isinstance(bar.id, str) + # UUID id + uid = uuid4() + bar = BarSource(id=uid, label="test") + assert bar.id == uid + assert isinstance(bar.id, UUID) # UUID id uid = UUID("550e8400-e29b-41d4-a716-446655440000") @@ -77,9 +78,10 @@ def test_from_attributes_orm_object(self): assert foo.source == "foo" assert foo.value == 3.14 - bar_orm = BarSourceORM(id="abc", source="bar", label="test") + uid = uuid4() + bar_orm = BarSourceORM(id=uid, source="bar", label="test") bar = BarSource.model_validate(bar_orm, from_attributes=True) - assert bar.id == "abc" + assert bar.id == uid assert bar.label == "test" @@ -106,11 +108,11 @@ def test_int_id_rejects_non_numeric_string(self): FooSource.model_validate_json(payload) assert_has_error(exc_info.value.errors(), type="int_parsing", loc=("id",)) - def test_str_id_rejects_int_via_json(self): + def test_uuid_id_rejects_int_via_json(self): payload = json.dumps({"id": 123, "source": "bar", "label": "test"}) with pytest.raises(ValidationError) as exc_info: BarSource.model_validate_json(payload) - assert_has_error(exc_info.value.errors(), type="string_type", loc=("id",)) + assert_has_error(exc_info.value.errors(), type="uuid_type", loc=("id",)) def test_uuid_id_rejects_malformed_string(self): payload = json.dumps({"id": "not-a-uuid", "source": "uuid_src"}) diff --git a/scripts/test-package.py b/scripts/test-package.py new file mode 100755 index 0000000..0dd1f66 --- /dev/null +++ b/scripts/test-package.py @@ -0,0 +1,45 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# /// + +import argparse +import subprocess +import sys +import tomllib +from pathlib import Path + + +def find_package_dir(name: str) -> str: + root = tomllib.loads(Path("pyproject.toml").read_text()) + for member in root["tool"]["uv"]["workspace"]["members"]: + p = Path(member) / "pyproject.toml" + if p.exists() and tomllib.loads(p.read_text())["project"]["name"] == name: + return member + print(f"error: package {name!r} not found in workspace", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run pytest for a uv workspace package.", + ) + parser.add_argument("package", help="workspace package name") + parser.add_argument( + "--exact", + action="store_true", + help="install only the package's declared deps (CI isolation)", + ) + known, pytest_args = parser.parse_known_args() + + pkg_dir = find_package_dir(known.package) + + cmd = ["uv", "run", "--package", known.package] + if known.exact: + cmd += ["--exact", "--group", "dev"] + cmd += ["pytest", pkg_dir, *pytest_args] + + sys.exit(subprocess.call(cmd)) + + +main()