From 0d2c488071ac02b2172730b16679a01641d96b22 Mon Sep 17 00:00:00 2001 From: krahd Date: Tue, 19 May 2026 01:22:04 -0300 Subject: [PATCH] Harden release workflow ordering and JSON client tests --- .github/workflows/publish.yml | 10 +-- STATUS.md | 15 +++-- docs/API.md | 9 +-- tests/test_client_json.py | 121 ++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 tests/test_client_json.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 365bcf8..b597611 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Validate tag matches package version run: | python - <<'PY' @@ -45,11 +50,6 @@ jobs: print(f"release version OK: {package_version}") PY - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Build distributions run: | python -m pip install --upgrade pip diff --git a/STATUS.md b/STATUS.md index 9e5e367..0dcf730 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # modelito – Project Status -Last updated: 2026-05-19 01:13 +Last updated: 2026-05-19 01:21 ## Project purpose @@ -53,7 +53,7 @@ Phase 4 server-contract hardening and provider cleanup pass complete: 19. Latest review-feedback fixes made fallback streaming lazy, scoped warning headers to tool fallbacks, shared readiness probes between client and doctor, and cleaned up package exports. - `modelito-serve` exposes `/v1/models`, `/v1/chat/completions`, and `/v1/embeddings` for Pi and other OpenAI-compatible consumers. -- Validation completed locally in this session: `python scripts/check_no_legacy_dicts.py` -> no literal dict-shaped message examples found in docs/examples; `ruff check .` clean; `mypy modelito --ignore-missing-imports` clean; `pytest -q` -> `236 passed, 2 skipped`; `python -c "from modelito import flatten_message_inputs; print(flatten_message_inputs(['hello']))"` -> `[{"role": "user", "content": "hello"}]`. +- Validation status is recorded in the current session snapshot under Recent changes. ## Architecture overview @@ -150,7 +150,14 @@ python -m twine check dist/* - Current oMLX stack uses `OpenAICompatibleHTTPProvider` with strict-mode typed error handling. - Current provider typing includes `ChatProvider`, `MessageInput`, and `OpenAIMessageDict` exports, with `Client` chat-related methods accepting broadened message input types; provider protocols are aligned so `SyncProvider`, `AsyncProvider`, `StreamingProvider`, and `ChatProvider` all accept `Iterable[MessageInput]`. - `Client.chat_json()` now supports optional stronger schema validation via `strict_schema=True` using dataclass construction or Pydantic-style `model_validate`/`parse_obj` hooks, while preserving lightweight key-presence checks by default. -- Validation completed locally in this session: `python scripts/check_no_legacy_dicts.py` -> no literal dict-shaped message examples found in docs/examples; `ruff check .` clean; `mypy modelito --ignore-missing-imports` clean; `pytest -q --ignore=tests/integration tests` -> `250 passed, 1 skipped`; `python -c "from modelito import get_model_metadata; print(get_model_metadata('gpt-4o-mini')['provider'])"` -> `openai`; optional checks: `python -m build` succeeded and `python -m twine check dist/*` passed. +- Validation completed locally in this session: + - `python scripts/check_no_legacy_dicts.py` -> clean + - `ruff check .` -> clean + - `mypy modelito --ignore-missing-imports` -> clean + - `pytest -q --ignore=tests/integration tests` -> `257 passed, 1 skipped` + - `python -c "import modelito; print(modelito.__version__)"` -> `1.4.4` + - `python -m build` -> succeeded + - `python -m twine check dist/*` -> passed - Trusted publishing note: the workflow is configured for PyPI trusted publishing, but PyPI project-side trusted publisher settings must be verified before release, and the release tag must match `pyproject.toml`. - Historical release narratives are maintained in `CHANGELOG.md`; STATUS.md is kept as a current-state snapshot. @@ -187,4 +194,4 @@ python -m twine check dist/* --- -Last updated: 2026-05-19 01:13 +Last updated: 2026-05-19 01:21 diff --git a/docs/API.md b/docs/API.md index 6eb2319..1d5245b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,12 +149,13 @@ Structured output helpers ------------------------- `Client.chat_json(messages, schema=None, settings=None, strict_schema=False) -> dict` -: Request structured JSON output, keep the parsed dict, and optionally apply - lightweight schema validation. +: Request structured JSON output and return a parsed `dict`; optionally apply + key-presence schema checks and stricter runtime validation when + `strict_schema=True`. `Client.chat_parsed(messages, schema, settings=None, strict_schema=True) -> Any` -: Request structured JSON output and construct the schema object directly when - the schema is a dataclass or Pydantic-style model. +: Request structured JSON output and return a parsed schema object when + supported (dataclass or Pydantic-style model hooks). Ollama helpers -------------- diff --git a/tests/test_client_json.py b/tests/test_client_json.py new file mode 100644 index 0000000..b623cdf --- /dev/null +++ b/tests/test_client_json.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from typing import TypedDict + +import pytest + +from modelito.client import Client +from modelito.messages import Response + + +class FakeSummarizeProvider: + model = "fake" + + def __init__(self, payload: str): + self.payload = payload + self.last_settings = None + + def list_models(self): + return [self.model] + + def summarize(self, messages, settings=None): + self.last_settings = settings + return self.payload + + +class FakeChatProvider(FakeSummarizeProvider): + def __init__(self, payload: str): + super().__init__(payload) + self.chat_called = False + self.summarize_called = False + + def summarize(self, messages, settings=None): + self.summarize_called = True + return super().summarize(messages, settings=settings) + + def chat(self, messages, settings=None): + self.chat_called = True + self.last_settings = settings + return Response(text=self.payload) + + +class PersonSchema(TypedDict): + name: str + age: int + + +@dataclass +class Person: + name: str + age: int + + +def test_chat_json_injects_json_response_format(): + provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}') + client = Client(provider=provider) + + result = client.chat_json(["hello"]) + + assert result == {"name": "Ada", "age": 36} + assert provider.last_settings["response_format"] == {"type": "json_object"} + + +def test_chat_json_preserves_existing_settings(): + provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}') + client = Client(provider=provider) + + client.chat_json(["hello"], settings={"temperature": 0}) + + assert provider.last_settings["temperature"] == 0 + assert provider.last_settings["response_format"] == {"type": "json_object"} + + +def test_chat_json_raises_for_invalid_json(): + provider = FakeSummarizeProvider("not json") + client = Client(provider=provider) + + with pytest.raises(ValueError): + client.chat_json(["hello"]) + + +def test_chat_json_key_presence_schema_validation(): + provider = FakeSummarizeProvider('{"name": "Ada"}') + client = Client(provider=provider) + + with pytest.raises(ValueError, match="missing required keys"): + client.chat_json(["hello"], schema=PersonSchema) + + +def test_chat_json_strict_schema_dataclass_success_and_failure(): + ok_provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}') + ok_client = Client(provider=ok_provider) + + ok = ok_client.chat_json(["hello"], schema=Person, strict_schema=True) + assert ok == {"name": "Ada", "age": 36} + + bad_provider = FakeSummarizeProvider('{"name": "Ada", "age": 36, "extra": true}') + bad_client = Client(provider=bad_provider) + + with pytest.raises(ValueError, match="dataclass validation"): + bad_client.chat_json(["hello"], schema=Person, strict_schema=True) + + +def test_chat_parsed_returns_dataclass_instance(): + provider = FakeSummarizeProvider('{"name": "Ada", "age": 36}') + client = Client(provider=provider) + + obj = client.chat_parsed(["hello"], Person) + + assert isinstance(obj, Person) + assert obj.name == "Ada" + assert obj.age == 36 + + +def test_chat_json_uses_provider_chat_when_available(): + provider = FakeChatProvider('{"ok": true}') + client = Client(provider=provider) + + result = client.chat_json(["hello"]) + + assert result == {"ok": True} + assert provider.chat_called is True + assert provider.summarize_called is False