diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c080c96..26e9aa5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,12 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: uv run ruff format --check src/celeste tests/ + - run: | + if [ -d "packages" ]; then + uv run ruff format --check src/celeste tests/ packages/ + else + uv run ruff format --check src/celeste tests/ + fi lint: runs-on: ubuntu-latest @@ -41,7 +46,12 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: uv run ruff check --output-format=github src/celeste tests/ + - run: | + if [ -d "packages" ]; then + uv run ruff check --output-format=github src/celeste tests/ packages/ + else + uv run ruff check --output-format=github src/celeste tests/ + fi type-check: runs-on: ubuntu-latest @@ -52,7 +62,12 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: uv run mypy -p celeste && uv run mypy tests/ + - run: | + if [ -d "packages" ]; then + uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/ + else + uv run mypy -p celeste && uv run mypy tests/ + fi security: runs-on: ubuntu-latest @@ -63,7 +78,12 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: uv run bandit -c pyproject.toml -r src/ -f screen + - run: | + if [ -d "packages" ]; then + uv run bandit -c pyproject.toml -r src/ packages/ -f screen + else + uv run bandit -c pyproject.toml -r src/ -f screen + fi test: if: ${{ !inputs.skip-tests }} diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 205b0fe2..81170e84 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -54,4 +54,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 412cef9e..d2d60083 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/Makefile b/Makefile index 4e71e9c4..94c95e57 100644 --- a/Makefile +++ b/Makefile @@ -20,19 +20,19 @@ sync: # Linting lint: - uv run ruff check src/celeste tests/ + uv run ruff check src/celeste tests/ packages/ # Linting with auto-fix lint-fix: - uv run ruff check --fix src/celeste tests/ + uv run ruff check --fix src/celeste tests/ packages/ # Formatting format: - uv run ruff format src/celeste tests/ + uv run ruff format src/celeste tests/ packages/ # Type checking (fail fast on any error) typecheck: - @uv run mypy -p celeste && uv run mypy tests/ + @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/ # Testing test: @@ -40,7 +40,7 @@ test: # Security scanning (config reads from pyproject.toml) security: - uv run bandit -c pyproject.toml -r src/ -f screen + uv run bandit -c pyproject.toml -r src/ packages/ -f screen # Full CI/CD pipeline - what GitHub Actions will run ci: diff --git a/pyproject.toml b/pyproject.toml index 27e13edb..5c4f79fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,6 @@ dev = [ "pre-commit>=3.5.0", ] -[project.entry-points."celeste.packages"] -# Example entry points for packages to register their models and clients: -# text_generation = "text_generation:register_models" -# image_generation = "image_generation:register_models" - [tool.uv.workspace] members = ["packages/*"] @@ -133,6 +128,7 @@ strict_equality = true module = "tests.*" disallow_untyped_defs = false # Relax for tests disallow_incomplete_defs = false +disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"] [[tool.mypy.overrides]] module = "httpx" @@ -142,6 +138,15 @@ ignore_missing_imports = true module = "httpx_sse" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = [ + "celeste_text_generation.*", + "celeste_text_generation.client", + "celeste_text_generation.streaming", + "celeste_text_generation.providers.*", +] +disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"] + [tool.bandit] exclude_dirs = [".venv", "__pycache__"] skips = ["B101"] # Skip B101 (assert_used) since we use pytest diff --git a/src/celeste/client.py b/src/celeste/client.py index fe68e94e..00e5cb6e 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -16,7 +16,7 @@ from celeste.streaming import Stream -class Client[In: Input, Out: Output](ABC, BaseModel): +class Client[In: Input, Out: Output, Params: Parameters](ABC, BaseModel): """Base class for all capability-specific clients.""" model_config = ConfigDict(from_attributes=True) @@ -38,7 +38,11 @@ def http_client(self) -> HTTPClient: """Shared HTTP client with connection pooling for this provider.""" return get_http_client(self.provider, self.capability) - async def generate(self, *args: Any, **parameters: Unpack[Parameters]) -> Out: # noqa: ANN401 + async def generate( + self, + *args: Any, # noqa: ANN401 + **parameters: Unpack[Params], # type: ignore[misc] + ) -> Out: """Generate content - signature varies by capability. Args: @@ -59,7 +63,11 @@ async def generate(self, *args: Any, **parameters: Unpack[Parameters]) -> Out: metadata=self._build_metadata(response_data), ) - def stream(self, *args: Any, **parameters: Unpack[Parameters]) -> Stream[Out]: # noqa: ANN401 + def stream( + self, + *args: Any, # noqa: ANN401 + **parameters: Unpack[Params], # type: ignore[misc] + ) -> Stream[Out, Params]: """Stream content - signature varies by capability. Args: @@ -79,7 +87,7 @@ def stream(self, *args: Any, **parameters: Unpack[Parameters]) -> Stream[Out]: inputs = self._create_inputs(*args, **parameters) request_body = self._build_request(inputs, **parameters) sse_iterator = self._make_stream_request(request_body, **parameters) - return self._stream_class()( # type: ignore[call-arg] + return self._stream_class()( sse_iterator, transform_output=self._transform_output, **parameters, @@ -103,13 +111,19 @@ def _parse_usage(self, response_data: dict[str, Any]) -> Usage: @abstractmethod def _parse_content( - self, response_data: dict[str, Any], **parameters: Unpack[Parameters] + self, + response_data: dict[str, Any], + **parameters: Unpack[Params], # type: ignore[misc] ) -> object: """Parse content from provider response.""" ... @abstractmethod - def _create_inputs(self, *args: Any, **parameters: Unpack[Parameters]) -> In: # noqa: ANN401 + def _create_inputs( + self, + *args: Any, # noqa: ANN401 + **parameters: Unpack[Params], # type: ignore[misc] + ) -> In: """Map positional arguments to Input type.""" ... @@ -121,19 +135,23 @@ def _output_class(cls) -> type[Out]: @abstractmethod async def _make_request( - self, request_body: dict[str, Any], **parameters: Unpack[Parameters] + self, + request_body: dict[str, Any], + **parameters: Unpack[Params], # type: ignore[misc] ) -> httpx.Response: """Make HTTP request(s) and return response object.""" ... @abstractmethod - def _stream_class(self) -> type[Stream[Out]]: + def _stream_class(self) -> type[Stream[Out, Params]]: """Return the Stream class for this client.""" ... @abstractmethod def _make_stream_request( - self, request_body: dict[str, Any], **parameters: Unpack[Parameters] + self, + request_body: dict[str, Any], + **parameters: Unpack[Params], # type: ignore[misc] ) -> AsyncIterator[dict[str, Any]]: """Make HTTP streaming request and return async iterator of events.""" ... @@ -161,7 +179,9 @@ def _handle_error_response(self, response: httpx.Response) -> None: ) def _transform_output( - self, content: object, **parameters: Unpack[Parameters] + self, + content: object, + **parameters: Unpack[Params], # type: ignore[misc] ) -> object: """Transform content using parameter mapper output transformations.""" for mapper in self.parameter_mappers(): @@ -171,7 +191,9 @@ def _transform_output( return content def _build_request( - self, inputs: In, **parameters: Unpack[Parameters] + self, + inputs: In, + **parameters: Unpack[Params], # type: ignore[misc] ) -> dict[str, Any]: """Build complete request by combining base request with parameters.""" request = self._init_request(inputs) @@ -183,11 +205,13 @@ def _build_request( return request -_clients: dict[tuple[Capability, Provider], type[Client]] = {} +_clients: dict[tuple[Capability, Provider], type[Client[Any, Any, Any]]] = {} def register_client( - capability: Capability, provider: Provider, client_class: type[Client] + capability: Capability, + provider: Provider, + client_class: type[Client[Any, Any, Any]], ) -> None: """Register a provider-specific client class for a capability. @@ -199,7 +223,9 @@ def register_client( _clients[(capability, provider)] = client_class -def get_client_class(capability: Capability, provider: Provider) -> type[Client]: +def get_client_class( + capability: Capability, provider: Provider +) -> type[Client[Any, Any, Any]]: """Get the registered client class for a capability and provider. Args: diff --git a/src/celeste/constraints.py b/src/celeste/constraints.py index 4b604a45..725536c5 100644 --- a/src/celeste/constraints.py +++ b/src/celeste/constraints.py @@ -34,11 +34,13 @@ class Range(Constraint): """Range constraint - value must be within min/max bounds. If step is provided, value must be at min + (n * step) for some integer n. + If special_values is provided, those values bypass min/max validation. """ min: float | int max: float | int step: float | None = None + special_values: list[float | int] | None = None def __call__(self, value: float | int) -> float | int: """Validate value is within range and matches step increment.""" @@ -46,10 +48,19 @@ def __call__(self, value: float | int) -> float | int: msg = f"Must be numeric, got {type(value).__name__}" raise TypeError(msg) + # Check if value is a special value that bypasses range check + if self.special_values is not None and value in self.special_values: + return value + + # Validate range if not self.min <= value <= self.max: - msg = f"Must be between {self.min} and {self.max}, got {value}" + special_msg = ( + f" or one of {self.special_values}" if self.special_values else "" + ) + msg = f"Must be between {self.min} and {self.max}{special_msg}, got {value}" raise ValueError(msg) + # Validate step if provided if self.step is not None: remainder = (value - self.min) % self.step # Use epsilon for floating-point comparison tolerance diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py index 47c62e35..ff858616 100644 --- a/src/celeste/parameters.py +++ b/src/celeste/parameters.py @@ -31,7 +31,7 @@ def map(self, request: dict[str, Any], value: Any, model: Model) -> dict[str, An """ ... - def parse_output(self, content: object, value: object | None) -> object: + def parse_output(self, content: Any, value: object | None) -> object: # noqa: ANN401 """Optionally transform parsed content based on parameter value (default: return unchanged).""" return content diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py index 40ad13d2..16233cca 100644 --- a/src/celeste/streaming.py +++ b/src/celeste/streaming.py @@ -3,23 +3,26 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator from types import TracebackType -from typing import Any, Self +from typing import Any, Self, Unpack from celeste.io import Chunk, Output +from celeste.parameters import Parameters -class Stream[Out: Output](ABC): +class Stream[Out: Output, Params: Parameters](ABC): """Async iterator wrapper providing final Output access after stream exhaustion.""" def __init__( self, sse_iterator: AsyncIterator[dict[str, Any]], + **parameters: Unpack[Params], # type: ignore[misc] ) -> None: """Initialize stream with SSE iterator.""" self._sse_iterator = sse_iterator self._chunks: list[Chunk] = [] self._closed = False self._output: Out | None = None + self._parameters = parameters @abstractmethod def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: @@ -27,7 +30,7 @@ def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: ... @abstractmethod - def _parse_output(self, chunks: list[Chunk]) -> Out: + def _parse_output(self, chunks: list[Chunk], **parameters: Unpack[Params]) -> Out: # type: ignore[misc] """Parse final Output from accumulated chunks.""" ... @@ -67,7 +70,7 @@ async def __anext__(self) -> Chunk: msg = "Stream completed but no chunks were produced" raise RuntimeError(msg) - self._output = self._parse_output(self._chunks) + self._output = self._parse_output(self._chunks, **self._parameters) except Exception: await self.aclose() raise diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 2747deb1..58dbe952 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -52,9 +52,9 @@ def _init_request(self, inputs: Input) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> Usage: return Usage() - def _parse_content( + def _parse_content( # type: ignore[override] self, response_data: dict[str, Any], **parameters: Unpack[Parameters] - ) -> Any: # noqa: ANN401 + ) -> object: return response_data.get("content", "test content") async def generate(self, **parameters: Unpack[Parameters]) -> Output: @@ -164,9 +164,9 @@ def _init_request(self, inputs: Input) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> Usage: return Usage() - def _parse_content( + def _parse_content( # type: ignore[override] self, response_data: dict[str, Any], **parameters: Unpack[Parameters] - ) -> Any: # noqa: ANN401 + ) -> object: return response_data.get("content", "test content") def _create_inputs( @@ -187,7 +187,7 @@ def _output_class(cls) -> type[Output]: """Return the Output class for this client.""" return Output - async def _make_request( + async def _make_request( # type: ignore[override] self, request_body: dict[str, Any], **parameters: Unpack[Parameters] ) -> httpx.Response: """Make HTTP request(s) and return response object.""" @@ -197,11 +197,11 @@ async def _make_request( request=httpx.Request("POST", "https://test.com"), ) - def _stream_class(self) -> type[Stream[Output]]: + def _stream_class(self) -> type[Stream[Output, Parameters]]: """Return the Stream class for this client.""" raise NotImplementedError("Streaming not implemented in test client") - def _make_stream_request( + def _make_stream_request( # type: ignore[override] self, request_body: dict[str, Any], **parameters: Unpack[Parameters] ) -> AsyncIterator[dict[str, Any]]: """Make HTTP streaming request and return async iterator of events.""" @@ -438,7 +438,7 @@ def parameter_mappers(cls) -> list[ParameterMapper]: inputs = _TestInput(prompt="test prompt") # Act - request = client._build_request(inputs, test_param="mapped_value") # type: ignore[call-arg] + request = client._build_request(inputs, test_param="mapped_value") # Assert assert request["prompt"] == "test prompt" @@ -473,7 +473,7 @@ def parameter_mappers(cls) -> list[ParameterMapper]: # Act request = client._build_request( inputs, first_param="first", second_param="second" - ) # type: ignore[call-arg] + ) # Assert assert request["first_param"] == "first" diff --git a/tests/unit_tests/test_init.py b/tests/unit_tests/test_init.py index c079079d..586480f2 100644 --- a/tests/unit_tests/test_init.py +++ b/tests/unit_tests/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from pydantic import SecretStr from celeste import Capability, Model, Provider, create_client @@ -65,9 +66,15 @@ def test_create_client_uses_explicit_model_when_both_provided( self, sample_models: list[Model] ) -> None: """Test that create_client uses get_model for explicit selection.""" - with patch("celeste.get_model", autospec=True) as mock_get_model: + with ( + patch("celeste.get_model", autospec=True) as mock_get_model, + patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + ): # Arrange mock_get_model.return_value = sample_models[1] # claude-3 + mock_get_client_class.side_effect = NotImplementedError( + "Client not registered" + ) # Act & Assert with pytest.raises( @@ -77,6 +84,7 @@ def test_create_client_uses_explicit_model_when_both_provided( capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, model="claude-3", + api_key=SecretStr("dummy"), ) mock_get_model.assert_called_once_with("claude-3", Provider.ANTHROPIC) @@ -96,9 +104,15 @@ def test_create_client_filters_by_provider_when_specified( self, sample_models: list[Model] ) -> None: """Test that provider filtering is applied when provider is specified.""" - with patch("celeste.list_models", autospec=True) as mock_list_models: + with ( + patch("celeste.list_models", autospec=True) as mock_list_models, + patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + ): # Arrange mock_list_models.return_value = [sample_models[1]] # claude-3 + mock_get_client_class.side_effect = NotImplementedError( + "Client not registered" + ) # Act - Should fail at _get_client_class but provider filtering should work with pytest.raises( @@ -107,6 +121,7 @@ def test_create_client_filters_by_provider_when_specified( create_client( capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, + api_key=SecretStr("dummy"), ) # Assert - verify provider was passed to list_models @@ -123,6 +138,7 @@ def test_model_selection_precedence(self, sample_models: list[Model]) -> None: with ( patch("celeste.list_models", autospec=True) as mock_list_models, patch("celeste.get_model", autospec=True) as mock_get_model, + patch("celeste.get_client_class", autospec=True) as mock_get_client_class, ): # Arrange explicit_model = sample_models[1] # claude-3 @@ -130,6 +146,9 @@ def test_model_selection_precedence(self, sample_models: list[Model]) -> None: mock_get_model.return_value = explicit_model mock_list_models.return_value = [auto_model] + mock_get_client_class.side_effect = NotImplementedError( + "Client not registered" + ) # Act - Should fail at _get_client_class but precedence should work with pytest.raises(NotImplementedError): @@ -137,6 +156,7 @@ def test_model_selection_precedence(self, sample_models: list[Model]) -> None: capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, model="claude-3", + api_key=SecretStr("dummy"), ) # Assert - Should use explicit path, not auto-selection diff --git a/tests/unit_tests/test_streaming.py b/tests/unit_tests/test_streaming.py index 54ba5d1e..bd60c716 100644 --- a/tests/unit_tests/test_streaming.py +++ b/tests/unit_tests/test_streaming.py @@ -1,13 +1,14 @@ """High-value tests for Stream - focusing on lifecycle, resource cleanup, and state management.""" from collections.abc import AsyncIterator -from typing import Any +from typing import Any, Unpack from unittest.mock import AsyncMock import pytest from pydantic import Field from celeste.io import Chunk, FinishReason, Output, Usage +from celeste.parameters import Parameters from celeste.streaming import Stream @@ -17,7 +18,7 @@ class ConcreteOutput(Output[str]): pass -class ConcreteStream(Stream[ConcreteOutput]): +class ConcreteStream(Stream[ConcreteOutput, Parameters]): """Concrete Stream implementation for testing abstract behavior.""" def __init__( @@ -25,6 +26,7 @@ def __init__( sse_iterator: AsyncIterator[dict[str, Any]], chunk_events: list[dict[str, Any]] | None = None, filter_none: bool = False, + **parameters: Unpack[Parameters], ) -> None: """Initialize stream with configurable parsing behavior. @@ -33,7 +35,7 @@ def __init__( chunk_events: Events that should be converted to chunks. filter_none: If True, return None for events not in chunk_events. """ - super().__init__(sse_iterator) + super().__init__(sse_iterator, **parameters) self._chunk_events = chunk_events or [] self._filter_none = filter_none @@ -53,7 +55,9 @@ def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: metadata=event.get("metadata", {}), ) - def _parse_output(self, chunks: list[Chunk]) -> ConcreteOutput: + def _parse_output( # type: ignore[override] + self, chunks: list[Chunk], **parameters: Unpack[Parameters] + ) -> ConcreteOutput: """Combine chunks into final output.""" content = "".join(chunk.content for chunk in chunks) # Usage is empty base class - store data in metadata for tests @@ -364,10 +368,12 @@ async def test_subclass_without_parse_chunk_fails( """Subclass without _parse_chunk implementation must fail instantiation.""" # Arrange - class IncompleteStream(Stream[ConcreteOutput]): + class IncompleteStream(Stream[ConcreteOutput, Parameters]): """Missing _parse_chunk implementation.""" - def _parse_output(self, chunks: list[Chunk]) -> ConcreteOutput: + def _parse_output( # type: ignore[override] + self, chunks: list[Chunk], **parameters: Unpack[Parameters] + ) -> ConcreteOutput: """Implement _parse_output but not _parse_chunk.""" return ConcreteOutput(content="test") @@ -381,7 +387,7 @@ async def test_subclass_without_parse_output_fails( """Subclass without _parse_output implementation must fail instantiation.""" # Arrange - class IncompleteStream(Stream[ConcreteOutput]): + class IncompleteStream(Stream[ConcreteOutput, Parameters]): """Missing _parse_output implementation.""" def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: @@ -538,7 +544,9 @@ async def test_anext_handles_exception_in_parse_output_and_cleans_up(self) -> No class ExceptionStream(ConcreteStream): """Stream that raises exception in _parse_output.""" - def _parse_output(self, chunks: list[Chunk]) -> ConcreteOutput: + def _parse_output( # type: ignore[override] + self, chunks: list[Chunk], **parameters: Unpack[Parameters] + ) -> ConcreteOutput: """Raise exception during output parsing.""" raise RuntimeError("Output parse error") @@ -611,7 +619,7 @@ class TypedOutput(Output[str]): default_factory=lambda: TypedUsage(input_tokens=0, output_tokens=0) ) - class TypedStream(Stream[TypedOutput]): + class TypedStream(Stream[TypedOutput, Parameters]): """Stream using typed classes.""" def _parse_chunk(self, event: dict[str, Any]) -> TypedChunk | None: @@ -639,7 +647,9 @@ def _parse_chunk(self, event: dict[str, Any]) -> TypedChunk | None: usage=usage, ) - def _parse_output(self, chunks: list[Chunk]) -> TypedOutput: + def _parse_output( # type: ignore[override] + self, chunks: list[Chunk], **parameters: Unpack[Parameters] + ) -> TypedOutput: """Combine chunks into typed output.""" content = "".join(chunk.content for chunk in chunks)