diff --git a/pyproject.toml b/pyproject.toml index d24653f3d6..271315e622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,10 +150,6 @@ plugins = ["pydantic.mypy"] module = "litellm.*" ignore_missing_imports = true -[[tool.mypy.overrides]] -module = "tests.*" -disallow_untyped_defs = false - [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/tests/unit/budget/conftest.py b/tests/unit/budget/conftest.py index 2304162e0a..5445a4e9b6 100644 --- a/tests/unit/budget/conftest.py +++ b/tests/unit/budget/conftest.py @@ -27,20 +27,20 @@ # ── Factories ────────────────────────────────────────────────────── -class BudgetAlertConfigFactory(ModelFactory): +class BudgetAlertConfigFactory(ModelFactory[BudgetAlertConfig]): __model__ = BudgetAlertConfig warn_at = 75 critical_at = 90 hard_stop_at = 100 -class AutoDowngradeConfigFactory(ModelFactory): +class AutoDowngradeConfigFactory(ModelFactory[AutoDowngradeConfig]): __model__ = AutoDowngradeConfig enabled = False downgrade_map = () -class BudgetConfigFactory(ModelFactory): +class BudgetConfigFactory(ModelFactory[BudgetConfig]): __model__ = BudgetConfig total_monthly = 100.0 per_task_limit = 5.0 @@ -49,44 +49,44 @@ class BudgetConfigFactory(ModelFactory): auto_downgrade = AutoDowngradeConfigFactory -class TeamBudgetFactory(ModelFactory): +class TeamBudgetFactory(ModelFactory[TeamBudget]): __model__ = TeamBudget budget_percent = 10.0 -class DepartmentBudgetFactory(ModelFactory): +class DepartmentBudgetFactory(ModelFactory[DepartmentBudget]): __model__ = DepartmentBudget budget_percent = 25.0 teams = () -class BudgetHierarchyFactory(ModelFactory): +class BudgetHierarchyFactory(ModelFactory[BudgetHierarchy]): __model__ = BudgetHierarchy departments = () -class CostRecordFactory(ModelFactory): +class CostRecordFactory(ModelFactory[CostRecord]): __model__ = CostRecord input_tokens = 1000 output_tokens = 500 cost_usd = 0.05 -class PeriodSpendingFactory(ModelFactory): +class PeriodSpendingFactory(ModelFactory[PeriodSpending]): __model__ = PeriodSpending start = datetime(2026, 2, 1, tzinfo=UTC) end = datetime(2026, 3, 1, tzinfo=UTC) -class AgentSpendingFactory(ModelFactory): +class AgentSpendingFactory(ModelFactory[AgentSpending]): __model__ = AgentSpending -class DepartmentSpendingFactory(ModelFactory): +class DepartmentSpendingFactory(ModelFactory[DepartmentSpending]): __model__ = DepartmentSpending -class SpendingSummaryFactory(ModelFactory): +class SpendingSummaryFactory(ModelFactory[SpendingSummary]): __model__ = SpendingSummary period = PeriodSpendingFactory by_agent = () diff --git a/tests/unit/budget/test_config.py b/tests/unit/budget/test_config.py index 5d4d6faf62..66becdfcdd 100644 --- a/tests/unit/budget/test_config.py +++ b/tests/unit/budget/test_config.py @@ -51,7 +51,7 @@ def test_hard_stop_at_boundary_100(self) -> None: def test_float_threshold_rejected(self) -> None: """Reject float value for threshold (strict int).""" with pytest.raises(ValidationError): - BudgetAlertConfig(warn_at=75.5, critical_at=90, hard_stop_at=100) + BudgetAlertConfig(warn_at=75.5, critical_at=90, hard_stop_at=100) # type: ignore[arg-type] def test_negative_threshold_rejected(self) -> None: """Reject negative threshold values.""" @@ -153,7 +153,7 @@ def test_duplicate_source_alias_rejected(self) -> None: def test_float_threshold_rejected(self) -> None: """Reject float value for threshold (strict int).""" with pytest.raises(ValidationError): - AutoDowngradeConfig(threshold=85.5) + AutoDowngradeConfig(threshold=85.5) # type: ignore[arg-type] def test_threshold_boundary_0(self) -> None: """Accept threshold at lower boundary (0).""" diff --git a/tests/unit/budget/test_enums.py b/tests/unit/budget/test_enums.py index 6005d88bc2..3c5aba6b89 100644 --- a/tests/unit/budget/test_enums.py +++ b/tests/unit/budget/test_enums.py @@ -22,10 +22,10 @@ def test_all_members_exist(self) -> None: def test_values_are_strings(self) -> None: """Verify StrEnum produces string values.""" - assert BudgetAlertLevel.NORMAL == "normal" - assert BudgetAlertLevel.WARNING == "warning" - assert BudgetAlertLevel.CRITICAL == "critical" - assert BudgetAlertLevel.HARD_STOP == "hard_stop" + assert BudgetAlertLevel.NORMAL.value == "normal" + assert BudgetAlertLevel.WARNING.value == "warning" + assert BudgetAlertLevel.CRITICAL.value == "critical" + assert BudgetAlertLevel.HARD_STOP.value == "hard_stop" def test_membership(self) -> None: """Verify string-based membership check works.""" diff --git a/tests/unit/communication/conftest.py b/tests/unit/communication/conftest.py index 2c2734d62e..d7ee8c9d8f 100644 --- a/tests/unit/communication/conftest.py +++ b/tests/unit/communication/conftest.py @@ -17,6 +17,7 @@ RateLimitConfig, ) from ai_company.communication.enums import ( + AttachmentType, ChannelType, MessagePriority, MessageType, @@ -26,11 +27,11 @@ # ── Factories ────────────────────────────────────────────────────── -class AttachmentFactory(ModelFactory): +class AttachmentFactory(ModelFactory[Attachment]): __model__ = Attachment -class MessageMetadataFactory(ModelFactory): +class MessageMetadataFactory(ModelFactory[MessageMetadata]): __model__ = MessageMetadata task_id = None project_id = None @@ -39,52 +40,52 @@ class MessageMetadataFactory(ModelFactory): extra = () -class MessageFactory(ModelFactory): +class MessageFactory(ModelFactory[Message]): __model__ = Message priority = MessagePriority.NORMAL attachments = () metadata = MessageMetadataFactory -class ChannelFactory(ModelFactory): +class ChannelFactory(ModelFactory[Channel]): __model__ = Channel type = ChannelType.TOPIC subscribers = () -class MessageBusConfigFactory(ModelFactory): +class MessageBusConfigFactory(ModelFactory[MessageBusConfig]): __model__ = MessageBusConfig -class MeetingTypeConfigFactory(ModelFactory): +class MeetingTypeConfigFactory(ModelFactory[MeetingTypeConfig]): __model__ = MeetingTypeConfig frequency = "daily" trigger = None -class MeetingsConfigFactory(ModelFactory): +class MeetingsConfigFactory(ModelFactory[MeetingsConfig]): __model__ = MeetingsConfig types = () -class HierarchyConfigFactory(ModelFactory): +class HierarchyConfigFactory(ModelFactory[HierarchyConfig]): __model__ = HierarchyConfig -class RateLimitConfigFactory(ModelFactory): +class RateLimitConfigFactory(ModelFactory[RateLimitConfig]): __model__ = RateLimitConfig -class CircuitBreakerConfigFactory(ModelFactory): +class CircuitBreakerConfigFactory(ModelFactory[CircuitBreakerConfig]): __model__ = CircuitBreakerConfig -class LoopPreventionConfigFactory(ModelFactory): +class LoopPreventionConfigFactory(ModelFactory[LoopPreventionConfig]): __model__ = LoopPreventionConfig ancestry_tracking = True -class CommunicationConfigFactory(ModelFactory): +class CommunicationConfigFactory(ModelFactory[CommunicationConfig]): __model__ = CommunicationConfig meetings = MeetingsConfigFactory loop_prevention = LoopPreventionConfigFactory @@ -95,7 +96,7 @@ class CommunicationConfigFactory(ModelFactory): @pytest.fixture def sample_attachment() -> Attachment: - return Attachment(type="artifact", ref="pr-42") + return Attachment(type=AttachmentType.ARTIFACT, ref="pr-42") @pytest.fixture @@ -118,7 +119,7 @@ def sample_message(sample_metadata: MessageMetadata) -> Message: priority=MessagePriority.NORMAL, channel="#backend", content="Completed API endpoint for user authentication.", - attachments=(Attachment(type="artifact", ref="pr-42"),), + attachments=(Attachment(type=AttachmentType.ARTIFACT, ref="pr-42"),), metadata=sample_metadata, ) diff --git a/tests/unit/communication/test_config.py b/tests/unit/communication/test_config.py index 06d19b188b..b3bc243d1d 100644 --- a/tests/unit/communication/test_config.py +++ b/tests/unit/communication/test_config.py @@ -435,7 +435,7 @@ def test_ancestry_tracking_false_rejected(self) -> None: ValidationError, match="Input should be True", ): - LoopPreventionConfig(ancestry_tracking=False) + LoopPreventionConfig(ancestry_tracking=False) # type: ignore[arg-type] def test_zero_delegation_depth_rejected(self) -> None: with pytest.raises(ValidationError): diff --git a/tests/unit/communication/test_enums.py b/tests/unit/communication/test_enums.py index e68778b925..5709603838 100644 --- a/tests/unit/communication/test_enums.py +++ b/tests/unit/communication/test_enums.py @@ -20,14 +20,14 @@ def test_member_count(self) -> None: assert len(MessageType) == 8 def test_values(self) -> None: - assert MessageType.TASK_UPDATE == "task_update" - assert MessageType.QUESTION == "question" - assert MessageType.ANNOUNCEMENT == "announcement" - assert MessageType.REVIEW_REQUEST == "review_request" - assert MessageType.APPROVAL == "approval" - assert MessageType.DELEGATION == "delegation" - assert MessageType.STATUS_REPORT == "status_report" - assert MessageType.ESCALATION == "escalation" + assert MessageType.TASK_UPDATE.value == "task_update" + assert MessageType.QUESTION.value == "question" + assert MessageType.ANNOUNCEMENT.value == "announcement" + assert MessageType.REVIEW_REQUEST.value == "review_request" + assert MessageType.APPROVAL.value == "approval" + assert MessageType.DELEGATION.value == "delegation" + assert MessageType.STATUS_REPORT.value == "status_report" + assert MessageType.ESCALATION.value == "escalation" def test_string_identity(self) -> None: assert str(MessageType.TASK_UPDATE) == "task_update" @@ -39,10 +39,10 @@ def test_member_count(self) -> None: assert len(MessagePriority) == 4 def test_values(self) -> None: - assert MessagePriority.LOW == "low" - assert MessagePriority.NORMAL == "normal" - assert MessagePriority.HIGH == "high" - assert MessagePriority.URGENT == "urgent" + assert MessagePriority.LOW.value == "low" + assert MessagePriority.NORMAL.value == "normal" + assert MessagePriority.HIGH.value == "high" + assert MessagePriority.URGENT.value == "urgent" def test_normal_not_medium(self) -> None: """Message priority uses 'normal', not 'medium' like task Priority.""" @@ -57,9 +57,9 @@ def test_member_count(self) -> None: assert len(ChannelType) == 3 def test_values(self) -> None: - assert ChannelType.TOPIC == "topic" - assert ChannelType.DIRECT == "direct" - assert ChannelType.BROADCAST == "broadcast" + assert ChannelType.TOPIC.value == "topic" + assert ChannelType.DIRECT.value == "direct" + assert ChannelType.BROADCAST.value == "broadcast" @pytest.mark.unit @@ -68,9 +68,9 @@ def test_member_count(self) -> None: assert len(AttachmentType) == 3 def test_values(self) -> None: - assert AttachmentType.ARTIFACT == "artifact" - assert AttachmentType.FILE == "file" - assert AttachmentType.LINK == "link" + assert AttachmentType.ARTIFACT.value == "artifact" + assert AttachmentType.FILE.value == "file" + assert AttachmentType.LINK.value == "link" @pytest.mark.unit @@ -79,10 +79,10 @@ def test_member_count(self) -> None: assert len(CommunicationPattern) == 4 def test_values(self) -> None: - assert CommunicationPattern.EVENT_DRIVEN == "event_driven" - assert CommunicationPattern.HIERARCHICAL == "hierarchical" - assert CommunicationPattern.MEETING_BASED == "meeting_based" - assert CommunicationPattern.HYBRID == "hybrid" + assert CommunicationPattern.EVENT_DRIVEN.value == "event_driven" + assert CommunicationPattern.HIERARCHICAL.value == "hierarchical" + assert CommunicationPattern.MEETING_BASED.value == "meeting_based" + assert CommunicationPattern.HYBRID.value == "hybrid" @pytest.mark.unit @@ -100,7 +100,7 @@ def test_member_count(self) -> None: assert len(MessageBusBackend) == 4 def test_values(self) -> None: - assert MessageBusBackend.INTERNAL == "internal" - assert MessageBusBackend.REDIS == "redis" - assert MessageBusBackend.RABBITMQ == "rabbitmq" - assert MessageBusBackend.KAFKA == "kafka" + assert MessageBusBackend.INTERNAL.value == "internal" + assert MessageBusBackend.REDIS.value == "redis" + assert MessageBusBackend.RABBITMQ.value == "rabbitmq" + assert MessageBusBackend.KAFKA.value == "kafka" diff --git a/tests/unit/config/conftest.py b/tests/unit/config/conftest.py index 5fef9952a5..95a01db440 100644 --- a/tests/unit/config/conftest.py +++ b/tests/unit/config/conftest.py @@ -1,12 +1,8 @@ """Unit test configuration and fixtures for config models.""" -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol import pytest - -if TYPE_CHECKING: - from collections.abc import Callable - from pathlib import Path from polyfactory.factories.pydantic_factory import ModelFactory from ai_company.budget.config import BudgetConfig @@ -21,38 +17,48 @@ ) from ai_company.core.company import CompanyConfig +if TYPE_CHECKING: + from pathlib import Path + + +class ConfigFileFactory(Protocol): + """Callable signature for the tmp_config_file fixture.""" + + def __call__(self, content: str, name: str = ...) -> Path: ... + + # ── Factories ────────────────────────────────────────────────────── -class ProviderModelConfigFactory(ModelFactory): +class ProviderModelConfigFactory(ModelFactory[ProviderModelConfig]): __model__ = ProviderModelConfig -class ProviderConfigFactory(ModelFactory): +class ProviderConfigFactory(ModelFactory[ProviderConfig]): __model__ = ProviderConfig models = () -class RoutingRuleConfigFactory(ModelFactory): +class RoutingRuleConfigFactory(ModelFactory[RoutingRuleConfig]): __model__ = RoutingRuleConfig -class RoutingConfigFactory(ModelFactory): +class RoutingConfigFactory(ModelFactory[RoutingConfig]): __model__ = RoutingConfig rules = () fallback_chain = () -class AgentConfigFactory(ModelFactory): +class AgentConfigFactory(ModelFactory[AgentConfig]): __model__ = AgentConfig -class RootConfigFactory(ModelFactory): +class RootConfigFactory(ModelFactory[RootConfig]): __model__ = RootConfig departments = () agents = () custom_roles = () - providers: dict[str, ProviderConfig] = {} # type: ignore[assignment] # noqa: RUF012 + providers: dict[str, ProviderConfig] = {} # noqa: RUF012 config = CompanyConfig() budget = BudgetConfig() communication = CommunicationConfig() @@ -139,7 +145,7 @@ def sample_root_config() -> RootConfig: @pytest.fixture -def tmp_config_file(tmp_path: Path) -> Callable[[str, str], Path]: +def tmp_config_file(tmp_path: Path) -> ConfigFileFactory: def _create(content: str, name: str = "config.yaml") -> Path: path = tmp_path / name path.write_text(content, encoding="utf-8") diff --git a/tests/unit/config/test_defaults.py b/tests/unit/config/test_defaults.py index bd390c93b0..6c8712c42b 100644 --- a/tests/unit/config/test_defaults.py +++ b/tests/unit/config/test_defaults.py @@ -8,30 +8,30 @@ @pytest.mark.unit class TestDefaultConfigDict: - def test_returns_dict(self): + def test_returns_dict(self) -> None: result = default_config_dict() assert isinstance(result, dict) - def test_required_keys_present(self): + def test_required_keys_present(self) -> None: result = default_config_dict() assert "company_name" in result assert "company_type" in result assert result["company_name"] == "AI Company" assert result["company_type"] == "custom" - def test_constructs_valid_root_config(self): + def test_constructs_valid_root_config(self) -> None: data = default_config_dict() cfg = RootConfig(**data) assert cfg.company_name == "AI Company" assert cfg.company_type.value == "custom" - def test_returns_fresh_dict_each_call(self): + def test_returns_fresh_dict_each_call(self) -> None: a = default_config_dict() b = default_config_dict() assert a == b assert a is not b - def test_keys_match_root_config_fields(self): + def test_keys_match_root_config_fields(self) -> None: defaults = default_config_dict() root_fields = set(RootConfig.model_fields.keys()) default_keys = set(defaults.keys()) diff --git a/tests/unit/config/test_errors.py b/tests/unit/config/test_errors.py index 505c61887f..c0ef9b9650 100644 --- a/tests/unit/config/test_errors.py +++ b/tests/unit/config/test_errors.py @@ -13,7 +13,7 @@ @pytest.mark.unit class TestConfigLocation: - def test_creation(self): + def test_creation(self) -> None: loc = ConfigLocation( file_path="config.yaml", key_path="budget.alerts", @@ -25,14 +25,14 @@ def test_creation(self): assert loc.line == 12 assert loc.column == 3 - def test_defaults(self): + def test_defaults(self) -> None: loc = ConfigLocation() assert loc.file_path is None assert loc.key_path is None assert loc.line is None assert loc.column is None - def test_frozen(self): + def test_frozen(self) -> None: loc = ConfigLocation(file_path="config.yaml") with pytest.raises(AttributeError): loc.file_path = "other.yaml" # type: ignore[misc] @@ -40,11 +40,11 @@ def test_frozen(self): @pytest.mark.unit class TestConfigError: - def test_str_without_locations(self): + def test_str_without_locations(self) -> None: err = ConfigError("Something failed") assert str(err) == "Something failed" - def test_str_with_locations(self): + def test_str_with_locations(self) -> None: err = ConfigError( "Something failed", locations=( @@ -62,7 +62,7 @@ def test_str_with_locations(self): assert "config.yaml" in result assert "line 5, column 3" in result - def test_str_with_file_only_location(self): + def test_str_with_file_only_location(self) -> None: err = ConfigError( "Parse failed", locations=(ConfigLocation(file_path="config.yaml", line=3),), @@ -72,7 +72,7 @@ def test_str_with_file_only_location(self): assert "line 3" in result assert "column" not in result - def test_str_with_file_path_no_key_path(self): + def test_str_with_file_path_no_key_path(self) -> None: err = ConfigError( "Parse failed", locations=(ConfigLocation(file_path="config.yaml"),), @@ -80,10 +80,10 @@ def test_str_with_file_path_no_key_path(self): result = str(err) assert "config.yaml" in result - def test_inherits_exception(self): + def test_inherits_exception(self) -> None: assert isinstance(ConfigError("test"), Exception) - def test_message_attribute(self): + def test_message_attribute(self) -> None: err = ConfigError("hello") assert err.message == "hello" assert err.locations == () @@ -91,33 +91,33 @@ def test_message_attribute(self): @pytest.mark.unit class TestConfigFileNotFoundError: - def test_inherits_config_error(self): + def test_inherits_config_error(self) -> None: assert isinstance(ConfigFileNotFoundError("not found"), ConfigError) - def test_message(self): + def test_message(self) -> None: err = ConfigFileNotFoundError("File missing: config.yaml") assert str(err) == "File missing: config.yaml" @pytest.mark.unit class TestConfigParseError: - def test_inherits_config_error(self): + def test_inherits_config_error(self) -> None: assert isinstance(ConfigParseError("bad yaml"), ConfigError) - def test_message(self): + def test_message(self) -> None: err = ConfigParseError("YAML syntax error") assert "YAML syntax error" in str(err) @pytest.mark.unit class TestConfigValidationError: - def test_inherits_config_error(self): + def test_inherits_config_error(self) -> None: assert isinstance( ConfigValidationError("validation failed"), ConfigError, ) - def test_per_field_errors_formatting(self): + def test_per_field_errors_formatting(self) -> None: err = ConfigValidationError( "Configuration validation failed", locations=( @@ -146,7 +146,7 @@ def test_per_field_errors_formatting(self): assert "line 12, column 5" in result assert "line 25" in result - def test_field_error_without_matching_location(self): + def test_field_error_without_matching_location(self) -> None: err = ConfigValidationError( "validation failed", locations=(), @@ -156,11 +156,11 @@ def test_field_error_without_matching_location(self): assert "budget.total_monthly: must be positive" in result assert "in " not in result - def test_no_field_errors_falls_back(self): + def test_no_field_errors_falls_back(self) -> None: err = ConfigValidationError("validation failed") assert str(err) == "validation failed" - def test_field_errors_attribute(self): + def test_field_errors_attribute(self) -> None: err = ConfigValidationError( "bad", field_errors=(("x", "wrong"),), diff --git a/tests/unit/config/test_loader.py b/tests/unit/config/test_loader.py index a7c4001c54..948345d786 100644 --- a/tests/unit/config/test_loader.py +++ b/tests/unit/config/test_loader.py @@ -1,9 +1,13 @@ """Tests for config loader (parsing, merging, validation).""" from pathlib import Path +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from .conftest import ConfigFileFactory + from ai_company.config.errors import ( ConfigFileNotFoundError, ConfigParseError, @@ -38,20 +42,22 @@ @pytest.mark.unit class TestReadConfigText: - def test_reads_file(self, tmp_path): + def test_reads_file(self, tmp_path: Path) -> None: f = tmp_path / "config.yaml" f.write_text("company_name: Test\n", encoding="utf-8") assert _read_config_text(f) == "company_name: Test\n" - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(ConfigFileNotFoundError, match="not found"): _read_config_text(tmp_path / "missing.yaml") - def test_directory_rejected(self, tmp_path): + def test_directory_rejected(self, tmp_path: Path) -> None: with pytest.raises(ConfigFileNotFoundError, match="not found"): _read_config_text(tmp_path) - def test_os_error_wrapped(self, tmp_path, monkeypatch): + def test_os_error_wrapped( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: f = tmp_path / "config.yaml" f.write_text("content", encoding="utf-8") monkeypatch.setattr( @@ -67,35 +73,35 @@ def test_os_error_wrapped(self, tmp_path, monkeypatch): @pytest.mark.unit class TestParseYamlFile: - def test_valid_file(self, tmp_path): + def test_valid_file(self, tmp_path: Path) -> None: f = tmp_path / "config.yaml" f.write_text("company_name: Test\n", encoding="utf-8") result = _parse_yaml_file(f) assert result == {"company_name": "Test"} - def test_syntax_error(self, tmp_path): + def test_syntax_error(self, tmp_path: Path) -> None: f = tmp_path / "bad.yaml" f.write_text(INVALID_SYNTAX_YAML, encoding="utf-8") with pytest.raises(ConfigParseError, match="YAML syntax error"): _parse_yaml_file(f) - def test_non_mapping_top_level(self, tmp_path): + def test_non_mapping_top_level(self, tmp_path: Path) -> None: f = tmp_path / "list.yaml" f.write_text("- item1\n- item2\n", encoding="utf-8") with pytest.raises(ConfigParseError, match="mapping"): _parse_yaml_file(f) - def test_empty_file(self, tmp_path): + def test_empty_file(self, tmp_path: Path) -> None: f = tmp_path / "empty.yaml" f.write_text("", encoding="utf-8") assert _parse_yaml_file(f) == {} - def test_null_file(self, tmp_path): + def test_null_file(self, tmp_path: Path) -> None: f = tmp_path / "null.yaml" f.write_text("null\n", encoding="utf-8") assert _parse_yaml_file(f) == {} - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: f = tmp_path / "missing.yaml" with pytest.raises(ConfigFileNotFoundError, match="not found"): _parse_yaml_file(f) @@ -106,18 +112,18 @@ def test_file_not_found(self, tmp_path): @pytest.mark.unit class TestParseYamlString: - def test_valid_string(self): + def test_valid_string(self) -> None: result = _parse_yaml_string("key: value\n", "") assert result == {"key": "value"} - def test_syntax_error(self): + def test_syntax_error(self) -> None: with pytest.raises(ConfigParseError, match="syntax error"): _parse_yaml_string(INVALID_SYNTAX_YAML, "") - def test_empty_string(self): + def test_empty_string(self) -> None: assert _parse_yaml_string("", "") == {} - def test_non_mapping(self): + def test_non_mapping(self) -> None: with pytest.raises(ConfigParseError, match="mapping"): _parse_yaml_string("- a\n- b\n", "") @@ -127,7 +133,7 @@ def test_non_mapping(self): @pytest.mark.unit class TestBuildLineMap: - def test_simple_mapping(self): + def test_simple_mapping(self) -> None: yaml_text = "company_name: Test\nbudget:\n total_monthly: 100\n" result = _build_line_map(yaml_text) assert "company_name" in result @@ -136,26 +142,26 @@ def test_simple_mapping(self): assert result["company_name"][0] == 1 assert result["budget.total_monthly"][0] == 3 - def test_sequence_elements(self): + def test_sequence_elements(self) -> None: yaml_text = "agents:\n - name: Alice\n - name: Bob\n" result = _build_line_map(yaml_text) assert "agents.0" in result assert "agents.1" in result assert "agents.0.name" in result - def test_invalid_yaml_returns_empty(self): + def test_invalid_yaml_returns_empty(self) -> None: result = _build_line_map("invalid: [unterminated\n") assert result == {} - def test_non_mapping_root_returns_empty(self): + def test_non_mapping_root_returns_empty(self) -> None: result = _build_line_map("- item1\n- item2\n") assert result == {} - def test_empty_string_returns_empty(self): + def test_empty_string_returns_empty(self) -> None: result = _build_line_map("") assert result == {} - def test_null_yaml_returns_empty(self): + def test_null_yaml_returns_empty(self) -> None: result = _build_line_map("null\n") assert result == {} @@ -165,18 +171,18 @@ def test_null_yaml_returns_empty(self): @pytest.mark.unit class TestValidateConfigDict: - def test_valid_dict(self): + def test_valid_dict(self) -> None: data = {"company_name": "Test Corp"} result = _validate_config_dict(data) assert isinstance(result, RootConfig) assert result.company_name == "Test Corp" - def test_invalid_dict_raises(self): + def test_invalid_dict_raises(self) -> None: with pytest.raises(ConfigValidationError) as exc_info: _validate_config_dict({"company_name": ""}) assert exc_info.value.field_errors - def test_line_map_enriches_errors(self): + def test_line_map_enriches_errors(self) -> None: line_map = {"company_name": (5, 16)} with pytest.raises(ConfigValidationError) as exc_info: _validate_config_dict( @@ -194,7 +200,7 @@ def test_line_map_enriches_errors(self): assert loc.line == 5 assert loc.column == 16 - def test_none_line_map_gracefully_degrades(self): + def test_none_line_map_gracefully_degrades(self) -> None: with pytest.raises(ConfigValidationError) as exc_info: _validate_config_dict( {"company_name": ""}, @@ -213,13 +219,13 @@ def test_none_line_map_gracefully_degrades(self): @pytest.mark.unit class TestLoadConfig: - def test_explicit_path(self, tmp_config_file): + def test_explicit_path(self, tmp_config_file: ConfigFileFactory) -> None: path = tmp_config_file(MINIMAL_VALID_YAML) cfg = load_config(path) assert isinstance(cfg, RootConfig) assert cfg.company_name == "Test Corp" - def test_full_config(self, tmp_config_file): + def test_full_config(self, tmp_config_file: ConfigFileFactory) -> None: path = tmp_config_file(FULL_VALID_YAML) cfg = load_config(path) assert cfg.company_name == "Test Corp" @@ -227,7 +233,7 @@ def test_full_config(self, tmp_config_file): assert cfg.agents[0].name == "Alice" assert "anthropic" in cfg.providers - def test_layered_override(self, tmp_config_file): + def test_layered_override(self, tmp_config_file: ConfigFileFactory) -> None: base_path = tmp_config_file( "company_name: Base Corp\ncompany_type: custom\n", name="base.yaml", @@ -239,20 +245,24 @@ def test_layered_override(self, tmp_config_file): cfg = load_config(base_path, override_paths=(override_path,)) assert cfg.company_name == "Override Corp" - def test_multiple_override_files_applied_in_order(self, tmp_config_file): + def test_multiple_override_files_applied_in_order( + self, tmp_config_file: ConfigFileFactory + ) -> None: base = tmp_config_file("company_name: Base\n", name="base.yaml") over1 = tmp_config_file("company_name: Override1\n", name="over1.yaml") over2 = tmp_config_file("company_name: Override2\n", name="over2.yaml") cfg = load_config(base, override_paths=(over1, over2)) assert cfg.company_name == "Override2" - def test_defaults_applied(self, tmp_config_file): + def test_defaults_applied(self, tmp_config_file: ConfigFileFactory) -> None: path = tmp_config_file(MINIMAL_VALID_YAML) cfg = load_config(path) assert cfg.budget.total_monthly == 100.0 assert cfg.routing.strategy == "cost_aware" - def test_validation_error_with_location(self, tmp_config_file): + def test_validation_error_with_location( + self, tmp_config_file: ConfigFileFactory + ) -> None: path = tmp_config_file(MISSING_REQUIRED_YAML) with pytest.raises(ConfigValidationError) as exc_info: load_config(path) @@ -260,7 +270,7 @@ def test_validation_error_with_location(self, tmp_config_file): assert err.field_errors assert any("company_name" in key for key, _ in err.field_errors) - def test_frozen_result(self, tmp_config_file): + def test_frozen_result(self, tmp_config_file: ConfigFileFactory) -> None: from pydantic import ValidationError path = tmp_config_file(MINIMAL_VALID_YAML) @@ -268,16 +278,16 @@ def test_frozen_result(self, tmp_config_file): with pytest.raises(ValidationError): cfg.company_name = "Nope" # type: ignore[misc] - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(ConfigFileNotFoundError): load_config(tmp_path / "nonexistent.yaml") - def test_syntax_error(self, tmp_config_file): + def test_syntax_error(self, tmp_config_file: ConfigFileFactory) -> None: path = tmp_config_file(INVALID_SYNTAX_YAML) with pytest.raises(ConfigParseError): load_config(path) - def test_nested_override_merge(self, tmp_config_file): + def test_nested_override_merge(self, tmp_config_file: ConfigFileFactory) -> None: base_path = tmp_config_file( "company_name: X\nbudget:\n total_monthly: 200.0\n", name="base.yaml", @@ -290,13 +300,13 @@ def test_nested_override_merge(self, tmp_config_file): assert cfg.budget.total_monthly == 200.0 assert cfg.budget.per_task_limit == 10.0 - def test_string_path_accepted(self, tmp_config_file): + def test_string_path_accepted(self, tmp_config_file: ConfigFileFactory) -> None: """String paths are coerced to Path objects.""" path = tmp_config_file(MINIMAL_VALID_YAML) cfg = load_config(str(path)) assert cfg.company_name == "Test Corp" - def test_directory_path_rejected(self, tmp_path): + def test_directory_path_rejected(self, tmp_path: Path) -> None: with pytest.raises(ConfigFileNotFoundError): load_config(tmp_path) @@ -306,38 +316,38 @@ def test_directory_path_rejected(self, tmp_path): @pytest.mark.unit class TestLoadConfigFromString: - def test_minimal(self): + def test_minimal(self) -> None: cfg = load_config_from_string(MINIMAL_VALID_YAML) assert cfg.company_name == "Test Corp" assert isinstance(cfg, RootConfig) - def test_full(self): + def test_full(self) -> None: cfg = load_config_from_string(FULL_VALID_YAML) assert cfg.company_name == "Test Corp" assert len(cfg.agents) == 1 assert cfg.budget.total_monthly == 500.0 - def test_invalid_yaml(self): + def test_invalid_yaml(self) -> None: with pytest.raises(ConfigParseError): load_config_from_string(INVALID_SYNTAX_YAML) - def test_validation_error(self): + def test_validation_error(self) -> None: with pytest.raises(ConfigValidationError) as exc_info: load_config_from_string(INVALID_FIELD_VALUES_YAML) assert exc_info.value.field_errors - def test_defaults_merged(self): + def test_defaults_merged(self) -> None: cfg = load_config_from_string(MINIMAL_VALID_YAML) assert cfg.budget.total_monthly == 100.0 - def test_custom_source_name(self): + def test_custom_source_name(self) -> None: with pytest.raises(ConfigParseError, match="my-source"): load_config_from_string( INVALID_SYNTAX_YAML, source_name="my-source", ) - def test_empty_string_uses_defaults(self): + def test_empty_string_uses_defaults(self) -> None: cfg = load_config_from_string("") assert cfg.company_name == "AI Company" @@ -347,82 +357,84 @@ def test_empty_string_uses_defaults(self): @pytest.mark.unit class TestSubstituteEnvVars: - def test_simple_substitution(self, monkeypatch): + def test_simple_substitution(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FOO", "bar") data = {"key": "${FOO}"} result = _substitute_env_vars(data) assert result == {"key": "bar"} - def test_missing_var_raises(self): + def test_missing_var_raises(self) -> None: data = {"key": "${MISSING_VAR_XYZ}"} with pytest.raises(ConfigValidationError, match="MISSING_VAR_XYZ"): _substitute_env_vars(data) - def test_default_used_when_missing(self): + def test_default_used_when_missing(self) -> None: data = {"key": "${MISSING_VAR_XYZ:-fallback}"} result = _substitute_env_vars(data) assert result == {"key": "fallback"} - def test_default_ignored_when_present(self, monkeypatch): + def test_default_ignored_when_present( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("SET_VAR", "real") data = {"key": "${SET_VAR:-fallback}"} result = _substitute_env_vars(data) assert result == {"key": "real"} - def test_empty_default(self): + def test_empty_default(self) -> None: data = {"key": "${MISSING_VAR_XYZ:-}"} result = _substitute_env_vars(data) assert result == {"key": ""} - def test_nested_dict(self, monkeypatch): + def test_nested_dict(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("INNER", "resolved") data = {"outer": {"inner": "${INNER}"}} result = _substitute_env_vars(data) assert result == {"outer": {"inner": "resolved"}} - def test_list_values(self, monkeypatch): + def test_list_values(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ITEM", "hello") data = {"items": ["${ITEM}", "static"]} result = _substitute_env_vars(data) assert result == {"items": ["hello", "static"]} - def test_non_string_unchanged(self): + def test_non_string_unchanged(self) -> None: data = {"int": 42, "float": 3.14, "bool": True, "null": None} result = _substitute_env_vars(data) assert result == {"int": 42, "float": 3.14, "bool": True, "null": None} - def test_multiple_vars_in_one_string(self, monkeypatch): + def test_multiple_vars_in_one_string(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("A", "alpha") monkeypatch.setenv("B", "beta") data = {"key": "${A}:${B}"} result = _substitute_env_vars(data) assert result == {"key": "alpha:beta"} - def test_partial_string(self, monkeypatch): + def test_partial_string(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("VAR", "middle") data = {"key": "prefix-${VAR}-suffix"} result = _substitute_env_vars(data) assert result == {"key": "prefix-middle-suffix"} - def test_input_not_mutated(self, monkeypatch): + def test_input_not_mutated(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("X", "replaced") original = {"key": "${X}", "nested": {"deep": "${X}"}} original_copy = {"key": "${X}", "nested": {"deep": "${X}"}} _substitute_env_vars(original) assert original == original_copy - def test_no_placeholders_passthrough(self): + def test_no_placeholders_passthrough(self) -> None: data = {"key": "no vars here", "num": 123} result = _substitute_env_vars(data) assert result == {"key": "no vars here", "num": 123} - def test_deeply_nested(self, monkeypatch): + def test_deeply_nested(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DEEP", "found") data = {"a": {"b": {"c": {"d": {"e": "${DEEP}"}}}}} result = _substitute_env_vars(data) assert result == {"a": {"b": {"c": {"d": {"e": "found"}}}}} - def test_no_recursive_expansion(self, monkeypatch): + def test_no_recursive_expansion(self, monkeypatch: pytest.MonkeyPatch) -> None: """Env var values containing ${...} syntax are NOT recursively expanded.""" monkeypatch.setenv("OUTER", "${INNER}") monkeypatch.setenv("INNER", "should_not_appear") @@ -430,14 +442,14 @@ def test_no_recursive_expansion(self, monkeypatch): result = _substitute_env_vars(data) assert result == {"key": "${INNER}"} - def test_special_chars_in_env_value(self, monkeypatch): + def test_special_chars_in_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None: """Env var values with regex/URL special chars are preserved verbatim.""" monkeypatch.setenv("URL", "https://example.com/path?a=1&b=2#frag") data = {"endpoint": "${URL}"} result = _substitute_env_vars(data) assert result == {"endpoint": "https://example.com/path?a=1&b=2#frag"} - def test_missing_var_error_includes_source_file(self): + def test_missing_var_error_includes_source_file(self) -> None: """Error for missing env var includes the source_file in locations.""" data = {"key": "${MISSING_XYZ}"} with pytest.raises(ConfigValidationError) as exc_info: @@ -450,14 +462,18 @@ def test_missing_var_error_includes_source_file(self): @pytest.mark.unit class TestDiscoverConfig: - def test_finds_cwd_config(self, tmp_path, monkeypatch): + def test_finds_cwd_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: config_file = tmp_path / "ai-company.yaml" config_file.write_text("company_name: Test\n", encoding="utf-8") monkeypatch.chdir(tmp_path) result = discover_config() assert result == config_file.resolve() - def test_finds_config_subdir(self, tmp_path, monkeypatch): + def test_finds_config_subdir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: config_dir = tmp_path / "config" config_dir.mkdir() config_file = config_dir / "ai-company.yaml" @@ -466,7 +482,9 @@ def test_finds_config_subdir(self, tmp_path, monkeypatch): result = discover_config() assert result == config_file.resolve() - def test_finds_home_config(self, tmp_path, monkeypatch): + def test_finds_home_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: # CWD has no config monkeypatch.chdir(tmp_path) # Home dir has config @@ -479,7 +497,9 @@ def test_finds_home_config(self, tmp_path, monkeypatch): result = discover_config() assert result == config_file.resolve() - def test_precedence_cwd_over_subdir(self, tmp_path, monkeypatch): + def test_precedence_cwd_over_subdir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: # Both CWD and config/ have files cwd_file = tmp_path / "ai-company.yaml" cwd_file.write_text("company_name: CWD\n", encoding="utf-8") @@ -491,7 +511,9 @@ def test_precedence_cwd_over_subdir(self, tmp_path, monkeypatch): result = discover_config() assert result == cwd_file.resolve() - def test_precedence_subdir_over_home(self, tmp_path, monkeypatch): + def test_precedence_subdir_over_home( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.chdir(tmp_path) # config/ subdir has file config_dir = tmp_path / "config" @@ -508,7 +530,9 @@ def test_precedence_subdir_over_home(self, tmp_path, monkeypatch): result = discover_config() assert result == subdir_file.resolve() - def test_no_config_raises(self, tmp_path, monkeypatch): + def test_no_config_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.chdir(tmp_path) fake_home = tmp_path / "fakehome" fake_home.mkdir() @@ -520,7 +544,9 @@ def test_no_config_raises(self, tmp_path, monkeypatch): # All 3 search locations should be reported assert len(exc_info.value.locations) == 3 - def test_returns_resolved_path(self, tmp_path, monkeypatch): + def test_returns_resolved_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: config_file = tmp_path / "ai-company.yaml" config_file.write_text("company_name: Test\n", encoding="utf-8") monkeypatch.chdir(tmp_path) @@ -533,24 +559,32 @@ def test_returns_resolved_path(self, tmp_path, monkeypatch): @pytest.mark.unit class TestLoadConfigEnvVar: - def test_env_var_in_load_config(self, tmp_config_file, monkeypatch): + def test_env_var_in_load_config( + self, tmp_config_file: ConfigFileFactory, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("COMPANY_NAME", "Env Corp") path = tmp_config_file(ENV_VAR_SIMPLE_YAML) cfg = load_config(path) assert cfg.company_name == "Env Corp" - def test_env_var_with_default_in_load_config(self, tmp_config_file): + def test_env_var_with_default_in_load_config( + self, tmp_config_file: ConfigFileFactory + ) -> None: yaml_content = "company_name: ${UNDEFINED_TEST_VAR:-Default Corp}\n" path = tmp_config_file(yaml_content) cfg = load_config(path) assert cfg.company_name == "Default Corp" - def test_missing_env_var_raises_in_load_config(self, tmp_config_file): + def test_missing_env_var_raises_in_load_config( + self, tmp_config_file: ConfigFileFactory + ) -> None: path = tmp_config_file(ENV_VAR_MISSING_YAML) with pytest.raises(ConfigValidationError, match="UNDEFINED_VAR"): load_config(path) - def test_env_var_in_nested_config(self, tmp_config_file, monkeypatch): + def test_env_var_in_nested_config( + self, tmp_config_file: ConfigFileFactory, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("COMPANY_NAME", "Nested Corp") monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://custom.api") path = tmp_config_file(ENV_VAR_NESTED_YAML) @@ -558,21 +592,25 @@ def test_env_var_in_nested_config(self, tmp_config_file, monkeypatch): assert cfg.company_name == "Nested Corp" assert cfg.providers["anthropic"].base_url == "https://custom.api" - def test_env_var_in_load_config_from_string(self, monkeypatch): + def test_env_var_in_load_config_from_string( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("COMPANY_NAME", "String Corp") cfg = load_config_from_string(ENV_VAR_SIMPLE_YAML) assert cfg.company_name == "String Corp" - def test_env_var_default_in_load_config_from_string(self): + def test_env_var_default_in_load_config_from_string(self) -> None: yaml_content = "company_name: ${UNDEFINED_TEST_VAR:-FromString Corp}\n" cfg = load_config_from_string(yaml_content) assert cfg.company_name == "FromString Corp" - def test_missing_env_var_raises_in_load_config_from_string(self): + def test_missing_env_var_raises_in_load_config_from_string(self) -> None: with pytest.raises(ConfigValidationError, match="UNDEFINED_VAR"): load_config_from_string(ENV_VAR_MISSING_YAML) - def test_env_var_in_override_file(self, tmp_config_file, monkeypatch): + def test_env_var_in_override_file( + self, tmp_config_file: ConfigFileFactory, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("OVERRIDE_NAME", "Override Corp") base = tmp_config_file(MINIMAL_VALID_YAML, name="base.yaml") override = tmp_config_file( @@ -588,14 +626,18 @@ def test_env_var_in_override_file(self, tmp_config_file, monkeypatch): @pytest.mark.unit class TestLoadConfigDiscovery: - def test_load_config_none_uses_discovery(self, tmp_path, monkeypatch): + def test_load_config_none_uses_discovery( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: config_file = tmp_path / "ai-company.yaml" config_file.write_text(MINIMAL_VALID_YAML, encoding="utf-8") monkeypatch.chdir(tmp_path) cfg = load_config(None) assert cfg.company_name == "Test Corp" - def test_load_config_none_no_config_raises(self, tmp_path, monkeypatch): + def test_load_config_none_no_config_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.chdir(tmp_path) fake_home = tmp_path / "fakehome" fake_home.mkdir() @@ -603,7 +645,9 @@ def test_load_config_none_no_config_raises(self, tmp_path, monkeypatch): with pytest.raises(ConfigFileNotFoundError): load_config(None) - def test_load_config_explicit_path_still_works(self, tmp_config_file): + def test_load_config_explicit_path_still_works( + self, tmp_config_file: ConfigFileFactory + ) -> None: """Backward compatibility: explicit path still works as before.""" path = tmp_config_file(MINIMAL_VALID_YAML) cfg = load_config(path) diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index 013e27a9a9..a2e121e55e 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -27,7 +27,7 @@ @pytest.mark.unit class TestProviderModelConfig: - def test_valid_minimal(self): + def test_valid_minimal(self) -> None: m = ProviderModelConfig(id="test-model:8b") assert m.id == "test-model:8b" assert m.alias is None @@ -35,7 +35,7 @@ def test_valid_minimal(self): assert m.cost_per_1k_output == 0.0 assert m.max_context == 200_000 - def test_valid_full(self): + def test_valid_full(self) -> None: m = ProviderModelConfig( id="test-model:8b", alias="sonnet", @@ -46,28 +46,28 @@ def test_valid_full(self): assert m.alias == "sonnet" assert m.cost_per_1k_input == 0.003 - def test_blank_id_rejected(self): + def test_blank_id_rejected(self) -> None: with pytest.raises(ValidationError): ProviderModelConfig(id="") - def test_whitespace_id_rejected(self): + def test_whitespace_id_rejected(self) -> None: with pytest.raises(ValidationError): ProviderModelConfig(id=" ") - def test_whitespace_alias_rejected(self): + def test_whitespace_alias_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): ProviderModelConfig(id="m1", alias=" ") - def test_negative_cost_rejected(self): + def test_negative_cost_rejected(self) -> None: with pytest.raises(ValidationError): ProviderModelConfig(id="m1", cost_per_1k_input=-1.0) - def test_frozen(self): + def test_frozen(self) -> None: m = ProviderModelConfig(id="m1") with pytest.raises(ValidationError): m.id = "m2" # type: ignore[misc] - def test_factory(self): + def test_factory(self) -> None: m = ProviderModelConfigFactory.build() assert isinstance(m, ProviderModelConfig) assert m.id @@ -78,13 +78,13 @@ def test_factory(self): @pytest.mark.unit class TestProviderConfig: - def test_defaults(self): + def test_defaults(self) -> None: p = ProviderConfig() assert p.api_key is None assert p.base_url is None assert p.models == () - def test_with_models(self): + def test_with_models(self) -> None: p = ProviderConfig( models=( ProviderModelConfig(id="m1", alias="fast"), @@ -93,7 +93,7 @@ def test_with_models(self): ) assert len(p.models) == 2 - def test_duplicate_model_ids_rejected(self): + def test_duplicate_model_ids_rejected(self) -> None: with pytest.raises(ValidationError, match="Duplicate model IDs"): ProviderConfig( models=( @@ -102,7 +102,7 @@ def test_duplicate_model_ids_rejected(self): ), ) - def test_duplicate_aliases_rejected(self): + def test_duplicate_aliases_rejected(self) -> None: with pytest.raises(ValidationError, match="Duplicate model aliases"): ProviderConfig( models=( @@ -111,19 +111,19 @@ def test_duplicate_aliases_rejected(self): ), ) - def test_whitespace_api_key_rejected(self): + def test_whitespace_api_key_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): ProviderConfig(api_key=" ") - def test_whitespace_base_url_rejected(self): + def test_whitespace_base_url_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): ProviderConfig(base_url=" ") - def test_api_key_hidden_from_repr(self): + def test_api_key_hidden_from_repr(self) -> None: p = ProviderConfig(api_key="sk-secret-key-123") assert "sk-secret-key-123" not in repr(p) - def test_factory(self): + def test_factory(self) -> None: p = ProviderConfigFactory.build() assert isinstance(p, ProviderConfig) @@ -133,14 +133,14 @@ def test_factory(self): @pytest.mark.unit class TestRoutingRuleConfig: - def test_minimal(self): + def test_minimal(self) -> None: r = RoutingRuleConfig(preferred_model="sonnet") assert r.preferred_model == "sonnet" assert r.role_level is None assert r.task_type is None assert r.fallback is None - def test_full(self): + def test_full(self) -> None: r = RoutingRuleConfig( role_level=SeniorityLevel.SENIOR, task_type="development", @@ -150,19 +150,19 @@ def test_full(self): assert r.role_level == SeniorityLevel.SENIOR assert r.task_type == "development" - def test_blank_preferred_model_rejected(self): + def test_blank_preferred_model_rejected(self) -> None: with pytest.raises(ValidationError): RoutingRuleConfig(preferred_model="") - def test_whitespace_task_type_rejected(self): + def test_whitespace_task_type_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): RoutingRuleConfig(preferred_model="sonnet", task_type=" ") - def test_whitespace_fallback_rejected(self): + def test_whitespace_fallback_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): RoutingRuleConfig(preferred_model="sonnet", fallback=" ") - def test_factory(self): + def test_factory(self) -> None: r = RoutingRuleConfigFactory.build() assert isinstance(r, RoutingRuleConfig) @@ -172,13 +172,13 @@ def test_factory(self): @pytest.mark.unit class TestRoutingConfig: - def test_defaults(self): + def test_defaults(self) -> None: r = RoutingConfig() assert r.strategy == "cost_aware" assert r.rules == () assert r.fallback_chain == () - def test_with_rules(self): + def test_with_rules(self) -> None: r = RoutingConfig( rules=(RoutingRuleConfig(preferred_model="sonnet"),), fallback_chain=("sonnet",), @@ -186,15 +186,15 @@ def test_with_rules(self): assert len(r.rules) == 1 assert r.fallback_chain == ("sonnet",) - def test_whitespace_strategy_rejected(self): + def test_whitespace_strategy_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): RoutingConfig(strategy=" ") - def test_whitespace_fallback_entry_rejected(self): + def test_whitespace_fallback_entry_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): RoutingConfig(fallback_chain=(" ",)) - def test_factory(self): + def test_factory(self) -> None: r = RoutingConfigFactory.build() assert isinstance(r, RoutingConfig) @@ -204,7 +204,7 @@ def test_factory(self): @pytest.mark.unit class TestAgentConfig: - def test_minimal(self): + def test_minimal(self) -> None: a = AgentConfig( name="Alice", role="Backend Developer", @@ -215,7 +215,7 @@ def test_minimal(self): assert a.personality == {} assert a.model == {} - def test_full(self): + def test_full(self) -> None: a = AgentConfig( name="Alice", role="Backend Developer", @@ -227,20 +227,20 @@ def test_full(self): assert a.level == SeniorityLevel.SENIOR assert a.personality == {"traits": ["analytical"]} - def test_blank_name_rejected(self): + def test_blank_name_rejected(self) -> None: with pytest.raises(ValidationError): AgentConfig(name="", role="dev", department="eng") - def test_whitespace_name_rejected(self): + def test_whitespace_name_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): AgentConfig(name=" ", role="dev", department="eng") - def test_frozen(self): + def test_frozen(self) -> None: a = AgentConfig(name="A", role="R", department="D") with pytest.raises(ValidationError): a.name = "B" # type: ignore[misc] - def test_factory(self): + def test_factory(self) -> None: a = AgentConfigFactory.build() assert isinstance(a, AgentConfig) assert a.name @@ -251,7 +251,7 @@ def test_factory(self): @pytest.mark.unit class TestRootConfig: - def test_minimal(self): + def test_minimal(self) -> None: cfg = RootConfig(company_name="Test Corp") assert cfg.company_name == "Test Corp" assert cfg.company_type == CompanyType.CUSTOM @@ -260,7 +260,7 @@ def test_minimal(self): assert cfg.providers == {} assert cfg.logging is None - def test_full(self): + def test_full(self) -> None: model = ProviderModelConfig(id="m1", alias="fast") cfg = RootConfig( company_name="Acme AI", @@ -283,30 +283,30 @@ def test_full(self): assert len(cfg.agents) == 1 assert "anthropic" in cfg.providers - def test_defaults_applied(self): + def test_defaults_applied(self) -> None: cfg = RootConfig(company_name="X") assert cfg.budget.total_monthly == 100.0 assert cfg.communication.default_pattern.value == "hybrid" assert cfg.routing.strategy == "cost_aware" - def test_missing_company_name_rejected(self): + def test_missing_company_name_rejected(self) -> None: with pytest.raises(ValidationError): RootConfig() # type: ignore[call-arg] - def test_blank_company_name_rejected(self): + def test_blank_company_name_rejected(self) -> None: with pytest.raises(ValidationError): RootConfig(company_name="") - def test_whitespace_company_name_rejected(self): + def test_whitespace_company_name_rejected(self) -> None: with pytest.raises(ValidationError, match="whitespace-only"): RootConfig(company_name=" ") - def test_frozen(self): + def test_frozen(self) -> None: cfg = RootConfig(company_name="X") with pytest.raises(ValidationError): cfg.company_name = "Y" # type: ignore[misc] - def test_unique_agent_names(self): + def test_unique_agent_names(self) -> None: with pytest.raises(ValidationError, match="Duplicate agent names"): RootConfig( company_name="X", @@ -316,20 +316,20 @@ def test_unique_agent_names(self): ), ) - def test_unique_department_names(self): + def test_unique_department_names(self) -> None: with pytest.raises( ValidationError, match="Duplicate department names", ): RootConfig( company_name="X", - departments=( + departments=( # type: ignore[arg-type] {"name": "Engineering", "head": "cto"}, {"name": "Engineering", "head": "vp"}, ), ) - def test_routing_references_unknown_model(self): + def test_routing_references_unknown_model(self) -> None: with pytest.raises( ValidationError, match="unknown model", @@ -341,7 +341,7 @@ def test_routing_references_unknown_model(self): ), ) - def test_routing_rule_unknown_fallback_rejected(self): + def test_routing_rule_unknown_fallback_rejected(self) -> None: model = ProviderModelConfig(id="m1", alias="fast") with pytest.raises(ValidationError, match="unknown fallback"): RootConfig( @@ -357,7 +357,7 @@ def test_routing_rule_unknown_fallback_rejected(self): ), ) - def test_fallback_chain_unknown_model_rejected(self): + def test_fallback_chain_unknown_model_rejected(self) -> None: model = ProviderModelConfig(id="m1") with pytest.raises( ValidationError, @@ -369,7 +369,7 @@ def test_fallback_chain_unknown_model_rejected(self): routing=RoutingConfig(fallback_chain=("nonexistent",)), ) - def test_routing_ambiguous_model_ref_across_providers(self): + def test_routing_ambiguous_model_ref_across_providers(self) -> None: model_a = ProviderModelConfig(id="shared-model") model_b = ProviderModelConfig(id="shared-model") with pytest.raises(ValidationError, match="Ambiguous model reference"): @@ -384,7 +384,7 @@ def test_routing_ambiguous_model_ref_across_providers(self): ), ) - def test_routing_ambiguous_alias_across_providers(self): + def test_routing_ambiguous_alias_across_providers(self) -> None: model_a = ProviderModelConfig(id="m1", alias="fast") model_b = ProviderModelConfig(id="m2", alias="fast") with pytest.raises(ValidationError, match="Ambiguous model reference"): @@ -399,7 +399,7 @@ def test_routing_ambiguous_alias_across_providers(self): ), ) - def test_routing_references_valid_model(self): + def test_routing_references_valid_model(self) -> None: model = ProviderModelConfig(id="m1", alias="fast") cfg = RootConfig( company_name="X", @@ -411,7 +411,7 @@ def test_routing_references_valid_model(self): ) assert cfg.routing.rules[0].preferred_model == "fast" - def test_factory(self): + def test_factory(self) -> None: cfg = RootConfigFactory.build() assert isinstance(cfg, RootConfig) assert cfg.company_name diff --git a/tests/unit/config/test_utils.py b/tests/unit/config/test_utils.py index d9aba4f2a3..b25a290492 100644 --- a/tests/unit/config/test_utils.py +++ b/tests/unit/config/test_utils.py @@ -7,61 +7,61 @@ @pytest.mark.unit class TestDeepMerge: - def test_empty_base(self): + def test_empty_base(self) -> None: result = deep_merge({}, {"a": 1}) assert result == {"a": 1} - def test_empty_override(self): + def test_empty_override(self) -> None: result = deep_merge({"a": 1}, {}) assert result == {"a": 1} - def test_both_empty(self): + def test_both_empty(self) -> None: result = deep_merge({}, {}) assert result == {} - def test_simple_override(self): + def test_simple_override(self) -> None: result = deep_merge({"a": 1, "b": 2}, {"b": 3}) assert result == {"a": 1, "b": 3} - def test_nested_merge(self): + def test_nested_merge(self) -> None: base = {"x": {"a": 1, "b": 2}} override = {"x": {"b": 3, "c": 4}} result = deep_merge(base, override) assert result == {"x": {"a": 1, "b": 3, "c": 4}} - def test_deeply_nested(self): + def test_deeply_nested(self) -> None: base = {"x": {"y": {"z": 1, "w": 2}}} override = {"x": {"y": {"z": 99}}} result = deep_merge(base, override) assert result == {"x": {"y": {"z": 99, "w": 2}}} - def test_override_dict_with_scalar(self): + def test_override_dict_with_scalar(self) -> None: base = {"x": {"a": 1}} override = {"x": 42} result = deep_merge(base, override) assert result == {"x": 42} - def test_override_scalar_with_dict(self): + def test_override_scalar_with_dict(self) -> None: base = {"x": 42} override = {"x": {"a": 1}} result = deep_merge(base, override) assert result == {"x": {"a": 1}} - def test_does_not_mutate_base(self): + def test_does_not_mutate_base(self) -> None: base = {"x": {"a": 1}} override = {"x": {"b": 2}} original_base = {"x": {"a": 1}} deep_merge(base, override) assert base == original_base - def test_does_not_mutate_override(self): + def test_does_not_mutate_override(self) -> None: base = {"x": 1} override = {"y": {"a": [1, 2]}} original_override = {"y": {"a": [1, 2]}} deep_merge(base, override) assert override == original_override - def test_list_replaced_not_merged(self): + def test_list_replaced_not_merged(self) -> None: base = {"items": [1, 2, 3]} override = {"items": [4, 5]} result = deep_merge(base, override) diff --git a/tests/unit/core/conftest.py b/tests/unit/core/conftest.py index aca9772911..545cb099fc 100644 --- a/tests/unit/core/conftest.py +++ b/tests/unit/core/conftest.py @@ -41,98 +41,98 @@ # ── Factories ────────────────────────────────────────────────────── -class SkillFactory(ModelFactory): +class SkillFactory(ModelFactory[Skill]): __model__ = Skill -class AuthorityFactory(ModelFactory): +class AuthorityFactory(ModelFactory[Authority]): __model__ = Authority -class SeniorityInfoFactory(ModelFactory): +class SeniorityInfoFactory(ModelFactory[SeniorityInfo]): __model__ = SeniorityInfo -class RoleFactory(ModelFactory): +class RoleFactory(ModelFactory[Role]): __model__ = Role -class CustomRoleFactory(ModelFactory): +class CustomRoleFactory(ModelFactory[CustomRole]): __model__ = CustomRole -class PersonalityConfigFactory(ModelFactory): +class PersonalityConfigFactory(ModelFactory[PersonalityConfig]): __model__ = PersonalityConfig -class SkillSetFactory(ModelFactory): +class SkillSetFactory(ModelFactory[SkillSet]): __model__ = SkillSet -class ModelConfigFactory(ModelFactory): +class ModelConfigFactory(ModelFactory[ModelConfig]): __model__ = ModelConfig temperature = 0.7 -class MemoryConfigFactory(ModelFactory): +class MemoryConfigFactory(ModelFactory[MemoryConfig]): __model__ = MemoryConfig type = MemoryType.SESSION -class ToolPermissionsFactory(ModelFactory): +class ToolPermissionsFactory(ModelFactory[ToolPermissions]): __model__ = ToolPermissions allowed = () denied = () -class AgentIdentityFactory(ModelFactory): +class AgentIdentityFactory(ModelFactory[AgentIdentity]): __model__ = AgentIdentity memory = MemoryConfigFactory tools = ToolPermissionsFactory -class TeamFactory(ModelFactory): +class TeamFactory(ModelFactory[Team]): __model__ = Team -class DepartmentFactory(ModelFactory): +class DepartmentFactory(ModelFactory[Department]): __model__ = Department budget_percent = 10.0 -class CompanyConfigFactory(ModelFactory): +class CompanyConfigFactory(ModelFactory[CompanyConfig]): __model__ = CompanyConfig -class HRRegistryFactory(ModelFactory): +class HRRegistryFactory(ModelFactory[HRRegistry]): __model__ = HRRegistry -class CompanyFactory(ModelFactory): +class CompanyFactory(ModelFactory[Company]): __model__ = Company departments = () -class ExpectedArtifactFactory(ModelFactory): +class ExpectedArtifactFactory(ModelFactory[ExpectedArtifact]): __model__ = ExpectedArtifact -class ArtifactFactory(ModelFactory): +class ArtifactFactory(ModelFactory[Artifact]): __model__ = Artifact -class AcceptanceCriterionFactory(ModelFactory): +class AcceptanceCriterionFactory(ModelFactory[AcceptanceCriterion]): __model__ = AcceptanceCriterion -class TaskFactory(ModelFactory): +class TaskFactory(ModelFactory[Task]): __model__ = Task status = TaskStatus.CREATED assigned_to = None deadline = None -class ProjectFactory(ModelFactory): +class ProjectFactory(ModelFactory[Project]): __model__ = Project deadline = None diff --git a/tests/unit/core/test_enums.py b/tests/unit/core/test_enums.py index 3a9eba9fd4..c956ac28d8 100644 --- a/tests/unit/core/test_enums.py +++ b/tests/unit/core/test_enums.py @@ -87,61 +87,61 @@ def test_seniority_levels_are_lowercase(self) -> None: assert member.value == member.value.lower() def test_agent_status_values(self) -> None: - assert AgentStatus.ACTIVE == "active" - assert AgentStatus.ON_LEAVE == "on_leave" - assert AgentStatus.TERMINATED == "terminated" + assert AgentStatus.ACTIVE.value == "active" + assert AgentStatus.ON_LEAVE.value == "on_leave" + assert AgentStatus.TERMINATED.value == "terminated" def test_cost_tier_values(self) -> None: - assert CostTier.LOW == "low" - assert CostTier.MEDIUM == "medium" - assert CostTier.HIGH == "high" - assert CostTier.PREMIUM == "premium" + assert CostTier.LOW.value == "low" + assert CostTier.MEDIUM.value == "medium" + assert CostTier.HIGH.value == "high" + assert CostTier.PREMIUM.value == "premium" def test_company_type_values(self) -> None: - assert CompanyType.SOLO_FOUNDER == "solo_founder" - assert CompanyType.STARTUP == "startup" - assert CompanyType.CUSTOM == "custom" + assert CompanyType.SOLO_FOUNDER.value == "solo_founder" + assert CompanyType.STARTUP.value == "startup" + assert CompanyType.CUSTOM.value == "custom" def test_task_status_values(self) -> None: - assert TaskStatus.CREATED == "created" - assert TaskStatus.ASSIGNED == "assigned" - assert TaskStatus.IN_PROGRESS == "in_progress" - assert TaskStatus.IN_REVIEW == "in_review" - assert TaskStatus.COMPLETED == "completed" - assert TaskStatus.BLOCKED == "blocked" - assert TaskStatus.CANCELLED == "cancelled" + assert TaskStatus.CREATED.value == "created" + assert TaskStatus.ASSIGNED.value == "assigned" + assert TaskStatus.IN_PROGRESS.value == "in_progress" + assert TaskStatus.IN_REVIEW.value == "in_review" + assert TaskStatus.COMPLETED.value == "completed" + assert TaskStatus.BLOCKED.value == "blocked" + assert TaskStatus.CANCELLED.value == "cancelled" def test_task_type_values(self) -> None: - assert TaskType.DEVELOPMENT == "development" - assert TaskType.DESIGN == "design" - assert TaskType.RESEARCH == "research" - assert TaskType.REVIEW == "review" - assert TaskType.MEETING == "meeting" - assert TaskType.ADMIN == "admin" + assert TaskType.DEVELOPMENT.value == "development" + assert TaskType.DESIGN.value == "design" + assert TaskType.RESEARCH.value == "research" + assert TaskType.REVIEW.value == "review" + assert TaskType.MEETING.value == "meeting" + assert TaskType.ADMIN.value == "admin" def test_priority_values(self) -> None: - assert Priority.CRITICAL == "critical" - assert Priority.HIGH == "high" - assert Priority.MEDIUM == "medium" - assert Priority.LOW == "low" + assert Priority.CRITICAL.value == "critical" + assert Priority.HIGH.value == "high" + assert Priority.MEDIUM.value == "medium" + assert Priority.LOW.value == "low" def test_complexity_values(self) -> None: - assert Complexity.SIMPLE == "simple" - assert Complexity.MEDIUM == "medium" - assert Complexity.COMPLEX == "complex" - assert Complexity.EPIC == "epic" + assert Complexity.SIMPLE.value == "simple" + assert Complexity.MEDIUM.value == "medium" + assert Complexity.COMPLEX.value == "complex" + assert Complexity.EPIC.value == "epic" def test_artifact_type_values(self) -> None: - assert ArtifactType.CODE == "code" - assert ArtifactType.TESTS == "tests" - assert ArtifactType.DOCUMENTATION == "documentation" + assert ArtifactType.CODE.value == "code" + assert ArtifactType.TESTS.value == "tests" + assert ArtifactType.DOCUMENTATION.value == "documentation" def test_project_status_values(self) -> None: - assert ProjectStatus.PLANNING == "planning" - assert ProjectStatus.ACTIVE == "active" - assert ProjectStatus.ON_HOLD == "on_hold" - assert ProjectStatus.COMPLETED == "completed" - assert ProjectStatus.CANCELLED == "cancelled" + assert ProjectStatus.PLANNING.value == "planning" + assert ProjectStatus.ACTIVE.value == "active" + assert ProjectStatus.ON_HOLD.value == "on_hold" + assert ProjectStatus.COMPLETED.value == "completed" + assert ProjectStatus.CANCELLED.value == "cancelled" # ── StrEnum Behavior ─────────────────────────────────────────────── @@ -153,7 +153,7 @@ def test_strenum_is_string(self) -> None: assert isinstance(SeniorityLevel.JUNIOR, str) def test_strenum_equality_with_string(self) -> None: - assert SeniorityLevel.JUNIOR == "junior" + assert SeniorityLevel.JUNIOR == "junior" # type: ignore[comparison-overlap] def test_strenum_iteration(self) -> None: levels = list(SeniorityLevel) @@ -161,7 +161,7 @@ def test_strenum_iteration(self) -> None: assert levels[0] == SeniorityLevel.JUNIOR def test_strenum_membership(self) -> None: - assert "senior" in [m.value for m in SeniorityLevel] + assert "senior" in SeniorityLevel.__members__.values() def test_strenum_from_value(self) -> None: assert SeniorityLevel("junior") is SeniorityLevel.JUNIOR diff --git a/tests/unit/observability/conftest.py b/tests/unit/observability/conftest.py index 3575199f24..a90c8cc418 100644 --- a/tests/unit/observability/conftest.py +++ b/tests/unit/observability/conftest.py @@ -16,14 +16,14 @@ # -- Factories -------------------------------------------------------------- -class RotationConfigFactory(ModelFactory): +class RotationConfigFactory(ModelFactory[RotationConfig]): __model__ = RotationConfig strategy = RotationStrategy.BUILTIN max_bytes = 10 * 1024 * 1024 backup_count = 5 -class SinkConfigFactory(ModelFactory): +class SinkConfigFactory(ModelFactory[SinkConfig]): __model__ = SinkConfig sink_type = SinkType.CONSOLE level = LogLevel.INFO @@ -32,7 +32,7 @@ class SinkConfigFactory(ModelFactory): json_format = False -class LogConfigFactory(ModelFactory): +class LogConfigFactory(ModelFactory[LogConfig]): __model__ = LogConfig root_level = LogLevel.DEBUG logger_levels = () diff --git a/tests/unit/observability/test_correlation.py b/tests/unit/observability/test_correlation.py index 0032c0b2b2..7d844b3f5e 100644 --- a/tests/unit/observability/test_correlation.py +++ b/tests/unit/observability/test_correlation.py @@ -198,7 +198,7 @@ def test_preserves_outer_context(self) -> None: @with_correlation(request_id="inner") def inner() -> str: ctx = structlog.contextvars.get_contextvars() - return ctx["request_id"] + return ctx["request_id"] # type: ignore[no-any-return] assert inner() == "inner" ctx = structlog.contextvars.get_contextvars() diff --git a/tests/unit/observability/test_enums.py b/tests/unit/observability/test_enums.py index d1be1abca7..4fd91b2510 100644 --- a/tests/unit/observability/test_enums.py +++ b/tests/unit/observability/test_enums.py @@ -21,11 +21,11 @@ def test_all_members_exist(self) -> None: assert LogLevel.CRITICAL in members def test_values_are_strings(self) -> None: - assert LogLevel.DEBUG == "DEBUG" - assert LogLevel.INFO == "INFO" - assert LogLevel.WARNING == "WARNING" - assert LogLevel.ERROR == "ERROR" - assert LogLevel.CRITICAL == "CRITICAL" + assert LogLevel.DEBUG.value == "DEBUG" + assert LogLevel.INFO.value == "INFO" + assert LogLevel.WARNING.value == "WARNING" + assert LogLevel.ERROR.value == "ERROR" + assert LogLevel.CRITICAL.value == "CRITICAL" def test_membership(self) -> None: assert "DEBUG" in LogLevel.__members__.values() @@ -46,8 +46,8 @@ def test_all_members_exist(self) -> None: assert RotationStrategy.EXTERNAL in members def test_values_are_strings(self) -> None: - assert RotationStrategy.BUILTIN == "builtin" - assert RotationStrategy.EXTERNAL == "external" + assert RotationStrategy.BUILTIN.value == "builtin" + assert RotationStrategy.EXTERNAL.value == "external" def test_is_str_subclass(self) -> None: assert isinstance(RotationStrategy.BUILTIN, str) @@ -64,8 +64,8 @@ def test_all_members_exist(self) -> None: assert SinkType.FILE in members def test_values_are_strings(self) -> None: - assert SinkType.CONSOLE == "console" - assert SinkType.FILE == "file" + assert SinkType.CONSOLE.value == "console" + assert SinkType.FILE.value == "file" def test_is_str_subclass(self) -> None: assert isinstance(SinkType.CONSOLE, str) diff --git a/tests/unit/observability/test_processors.py b/tests/unit/observability/test_processors.py index 54737c0cb5..f964b381db 100644 --- a/tests/unit/observability/test_processors.py +++ b/tests/unit/observability/test_processors.py @@ -97,7 +97,7 @@ def test_redacts_session(self) -> None: assert result["session_id"] == "**REDACTED**" def test_non_string_key_preserved(self) -> None: - event: dict[str | int, str] = {42: "value", "event": "test"} # type: ignore[assignment] + event: dict[str | int, str] = {42: "value", "event": "test"} result = sanitize_sensitive_fields(None, "info", event) # type: ignore[arg-type] assert result[42] == "value" # type: ignore[index] assert result["event"] == "test" diff --git a/tests/unit/providers/conftest.py b/tests/unit/providers/conftest.py index 20341c4992..f63b2f6c8d 100644 --- a/tests/unit/providers/conftest.py +++ b/tests/unit/providers/conftest.py @@ -21,7 +21,7 @@ # ── Factories ────────────────────────────────────────────────────── -class TokenUsageFactory(ModelFactory): +class TokenUsageFactory(ModelFactory[TokenUsage]): __model__ = TokenUsage input_tokens = 100 output_tokens = 50 @@ -29,32 +29,32 @@ class TokenUsageFactory(ModelFactory): cost_usd = 0.001 -class ToolDefinitionFactory(ModelFactory): +class ToolDefinitionFactory(ModelFactory[ToolDefinition]): __model__ = ToolDefinition name = "get_weather" description = "Get current weather for a location" - parameters_schema = { # type: ignore[var-annotated] # noqa: RUF012 + parameters_schema = { # noqa: RUF012 "type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"], } -class ToolCallFactory(ModelFactory): +class ToolCallFactory(ModelFactory[ToolCall]): __model__ = ToolCall id = "call_001" name = "get_weather" - arguments = {"location": "London"} # type: ignore[var-annotated] # noqa: RUF012 + arguments = {"location": "London"} # noqa: RUF012 -class ToolResultFactory(ModelFactory): +class ToolResultFactory(ModelFactory[ToolResult]): __model__ = ToolResult tool_call_id = "call_001" content = "Sunny, 22°C" is_error = False -class ChatMessageFactory(ModelFactory): +class ChatMessageFactory(ModelFactory[ChatMessage]): __model__ = ChatMessage role = MessageRole.USER content = "Hello" @@ -62,7 +62,7 @@ class ChatMessageFactory(ModelFactory): tool_result = None -class CompletionConfigFactory(ModelFactory): +class CompletionConfigFactory(ModelFactory[CompletionConfig]): __model__ = CompletionConfig temperature = 0.7 max_tokens = 1024 @@ -71,7 +71,7 @@ class CompletionConfigFactory(ModelFactory): timeout = None -class CompletionResponseFactory(ModelFactory): +class CompletionResponseFactory(ModelFactory[CompletionResponse]): __model__ = CompletionResponse content = "Hello! How can I help you?" tool_calls = () @@ -81,7 +81,7 @@ class CompletionResponseFactory(ModelFactory): provider_request_id = None -class StreamChunkFactory(ModelFactory): +class StreamChunkFactory(ModelFactory[StreamChunk]): __model__ = StreamChunk event_type = StreamEventType.CONTENT_DELTA content = "Hello" @@ -90,7 +90,7 @@ class StreamChunkFactory(ModelFactory): error_message = None -class ModelCapabilitiesFactory(ModelFactory): +class ModelCapabilitiesFactory(ModelFactory[ModelCapabilities]): __model__ = ModelCapabilities model_id = "test-model" provider = "test-provider" diff --git a/tests/unit/providers/drivers/test_litellm_driver.py b/tests/unit/providers/drivers/test_litellm_driver.py index bb5f869bf7..3a1ece9034 100644 --- a/tests/unit/providers/drivers/test_litellm_driver.py +++ b/tests/unit/providers/drivers/test_litellm_driver.py @@ -3,10 +3,14 @@ All tests mock ``litellm.acompletion`` — no real API calls are made. """ -from unittest.mock import AsyncMock, patch +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch import pytest +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from ai_company.config.schema import ProviderConfig, ProviderModelConfig from ai_company.providers.drivers.litellm_driver import LiteLLMDriver from ai_company.providers.enums import ( @@ -28,6 +32,7 @@ from ai_company.providers.models import ( ChatMessage, CompletionConfig, + StreamChunk, ToolDefinition, ) @@ -67,9 +72,9 @@ def _user_message( async def _collect_stream( driver: LiteLLMDriver, mock_call: AsyncMock, - chunks: list, + chunks: list[MagicMock], model: str = "sonnet", -) -> list: +) -> list[StreamChunk]: mock_call.return_value = mock_stream_response(chunks) stream = await driver.stream(_user_message(), model) return [chunk async for chunk in stream] @@ -80,7 +85,7 @@ async def _collect_stream( @pytest.mark.unit class TestDoComplete: - async def test_basic_completion(self): + async def test_basic_completion(self) -> None: driver = _make_driver() mock_resp = make_mock_response() @@ -94,7 +99,7 @@ async def test_basic_completion(self): assert result.usage.input_tokens == 100 assert result.usage.output_tokens == 50 - async def test_completion_with_tool_calls(self): + async def test_completion_with_tool_calls(self) -> None: driver = _make_driver() tc = make_mock_tool_call() mock_resp = make_mock_response( @@ -113,7 +118,7 @@ async def test_completion_with_tool_calls(self): assert result.tool_calls[0].name == "get_weather" assert result.finish_reason == FinishReason.TOOL_USE - async def test_model_alias_resolution(self): + async def test_model_alias_resolution(self) -> None: driver = _make_driver() mock_resp = make_mock_response() @@ -124,7 +129,7 @@ async def test_model_alias_resolution(self): kw = m.call_args.kwargs assert kw["model"] == "anthropic/claude-haiku-4-5" - async def test_model_id_resolution(self): + async def test_model_id_resolution(self) -> None: driver = _make_driver() mock_resp = make_mock_response() @@ -138,13 +143,13 @@ async def test_model_id_resolution(self): kw = m.call_args.kwargs assert kw["model"] == "anthropic/claude-sonnet-4-6" - async def test_unknown_model_raises(self): + async def test_unknown_model_raises(self) -> None: driver = _make_driver() with pytest.raises(ModelNotFoundError, match="nonexistent"): await driver.complete(_user_message(), "nonexistent") - async def test_api_key_passed_to_litellm(self): + async def test_api_key_passed_to_litellm(self) -> None: driver = _make_driver() mock_resp = make_mock_response() @@ -154,7 +159,7 @@ async def test_api_key_passed_to_litellm(self): assert m.call_args.kwargs["api_key"] == "sk-test-key" - async def test_base_url_passed_to_litellm(self): + async def test_base_url_passed_to_litellm(self) -> None: config = make_provider_config( base_url="https://custom.api.example.com", ) @@ -168,7 +173,7 @@ async def test_base_url_passed_to_litellm(self): kw = m.call_args.kwargs assert kw["api_base"] == "https://custom.api.example.com" - async def test_completion_config_parameters(self): + async def test_completion_config_parameters(self) -> None: driver = _make_driver() mock_resp = make_mock_response() comp_config = CompletionConfig( @@ -194,7 +199,7 @@ async def test_completion_config_parameters(self): assert kw["top_p"] == 0.9 assert kw["timeout"] == 30.0 - async def test_tools_passed_to_litellm(self): + async def test_tools_passed_to_litellm(self) -> None: driver = _make_driver() mock_resp = make_mock_response() tools = [ @@ -217,7 +222,7 @@ async def test_tools_passed_to_litellm(self): assert "tools" in kw assert kw["tools"][0]["function"]["name"] == "search" - async def test_provider_request_id_captured(self): + async def test_provider_request_id_captured(self) -> None: driver = _make_driver() mock_resp = make_mock_response(request_id="req_xyz789") @@ -227,7 +232,7 @@ async def test_provider_request_id_captured(self): assert result.provider_request_id == "req_xyz789" - async def test_cost_computed_from_config(self): + async def test_cost_computed_from_config(self) -> None: driver = _make_driver() mock_resp = make_mock_response( prompt_tokens=1000, @@ -247,7 +252,7 @@ async def test_cost_computed_from_config(self): @pytest.mark.unit class TestDoStream: - async def test_basic_streaming(self): + async def test_basic_streaming(self) -> None: driver = _make_driver() chunks = [ make_stream_chunk(content="Hello"), @@ -270,7 +275,7 @@ async def test_basic_streaming(self): assert content_chunks[1].content == " world" assert collected[-1].event_type == StreamEventType.DONE - async def test_streaming_with_tool_calls(self): + async def test_streaming_with_tool_calls(self) -> None: driver = _make_driver() td1 = make_stream_tool_call_delta( index=0, @@ -301,7 +306,7 @@ async def test_streaming_with_tool_calls(self): assert tc.name == "search" assert tc.arguments == {"query": "test"} - async def test_streaming_usage_chunk(self): + async def test_streaming_usage_chunk(self) -> None: driver = _make_driver() chunks = [ make_stream_chunk(content="Hi"), @@ -321,7 +326,7 @@ async def test_streaming_usage_chunk(self): assert usage_chunks[0].usage.input_tokens == 50 assert usage_chunks[0].usage.output_tokens == 10 - async def test_stream_sets_stream_option(self): + async def test_stream_sets_stream_option(self) -> None: driver = _make_driver() chunks = [make_stream_chunk(content="ok")] @@ -332,7 +337,7 @@ async def test_stream_sets_stream_option(self): assert kw["stream"] is True assert kw["stream_options"] == {"include_usage": True} - async def test_streaming_incomplete_tool_call_dropped(self): + async def test_streaming_incomplete_tool_call_dropped(self) -> None: """Tool call with no id/name is silently dropped.""" driver = _make_driver() # Delta with arguments but no id or name @@ -353,7 +358,7 @@ async def test_streaming_incomplete_tool_call_dropped(self): ] assert len(tc_chunks) == 0 - async def test_streaming_multiple_concurrent_tool_calls(self): + async def test_streaming_multiple_concurrent_tool_calls(self) -> None: """Multiple tool calls at different indices are emitted separately.""" driver = _make_driver() td1_a = make_stream_tool_call_delta( @@ -389,14 +394,18 @@ async def test_streaming_multiple_concurrent_tool_calls(self): c for c in collected if c.event_type == StreamEventType.TOOL_CALL_DELTA ] assert len(tc_chunks) == 2 - assert tc_chunks[0].tool_call_delta.id == "call_001" - assert tc_chunks[0].tool_call_delta.name == "search" - assert tc_chunks[0].tool_call_delta.arguments == {"q": "test"} - assert tc_chunks[1].tool_call_delta.id == "call_002" - assert tc_chunks[1].tool_call_delta.name == "read" - assert tc_chunks[1].tool_call_delta.arguments == {"path": "f.py"} - - async def test_streaming_usage_only_chunk_no_choices(self): + tc0 = tc_chunks[0].tool_call_delta + tc1 = tc_chunks[1].tool_call_delta + assert tc0 is not None + assert tc1 is not None + assert tc0.id == "call_001" + assert tc0.name == "search" + assert tc0.arguments == {"q": "test"} + assert tc1.id == "call_002" + assert tc1.name == "read" + assert tc1.arguments == {"path": "f.py"} + + async def test_streaming_usage_only_chunk_no_choices(self) -> None: """Usage-only chunk with empty choices is emitted.""" from unittest.mock import MagicMock @@ -418,9 +427,10 @@ async def test_streaming_usage_only_chunk_no_choices(self): usage_chunks = [c for c in collected if c.event_type == StreamEventType.USAGE] assert len(usage_chunks) == 1 + assert usage_chunks[0].usage is not None assert usage_chunks[0].usage.input_tokens == 50 - async def test_streaming_usage_emitted_when_prompt_tokens_zero(self): + async def test_streaming_usage_emitted_when_prompt_tokens_zero(self) -> None: """Usage with prompt_tokens=0 is still emitted.""" driver = _make_driver() chunks = [ @@ -436,10 +446,11 @@ async def test_streaming_usage_emitted_when_prompt_tokens_zero(self): usage_chunks = [c for c in collected if c.event_type == StreamEventType.USAGE] assert len(usage_chunks) == 1 + assert usage_chunks[0].usage is not None assert usage_chunks[0].usage.input_tokens == 0 assert usage_chunks[0].usage.output_tokens == 10 - async def test_tool_call_arguments_length_limit(self): + async def test_tool_call_arguments_length_limit(self) -> None: """Tool call arguments exceeding 1 MiB are truncated.""" from ai_company.providers.drivers.litellm_driver import _ToolCallAccumulator @@ -490,7 +501,7 @@ async def test_exception_mapping( self, litellm_exc_name: str, expected_type: type, - ): + ) -> None: import litellm as _litellm driver = _make_driver() @@ -506,18 +517,19 @@ async def test_exception_mapping( with pytest.raises(expected_type) as exc_info: await driver.complete(_user_message(), "sonnet") + assert isinstance(exc_info.value, ProviderError) assert exc_info.value.context["provider"] == "anthropic" - async def test_rate_limit_retry_after_extracted(self): + async def test_rate_limit_retry_after_extracted(self) -> None: import litellm as _litellm driver = _make_driver() - exc = _litellm.RateLimitError( + exc = _litellm.RateLimitError( # type: ignore[attr-defined] message="Rate limited", model="test", llm_provider="anthropic", ) - exc.headers = {"retry-after": "30"} + exc.headers = {"retry-after": "30"} # type: ignore[attr-defined] with patch( _PATCH_ACOMPLETION, @@ -529,17 +541,17 @@ async def test_rate_limit_retry_after_extracted(self): assert exc_info.value.retry_after == 30.0 - async def test_rate_limit_retry_after_case_insensitive(self): + async def test_rate_limit_retry_after_case_insensitive(self) -> None: """Header lookup is case-insensitive per HTTP semantics.""" import litellm as _litellm driver = _make_driver() - exc = _litellm.RateLimitError( + exc = _litellm.RateLimitError( # type: ignore[attr-defined] message="Rate limited", model="test", llm_provider="anthropic", ) - exc.headers = {"Retry-After": "15"} + exc.headers = {"Retry-After": "15"} # type: ignore[attr-defined] with patch( _PATCH_ACOMPLETION, @@ -551,12 +563,12 @@ async def test_rate_limit_retry_after_case_insensitive(self): assert exc_info.value.retry_after == 15.0 - async def test_rate_limit_no_headers(self): + async def test_rate_limit_no_headers(self) -> None: """No headers attribute yields retry_after=None.""" import litellm as _litellm driver = _make_driver() - exc = _litellm.RateLimitError( + exc = _litellm.RateLimitError( # type: ignore[attr-defined] message="Rate limited", model="test", llm_provider="anthropic", @@ -572,17 +584,17 @@ async def test_rate_limit_no_headers(self): assert exc_info.value.retry_after is None - async def test_rate_limit_non_numeric_retry_after(self): + async def test_rate_limit_non_numeric_retry_after(self) -> None: """Non-numeric retry-after gracefully returns None.""" import litellm as _litellm driver = _make_driver() - exc = _litellm.RateLimitError( + exc = _litellm.RateLimitError( # type: ignore[attr-defined] message="Rate limited", model="test", llm_provider="anthropic", ) - exc.headers = { + exc.headers = { # type: ignore[attr-defined] "retry-after": "Wed, 21 Oct 2025 07:28:00 GMT", } @@ -596,7 +608,7 @@ async def test_rate_limit_non_numeric_retry_after(self): assert exc_info.value.retry_after is None - async def test_unknown_exception_maps_to_internal(self): + async def test_unknown_exception_maps_to_internal(self) -> None: driver = _make_driver() with patch( @@ -613,14 +625,14 @@ async def test_unknown_exception_maps_to_internal(self): "sonnet", ) - async def test_stream_exception_during_iteration(self): + async def test_stream_exception_during_iteration(self) -> None: import litellm as _litellm driver = _make_driver() - async def _failing_stream(): + async def _failing_stream() -> AsyncIterator[MagicMock]: yield make_stream_chunk(content="Hi") - raise _litellm.Timeout( + raise _litellm.Timeout( # type: ignore[attr-defined] message="Stream timed out", model="test", llm_provider="anthropic", @@ -639,7 +651,7 @@ async def _failing_stream(): async for _ in stream: pass - async def test_stream_exception_before_iteration(self): + async def test_stream_exception_before_iteration(self) -> None: """Stream setup failure maps to ProviderError.""" import litellm as _litellm @@ -648,7 +660,7 @@ async def test_stream_exception_before_iteration(self): _PATCH_ACOMPLETION, new_callable=AsyncMock, ) as m: - m.side_effect = _litellm.AuthenticationError( + m.side_effect = _litellm.AuthenticationError( # type: ignore[attr-defined] message="Invalid key", model="test", llm_provider="anthropic", @@ -656,7 +668,7 @@ async def test_stream_exception_before_iteration(self): with pytest.raises(AuthenticationError): await driver.stream(_user_message(), "sonnet") - async def test_response_mapping_error_wrapped_as_provider_error(self): + async def test_response_mapping_error_wrapped_as_provider_error(self) -> None: """Errors during response mapping are caught, not leaked raw.""" from unittest.mock import MagicMock @@ -678,7 +690,7 @@ async def test_response_mapping_error_wrapped_as_provider_error(self): @pytest.mark.unit class TestGetModelCapabilities: - async def test_basic_capabilities(self): + async def test_basic_capabilities(self) -> None: driver = _make_driver() model_info = { "max_output_tokens": 8192, @@ -702,7 +714,7 @@ async def test_basic_capabilities(self): assert caps.cost_per_1k_input == 0.003 assert caps.cost_per_1k_output == 0.015 - async def test_capabilities_fallback_on_litellm_error(self): + async def test_capabilities_fallback_on_litellm_error(self) -> None: driver = _make_driver() with patch( @@ -714,7 +726,7 @@ async def test_capabilities_fallback_on_litellm_error(self): assert caps.model_id == "claude-sonnet-4-6" assert caps.max_output_tokens == 4096 - async def test_streaming_capability_from_model_info(self): + async def test_streaming_capability_from_model_info(self) -> None: """supports_streaming reads from model info, not hard-coded.""" driver = _make_driver() model_info = { @@ -731,7 +743,7 @@ async def test_streaming_capability_from_model_info(self): assert caps.supports_streaming is False assert caps.supports_streaming_tool_calls is False - async def test_streaming_tool_calls_requires_both(self): + async def test_streaming_tool_calls_requires_both(self) -> None: """supports_streaming_tool_calls needs streaming AND tools.""" driver = _make_driver() model_info = { @@ -748,7 +760,7 @@ async def test_streaming_tool_calls_requires_both(self): assert caps.supports_streaming is True assert caps.supports_streaming_tool_calls is False - async def test_max_output_capped_at_context(self): + async def test_max_output_capped_at_context(self) -> None: config = make_provider_config( models=( ProviderModelConfig( @@ -776,7 +788,7 @@ async def test_max_output_capped_at_context(self): # ── Helpers ────────────────────────────────────────────────────── -def _litellm_exc_kwargs(exc_name: str) -> dict: +def _litellm_exc_kwargs(exc_name: str) -> dict[str, str]: """Build constructor kwargs for litellm exceptions.""" return { "message": f"Test {exc_name}", diff --git a/tests/unit/providers/drivers/test_mappers.py b/tests/unit/providers/drivers/test_mappers.py index 09790d99e1..9e5c485d3b 100644 --- a/tests/unit/providers/drivers/test_mappers.py +++ b/tests/unit/providers/drivers/test_mappers.py @@ -21,25 +21,25 @@ @pytest.mark.unit class TestMessagesToDicts: - def test_system_message(self): + def test_system_message(self) -> None: msg = ChatMessage(role=MessageRole.SYSTEM, content="You are helpful.") result = messages_to_dicts([msg]) assert result == [{"role": "system", "content": "You are helpful."}] - def test_user_message(self): + def test_user_message(self) -> None: msg = ChatMessage(role=MessageRole.USER, content="Hello!") result = messages_to_dicts([msg]) assert result == [{"role": "user", "content": "Hello!"}] - def test_assistant_message_text_only(self): + def test_assistant_message_text_only(self) -> None: msg = ChatMessage(role=MessageRole.ASSISTANT, content="Hi there!") result = messages_to_dicts([msg]) assert result == [{"role": "assistant", "content": "Hi there!"}] - def test_assistant_message_with_tool_calls(self): + def test_assistant_message_with_tool_calls(self) -> None: tc = ToolCall( id="call_001", name="get_weather", @@ -55,14 +55,19 @@ def test_assistant_message_with_tool_calls(self): assert len(result) == 1 assert result[0]["role"] == "assistant" assert "content" not in result[0] - tool_calls = result[0]["tool_calls"] - assert len(tool_calls) == 1 - assert tool_calls[0]["id"] == "call_001" - assert tool_calls[0]["type"] == "function" - assert tool_calls[0]["function"]["name"] == "get_weather" - assert tool_calls[0]["function"]["arguments"] == '{"location": "London"}' - - def test_tool_result_message(self): + raw_tool_calls = result[0]["tool_calls"] + assert isinstance(raw_tool_calls, list) + assert len(raw_tool_calls) == 1 + tc = raw_tool_calls[0] + assert isinstance(tc, dict) + assert tc["id"] == "call_001" + assert tc["type"] == "function" + func = tc["function"] + assert isinstance(func, dict) + assert func["name"] == "get_weather" + assert func["arguments"] == '{"location": "London"}' + + def test_tool_result_message(self) -> None: msg = ChatMessage( role=MessageRole.TOOL, tool_result=ToolResult( @@ -80,7 +85,7 @@ def test_tool_result_message(self): }, ] - def test_multiple_messages_preserve_order(self): + def test_multiple_messages_preserve_order(self) -> None: messages = [ ChatMessage(role=MessageRole.SYSTEM, content="System prompt"), ChatMessage(role=MessageRole.USER, content="Question"), @@ -93,7 +98,7 @@ def test_multiple_messages_preserve_order(self): assert result[1]["role"] == "user" assert result[2]["role"] == "assistant" - def test_empty_messages_list(self): + def test_empty_messages_list(self) -> None: assert messages_to_dicts([]) == [] @@ -102,7 +107,7 @@ def test_empty_messages_list(self): @pytest.mark.unit class TestToolsToDicts: - def test_single_tool(self): + def test_single_tool(self) -> None: tool = ToolDefinition( name="search", description="Search the codebase", @@ -128,13 +133,15 @@ def test_single_tool(self): }, } - def test_tool_with_empty_schema(self): + def test_tool_with_empty_schema(self) -> None: tool = ToolDefinition(name="ping", description="Ping the server") result = tools_to_dicts([tool]) - assert result[0]["function"]["parameters"] == {} + func = result[0]["function"] + assert isinstance(func, dict) + assert func["parameters"] == {} - def test_multiple_tools(self): + def test_multiple_tools(self) -> None: tools = [ ToolDefinition(name="a", description="Tool A"), ToolDefinition(name="b", description="Tool B"), @@ -142,10 +149,14 @@ def test_multiple_tools(self): result = tools_to_dicts(tools) assert len(result) == 2 - assert result[0]["function"]["name"] == "a" - assert result[1]["function"]["name"] == "b" - - def test_empty_tools_list(self): + func_0 = result[0]["function"] + func_1 = result[1]["function"] + assert isinstance(func_0, dict) + assert isinstance(func_1, dict) + assert func_0["name"] == "a" + assert func_1["name"] == "b" + + def test_empty_tools_list(self) -> None: assert tools_to_dicts([]) == [] @@ -168,13 +179,13 @@ class TestMapFinishReason: ("content_filter", FinishReason.CONTENT_FILTER), ], ) - def test_known_reasons(self, raw: str, expected: FinishReason): + def test_known_reasons(self, raw: str, expected: FinishReason) -> None: assert map_finish_reason(raw) == expected - def test_none_maps_to_error(self): + def test_none_maps_to_error(self) -> None: assert map_finish_reason(None) == FinishReason.ERROR - def test_unknown_string_maps_to_error(self): + def test_unknown_string_maps_to_error(self) -> None: assert map_finish_reason("some_unknown_reason") == FinishReason.ERROR @@ -183,13 +194,13 @@ def test_unknown_string_maps_to_error(self): @pytest.mark.unit class TestExtractToolCalls: - def test_none_returns_empty_tuple(self): + def test_none_returns_empty_tuple(self) -> None: assert extract_tool_calls(None) == () - def test_empty_list_returns_empty_tuple(self): + def test_empty_list_returns_empty_tuple(self) -> None: assert extract_tool_calls([]) == () - def test_single_tool_call_from_dict(self): + def test_single_tool_call_from_dict(self) -> None: raw = [ { "id": "call_001", @@ -207,7 +218,7 @@ def test_single_tool_call_from_dict(self): assert result[0].name == "get_weather" assert result[0].arguments == {"location": "London"} - def test_tool_call_from_object(self): + def test_tool_call_from_object(self) -> None: """Handle LiteLLM response objects with attribute access.""" from unittest.mock import MagicMock @@ -226,7 +237,7 @@ def test_tool_call_from_object(self): assert result[0].name == "search" assert result[0].arguments == {"query": "test"} - def test_multiple_tool_calls(self): + def test_multiple_tool_calls(self) -> None: raw = [ { "id": "call_001", @@ -243,7 +254,7 @@ def test_multiple_tool_calls(self): assert result[0].name == "a" assert result[1].name == "b" - def test_invalid_json_arguments_returns_empty_dict(self): + def test_invalid_json_arguments_returns_empty_dict(self) -> None: raw = [ { "id": "call_001", @@ -254,7 +265,7 @@ def test_invalid_json_arguments_returns_empty_dict(self): assert result[0].arguments == {} - def test_pre_parsed_dict_arguments(self): + def test_pre_parsed_dict_arguments(self) -> None: raw = [ { "id": "call_001", @@ -268,13 +279,13 @@ def test_pre_parsed_dict_arguments(self): assert result[0].arguments == {"key": "value"} - def test_missing_function_skips_entry(self): + def test_missing_function_skips_entry(self) -> None: raw = [{"id": "call_001"}] result = extract_tool_calls(raw) assert result == () - def test_missing_id_skips_entry(self): + def test_missing_id_skips_entry(self) -> None: raw = [{"function": {"name": "test", "arguments": "{}"}}] result = extract_tool_calls(raw) diff --git a/tests/unit/providers/test_enums.py b/tests/unit/providers/test_enums.py index 541fd8e7bd..b9f65126aa 100644 --- a/tests/unit/providers/test_enums.py +++ b/tests/unit/providers/test_enums.py @@ -20,10 +20,10 @@ def test_all_members_exist(self) -> None: assert MessageRole.TOOL in members def test_values_are_strings(self) -> None: - assert MessageRole.SYSTEM == "system" - assert MessageRole.USER == "user" - assert MessageRole.ASSISTANT == "assistant" - assert MessageRole.TOOL == "tool" + assert MessageRole.SYSTEM.value == "system" + assert MessageRole.USER.value == "user" + assert MessageRole.ASSISTANT.value == "assistant" + assert MessageRole.TOOL.value == "tool" def test_membership(self) -> None: assert "system" in MessageRole.__members__.values() @@ -47,11 +47,11 @@ def test_all_members_exist(self) -> None: assert FinishReason.ERROR in members def test_values_are_strings(self) -> None: - assert FinishReason.STOP == "stop" - assert FinishReason.MAX_TOKENS == "max_tokens" - assert FinishReason.TOOL_USE == "tool_use" - assert FinishReason.CONTENT_FILTER == "content_filter" - assert FinishReason.ERROR == "error" + assert FinishReason.STOP.value == "stop" + assert FinishReason.MAX_TOKENS.value == "max_tokens" + assert FinishReason.TOOL_USE.value == "tool_use" + assert FinishReason.CONTENT_FILTER.value == "content_filter" + assert FinishReason.ERROR.value == "error" def test_is_str_subclass(self) -> None: assert isinstance(FinishReason.STOP, str) @@ -71,11 +71,11 @@ def test_all_members_exist(self) -> None: assert StreamEventType.DONE in members def test_values_are_strings(self) -> None: - assert StreamEventType.CONTENT_DELTA == "content_delta" - assert StreamEventType.TOOL_CALL_DELTA == "tool_call_delta" - assert StreamEventType.USAGE == "usage" - assert StreamEventType.ERROR == "error" - assert StreamEventType.DONE == "done" + assert StreamEventType.CONTENT_DELTA.value == "content_delta" + assert StreamEventType.TOOL_CALL_DELTA.value == "tool_call_delta" + assert StreamEventType.USAGE.value == "usage" + assert StreamEventType.ERROR.value == "error" + assert StreamEventType.DONE.value == "done" def test_is_str_subclass(self) -> None: assert isinstance(StreamEventType.DONE, str) diff --git a/tests/unit/providers/test_protocol.py b/tests/unit/providers/test_protocol.py index 2d55c8556f..afa831b6b3 100644 --- a/tests/unit/providers/test_protocol.py +++ b/tests/unit/providers/test_protocol.py @@ -243,7 +243,7 @@ def test_cannot_instantiate_abc_directly(self) -> None: def test_partial_implementation_rejected(self) -> None: class _PartialProvider(BaseCompletionProvider): - async def _do_complete( # type: ignore[override] + async def _do_complete( # type: ignore[empty-body] self, messages: list[ChatMessage], model: str, diff --git a/tests/unit/providers/test_registry.py b/tests/unit/providers/test_registry.py index 990f6dd0bf..c3278d4535 100644 --- a/tests/unit/providers/test_registry.py +++ b/tests/unit/providers/test_registry.py @@ -1,9 +1,23 @@ """Unit tests for ProviderRegistry.""" +from typing import TYPE_CHECKING + import pytest from ai_company.config.schema import ProviderConfig, ProviderModelConfig from ai_company.providers.base import BaseCompletionProvider + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from ai_company.providers.capabilities import ModelCapabilities + from ai_company.providers.models import ( + ChatMessage, + CompletionConfig, + CompletionResponse, + StreamChunk, + ToolDefinition, + ) from ai_company.providers.errors import ( DriverFactoryNotFoundError, DriverNotRegisteredError, @@ -39,13 +53,30 @@ def __init__(self, provider_name: str, config: ProviderConfig) -> None: self.provider_name = provider_name self.config = config - async def _do_complete(self, messages, model, *, tools=None, config=None): + async def _do_complete( + self, + messages: list[ChatMessage], + model: str, + *, + tools: list[ToolDefinition] | None = None, + config: CompletionConfig | None = None, + ) -> CompletionResponse: raise NotImplementedError - async def _do_stream(self, messages, model, *, tools=None, config=None): + async def _do_stream( + self, + messages: list[ChatMessage], + model: str, + *, + tools: list[ToolDefinition] | None = None, + config: CompletionConfig | None = None, + ) -> AsyncIterator[StreamChunk]: raise NotImplementedError - async def _do_get_model_capabilities(self, model): + async def _do_get_model_capabilities( + self, + model: str, + ) -> ModelCapabilities: raise NotImplementedError @@ -54,22 +85,22 @@ async def _do_get_model_capabilities(self, model): @pytest.mark.unit class TestRegistryGet: - def test_get_returns_registered_driver(self): - driver = _StubDriver("anthropic", _make_config()) + def test_get_returns_registered_driver(self) -> None: + driver: BaseCompletionProvider = _StubDriver("anthropic", _make_config()) registry = ProviderRegistry({"anthropic": driver}) result = registry.get("anthropic") assert result is driver - def test_get_raises_for_unknown_name(self): + def test_get_raises_for_unknown_name(self) -> None: registry = ProviderRegistry({}) with pytest.raises(DriverNotRegisteredError, match="not registered"): registry.get("nonexistent") - def test_get_error_lists_available_providers(self): - driver = _StubDriver("anthropic", _make_config()) + def test_get_error_lists_available_providers(self) -> None: + driver: BaseCompletionProvider = _StubDriver("anthropic", _make_config()) registry = ProviderRegistry({"anthropic": driver}) with pytest.raises(DriverNotRegisteredError, match="anthropic"): @@ -81,8 +112,8 @@ def test_get_error_lists_available_providers(self): @pytest.mark.unit class TestRegistryListProviders: - def test_list_providers_returns_sorted_names(self): - drivers = { + def test_list_providers_returns_sorted_names(self) -> None: + drivers: dict[str, BaseCompletionProvider] = { "openrouter": _StubDriver("openrouter", _make_config()), "anthropic": _StubDriver("anthropic", _make_config()), "ollama": _StubDriver("ollama", _make_config()), @@ -93,7 +124,7 @@ def test_list_providers_returns_sorted_names(self): assert result == ("anthropic", "ollama", "openrouter") - def test_list_providers_empty_registry(self): + def test_list_providers_empty_registry(self) -> None: registry = ProviderRegistry({}) assert registry.list_providers() == () @@ -104,19 +135,19 @@ def test_list_providers_empty_registry(self): @pytest.mark.unit class TestRegistryContainsAndLen: - def test_contains_registered_provider(self): - driver = _StubDriver("anthropic", _make_config()) + def test_contains_registered_provider(self) -> None: + driver: BaseCompletionProvider = _StubDriver("anthropic", _make_config()) registry = ProviderRegistry({"anthropic": driver}) assert "anthropic" in registry assert "unknown" not in registry - def test_contains_unhashable_returns_false(self): + def test_contains_unhashable_returns_false(self) -> None: registry = ProviderRegistry({}) assert [1, 2, 3] not in registry - def test_len_reflects_registered_count(self): - drivers = { + def test_len_reflects_registered_count(self) -> None: + drivers: dict[str, BaseCompletionProvider] = { "a": _StubDriver("a", _make_config()), "b": _StubDriver("b", _make_config()), } @@ -124,7 +155,7 @@ def test_len_reflects_registered_count(self): assert len(registry) == 2 - def test_empty_registry_len_zero(self): + def test_empty_registry_len_zero(self) -> None: assert len(ProviderRegistry({})) == 0 @@ -133,7 +164,7 @@ def test_empty_registry_len_zero(self): @pytest.mark.unit class TestRegistryFromConfig: - def test_from_config_with_factory_overrides(self): + def test_from_config_with_factory_overrides(self) -> None: config = _make_config(driver="stub") providers = {"test-provider": config} @@ -147,7 +178,7 @@ def test_from_config_with_factory_overrides(self): assert isinstance(driver, _StubDriver) assert driver.provider_name == "test-provider" - def test_from_config_multiple_providers(self): + def test_from_config_multiple_providers(self) -> None: providers = { "alpha": _make_config(driver="stub"), "beta": _make_config(driver="stub"), @@ -161,20 +192,20 @@ def test_from_config_multiple_providers(self): assert len(registry) == 2 assert registry.list_providers() == ("alpha", "beta") - def test_from_config_raises_for_unknown_driver(self): + def test_from_config_raises_for_unknown_driver(self) -> None: config = _make_config(driver="nonexistent") providers = {"test": config} with pytest.raises(DriverFactoryNotFoundError, match="No factory"): ProviderRegistry.from_config(providers) - def test_from_config_empty_providers(self): + def test_from_config_empty_providers(self) -> None: registry = ProviderRegistry.from_config({}) assert len(registry) == 0 assert registry.list_providers() == () - def test_from_config_raises_for_non_callable_factory(self): + def test_from_config_raises_for_non_callable_factory(self) -> None: config = _make_config(driver="bad") providers = {"test": config} @@ -184,7 +215,7 @@ def test_from_config_raises_for_non_callable_factory(self): factory_overrides={"bad": "not-a-function"}, ) - def test_from_config_raises_for_non_provider_return(self): + def test_from_config_raises_for_non_provider_return(self) -> None: config = _make_config(driver="bad") providers = {"test": config} @@ -199,12 +230,12 @@ def test_from_config_raises_for_non_provider_return(self): }, ) - def test_from_config_raises_for_factory_exception(self): + def test_from_config_raises_for_factory_exception(self) -> None: """Factory that raises is wrapped as DriverFactoryNotFoundError.""" config = _make_config(driver="bad") providers = {"test": config} - def _failing_factory(name, cfg): + def _failing_factory(name: str, cfg: ProviderConfig) -> BaseCompletionProvider: msg = "construction failed" raise TypeError(msg) @@ -217,7 +248,7 @@ def _failing_factory(name, cfg): factory_overrides={"bad": _failing_factory}, ) - def test_from_config_uses_litellm_by_default(self): + def test_from_config_uses_litellm_by_default(self) -> None: """Default driver='litellm' resolves to LiteLLMDriver factory.""" from ai_company.providers.drivers.litellm_driver import LiteLLMDriver @@ -235,7 +266,7 @@ def test_from_config_uses_litellm_by_default(self): @pytest.mark.unit class TestRegistryImmutability: - def test_registry_does_not_reflect_mutations_to_original_dict(self): + def test_registry_does_not_reflect_mutations_to_original_dict(self) -> None: drivers: dict[str, BaseCompletionProvider] = { "a": _StubDriver("a", _make_config()), } diff --git a/tests/unit/templates/conftest.py b/tests/unit/templates/conftest.py index 2697c73a2d..0171219bb1 100644 --- a/tests/unit/templates/conftest.py +++ b/tests/unit/templates/conftest.py @@ -1,6 +1,6 @@ """Unit test configuration and fixtures for templates.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol import pytest @@ -9,6 +9,12 @@ from pathlib import Path +class TemplateFileFactory(Protocol): + """Callable signature for the tmp_template_file fixture.""" + + def __call__(self, content: str, name: str = ...) -> Path: ... + + MINIMAL_TEMPLATE_YAML = """\ template: name: "Test Template" @@ -120,7 +126,7 @@ def make_template_dict() -> Callable[..., dict[str, Any]]: @pytest.fixture -def tmp_template_file(tmp_path: Path) -> Callable[[str, str], Path]: +def tmp_template_file(tmp_path: Path) -> TemplateFileFactory: """Factory fixture for writing a temporary template YAML file.""" def _create(content: str, name: str = "test_template.yaml") -> Path: diff --git a/tests/unit/templates/test_loader.py b/tests/unit/templates/test_loader.py index d57136ec41..f07cec8651 100644 --- a/tests/unit/templates/test_loader.py +++ b/tests/unit/templates/test_loader.py @@ -22,7 +22,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable + from .conftest import TemplateFileFactory from .conftest import ( INVALID_SYNTAX_YAML, @@ -36,17 +36,17 @@ @pytest.mark.unit class TestListBuiltinTemplates: - def test_returns_sorted_tuple(self): + def test_returns_sorted_tuple(self) -> None: names = list_builtin_templates() assert isinstance(names, tuple) assert names == tuple(sorted(names)) - def test_contains_all_registered(self): + def test_contains_all_registered(self) -> None: names = list_builtin_templates() for name in BUILTIN_TEMPLATES: assert name in names - def test_count_matches_registry(self): + def test_count_matches_registry(self) -> None: assert len(list_builtin_templates()) == len(BUILTIN_TEMPLATES) @@ -55,18 +55,18 @@ def test_count_matches_registry(self): @pytest.mark.unit class TestListTemplates: - def test_returns_tuple_of_template_info(self): + def test_returns_tuple_of_template_info(self) -> None: templates = list_templates() assert isinstance(templates, tuple) assert all(isinstance(t, TemplateInfo) for t in templates) - def test_all_builtins_present(self): + def test_all_builtins_present(self) -> None: templates = list_templates() names = {t.name for t in templates} for builtin_name in BUILTIN_TEMPLATES: assert builtin_name in names - def test_builtin_source_label(self): + def test_builtin_source_label(self) -> None: templates = list_templates() for t in templates: if t.name in BUILTIN_TEMPLATES: @@ -75,8 +75,8 @@ def test_builtin_source_label(self): def test_user_template_overrides_builtin( self, tmp_path: Path, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: user_dir = tmp_path / "user_templates" user_dir.mkdir() user_yaml = MINIMAL_TEMPLATE_YAML @@ -96,28 +96,28 @@ def test_user_template_overrides_builtin( @pytest.mark.unit class TestLoadTemplate: - def test_load_builtin_by_name(self): + def test_load_builtin_by_name(self) -> None: loaded = load_template("solo_founder") assert isinstance(loaded, LoadedTemplate) assert loaded.template.metadata.name == "Solo Founder" assert " None: loaded = load_template(" Solo_Founder ") assert loaded.template.metadata.name == "Solo Founder" - def test_all_builtins_load_successfully(self): + def test_all_builtins_load_successfully(self) -> None: for name in BUILTIN_TEMPLATES: loaded = load_template(name) assert isinstance(loaded, LoadedTemplate) assert len(loaded.raw_yaml) > 0 assert len(loaded.template.agents) >= 1 - def test_unknown_name_raises_not_found(self): + def test_unknown_name_raises_not_found(self) -> None: with pytest.raises(TemplateNotFoundError, match="Unknown template"): load_template("does_not_exist") - def test_user_template_preferred(self, tmp_path: Path): + def test_user_template_preferred(self, tmp_path: Path) -> None: user_dir = tmp_path / "user_templates" user_dir.mkdir() (user_dir / "solo_founder.yaml").write_text( @@ -140,8 +140,8 @@ def test_user_template_preferred(self, tmp_path: Path): class TestLoadTemplateFile: def test_load_from_path( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(MINIMAL_TEMPLATE_YAML) loaded = load_template_file(path) assert isinstance(loaded, LoadedTemplate) @@ -149,37 +149,37 @@ def test_load_from_path( def test_load_with_variables( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_WITH_VARIABLES_YAML) loaded = load_template_file(path) assert len(loaded.template.variables) == 2 assert loaded.template.variables[0].name == "company_name" - def test_nonexistent_file_raises_not_found(self): + def test_nonexistent_file_raises_not_found(self) -> None: with pytest.raises(TemplateNotFoundError, match="not found"): load_template_file(Path("/nonexistent/template.yaml")) def test_invalid_yaml_raises_render_error( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(INVALID_SYNTAX_YAML) with pytest.raises(TemplateRenderError, match="syntax error"): load_template_file(path) def test_missing_template_key_raises_validation_error( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(MISSING_TEMPLATE_KEY_YAML) with pytest.raises(TemplateValidationError, match="template"): load_template_file(path) def test_accepts_string_path( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(MINIMAL_TEMPLATE_YAML) loaded = load_template_file(str(path)) assert isinstance(loaded, LoadedTemplate) @@ -190,12 +190,12 @@ def test_accepts_string_path( @pytest.mark.unit class TestLoadedTemplate: - def test_frozen(self): + def test_frozen(self) -> None: loaded = load_template("solo_founder") with pytest.raises(AttributeError): loaded.source_name = "changed" # type: ignore[misc] - def test_raw_yaml_is_string(self): + def test_raw_yaml_is_string(self) -> None: loaded = load_template("startup") assert isinstance(loaded.raw_yaml, str) assert "template:" in loaded.raw_yaml diff --git a/tests/unit/templates/test_presets.py b/tests/unit/templates/test_presets.py index 2c554ac158..e6f916b08c 100644 --- a/tests/unit/templates/test_presets.py +++ b/tests/unit/templates/test_presets.py @@ -11,31 +11,31 @@ @pytest.mark.unit class TestGetPersonalityPreset: - def test_valid_preset_returns_dict(self): + def test_valid_preset_returns_dict(self) -> None: result = get_personality_preset("visionary_leader") assert isinstance(result, dict) assert "traits" in result assert "communication_style" in result - def test_case_insensitive(self): + def test_case_insensitive(self) -> None: result = get_personality_preset("VISIONARY_LEADER") assert result == get_personality_preset("visionary_leader") - def test_whitespace_stripped(self): + def test_whitespace_stripped(self) -> None: result = get_personality_preset(" pragmatic_builder ") assert result["communication_style"] == "concise" - def test_returns_copy(self): + def test_returns_copy(self) -> None: a = get_personality_preset("eager_learner") b = get_personality_preset("eager_learner") assert a == b assert a is not b - def test_unknown_preset_raises_key_error(self): + def test_unknown_preset_raises_key_error(self) -> None: with pytest.raises(KeyError, match="Unknown personality preset"): get_personality_preset("nonexistent") - def test_all_presets_have_required_keys(self): + def test_all_presets_have_required_keys(self) -> None: required_keys = {"traits", "communication_style", "description"} for name in PERSONALITY_PRESETS: preset = get_personality_preset(name) @@ -44,31 +44,31 @@ def test_all_presets_have_required_keys(self): @pytest.mark.unit class TestGenerateAutoName: - def test_known_role_returns_from_pool(self): + def test_known_role_returns_from_pool(self) -> None: name = generate_auto_name("CEO", seed=0) assert isinstance(name, str) assert len(name) > 0 - def test_unknown_role_uses_default_pool(self): + def test_unknown_role_uses_default_pool(self) -> None: name = generate_auto_name("Alien Commander", seed=0) assert name.startswith("Agent ") - def test_deterministic_with_seed(self): + def test_deterministic_with_seed(self) -> None: a = generate_auto_name("Backend Developer", seed=42) b = generate_auto_name("Backend Developer", seed=42) assert a == b - def test_different_seeds_may_differ(self): + def test_different_seeds_may_differ(self) -> None: names = {generate_auto_name("CEO", seed=i) for i in range(10)} # With 4 names in the pool, at least 2 distinct names expected. assert len(names) >= 2 - def test_case_insensitive_role(self): + def test_case_insensitive_role(self) -> None: a = generate_auto_name("ceo", seed=0) b = generate_auto_name("CEO", seed=0) assert a == b - def test_whitespace_stripped_from_role(self): + def test_whitespace_stripped_from_role(self) -> None: a = generate_auto_name(" CEO ", seed=0) b = generate_auto_name("CEO", seed=0) assert a == b diff --git a/tests/unit/templates/test_renderer.py b/tests/unit/templates/test_renderer.py index 977ea8d4c7..4c173ab4fa 100644 --- a/tests/unit/templates/test_renderer.py +++ b/tests/unit/templates/test_renderer.py @@ -13,29 +13,28 @@ from .conftest import TEMPLATE_REQUIRED_VAR_YAML, TEMPLATE_WITH_VARIABLES_YAML if TYPE_CHECKING: - from collections.abc import Callable - from pathlib import Path + from .conftest import TemplateFileFactory # ── render_template basic ──────────────────────────────────────── @pytest.mark.unit class TestRenderTemplateBasic: - def test_render_builtin_solo_founder(self): + def test_render_builtin_solo_founder(self) -> None: loaded = load_template("solo_founder") config = render_template(loaded) assert isinstance(config, RootConfig) assert config.company_name == "My Company" assert len(config.agents) == 2 - def test_render_builtin_startup(self): + def test_render_builtin_startup(self) -> None: loaded = load_template("startup") config = render_template(loaded) assert isinstance(config, RootConfig) assert config.company_name == "Startup Co" assert len(config.agents) == 5 - def test_render_all_builtins_produce_valid_root_config(self): + def test_render_all_builtins_produce_valid_root_config(self) -> None: from ai_company.templates.loader import BUILTIN_TEMPLATES for name in BUILTIN_TEMPLATES: @@ -44,7 +43,7 @@ def test_render_all_builtins_produce_valid_root_config(self): assert isinstance(config, RootConfig), f"{name} failed" assert len(config.agents) >= 1, f"{name} has no agents" - def test_render_returns_frozen_config(self): + def test_render_returns_frozen_config(self) -> None: loaded = load_template("solo_founder") config = render_template(loaded) with pytest.raises(ValidationError): @@ -58,8 +57,8 @@ def test_render_returns_frozen_config(self): class TestRenderTemplateVariables: def test_default_variables_applied( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_WITH_VARIABLES_YAML) loaded = load_template_file(path) config = render_template(loaded) @@ -67,8 +66,8 @@ def test_default_variables_applied( def test_user_variables_override_defaults( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_WITH_VARIABLES_YAML) loaded = load_template_file(path) config = render_template(loaded, variables={"company_name": "Acme Inc"}) @@ -76,8 +75,8 @@ def test_user_variables_override_defaults( def test_budget_variable_applied( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_WITH_VARIABLES_YAML) loaded = load_template_file(path) config = render_template(loaded, variables={"budget": 100.0}) @@ -85,8 +84,8 @@ def test_budget_variable_applied( def test_required_variable_missing_raises_error( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_REQUIRED_VAR_YAML) loaded = load_template_file(path) with pytest.raises(TemplateRenderError, match="Required template variable"): @@ -94,8 +93,8 @@ def test_required_variable_missing_raises_error( def test_required_variable_provided( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_REQUIRED_VAR_YAML) loaded = load_template_file(path) config = render_template(loaded, variables={"team_lead": "Alice"}) @@ -103,8 +102,8 @@ def test_required_variable_provided( def test_extra_variables_passed_through( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: path = tmp_template_file(TEMPLATE_WITH_VARIABLES_YAML) loaded = load_template_file(path) # Extra variables don't cause errors. @@ -120,13 +119,13 @@ def test_extra_variables_passed_through( @pytest.mark.unit class TestRenderTemplateAgents: - def test_agents_have_unique_names(self): + def test_agents_have_unique_names(self) -> None: loaded = load_template("startup") config = render_template(loaded) names = [a.name for a in config.agents] assert len(names) == len(set(names)) - def test_agent_name_from_jinja2(self): + def test_agent_name_from_jinja2(self) -> None: loaded = load_template("solo_founder") config = render_template(loaded, variables={"company_name": "ACME"}) # The CEO agent's name is "{{ company_name }} CEO" → "ACME CEO". @@ -134,7 +133,7 @@ def test_agent_name_from_jinja2(self): assert len(ceo_agents) == 1 assert "ACME" in ceo_agents[0].name - def test_auto_name_for_unnamed_agents(self): + def test_auto_name_for_unnamed_agents(self) -> None: loaded = load_template("research_lab") config = render_template(loaded) # research_lab agents don't have explicit names. @@ -148,12 +147,12 @@ def test_auto_name_for_unnamed_agents(self): @pytest.mark.unit class TestRenderTemplateDepartments: - def test_departments_included(self): + def test_departments_included(self) -> None: loaded = load_template("startup") config = render_template(loaded) assert len(config.departments) >= 1 - def test_department_names(self): + def test_department_names(self) -> None: loaded = load_template("solo_founder") config = render_template(loaded) dept_names = {d.name for d in config.departments} @@ -167,8 +166,8 @@ def test_department_names(self): class TestRenderTemplateErrors: def test_invalid_jinja2_raises_render_error( self, - tmp_template_file: Callable[[str, str], Path], - ): + tmp_template_file: TemplateFileFactory, + ) -> None: bad_yaml = """\ template: name: "Bad Jinja" diff --git a/tests/unit/templates/test_schema.py b/tests/unit/templates/test_schema.py index 05c5d65937..96e00c8b76 100644 --- a/tests/unit/templates/test_schema.py +++ b/tests/unit/templates/test_schema.py @@ -1,5 +1,7 @@ """Tests for template schema models.""" +from typing import TYPE_CHECKING, Any + import pytest from pydantic import ValidationError @@ -12,12 +14,17 @@ TemplateVariable, ) +if TYPE_CHECKING: + from collections.abc import Callable + +pytestmark = pytest.mark.timeout(30) + # ── TemplateVariable ───────────────────────────────────────────── @pytest.mark.unit class TestTemplateVariable: - def test_valid_minimal(self): + def test_valid_minimal(self) -> None: v = TemplateVariable(name="my_var") assert v.name == "my_var" assert v.description == "" @@ -25,7 +32,7 @@ def test_valid_minimal(self): assert v.default is None assert v.required is False - def test_valid_full(self): + def test_valid_full(self) -> None: v = TemplateVariable( name="budget", description="Monthly budget", @@ -36,24 +43,24 @@ def test_valid_full(self): assert v.var_type == "float" assert v.default == 50.0 - def test_blank_name_rejected(self): + def test_blank_name_rejected(self) -> None: with pytest.raises(ValidationError): TemplateVariable(name="") - def test_whitespace_name_rejected(self): + def test_whitespace_name_rejected(self) -> None: with pytest.raises(ValidationError): TemplateVariable(name=" ") - def test_required_with_default_rejected(self): + def test_required_with_default_rejected(self) -> None: with pytest.raises(ValidationError, match="required but defines a default"): TemplateVariable(name="x", required=True, default="oops") - def test_required_without_default_accepted(self): + def test_required_without_default_accepted(self) -> None: v = TemplateVariable(name="x", required=True) assert v.required is True assert v.default is None - def test_frozen(self): + def test_frozen(self) -> None: v = TemplateVariable(name="x") with pytest.raises(ValidationError): v.name = "y" # type: ignore[misc] @@ -64,7 +71,7 @@ def test_frozen(self): @pytest.mark.unit class TestTemplateAgentConfig: - def test_valid_minimal(self): + def test_valid_minimal(self) -> None: a = TemplateAgentConfig(role="Backend Developer") assert a.role == "Backend Developer" assert a.name == "" @@ -73,11 +80,11 @@ def test_valid_minimal(self): assert a.personality_preset is None assert a.department is None - def test_valid_full(self): + def test_valid_full(self) -> None: a = TemplateAgentConfig( role="CEO", name="{{ company_name }} CEO", - level="c_suite", + level=SeniorityLevel.C_SUITE, model="opus", personality_preset="visionary_leader", department="executive", @@ -85,7 +92,7 @@ def test_valid_full(self): assert a.level == SeniorityLevel.C_SUITE assert a.personality_preset == "visionary_leader" - def test_blank_role_rejected(self): + def test_blank_role_rejected(self) -> None: with pytest.raises(ValidationError): TemplateAgentConfig(role="") @@ -95,13 +102,13 @@ def test_blank_role_rejected(self): @pytest.mark.unit class TestTemplateDepartmentConfig: - def test_valid_minimal(self): + def test_valid_minimal(self) -> None: d = TemplateDepartmentConfig(name="engineering") assert d.name == "engineering" assert d.budget_percent == 0.0 assert d.head_role is None - def test_valid_full(self): + def test_valid_full(self) -> None: d = TemplateDepartmentConfig( name="engineering", budget_percent=60.0, @@ -110,15 +117,15 @@ def test_valid_full(self): assert d.budget_percent == 60.0 assert d.head_role == "CTO" - def test_budget_percent_negative_rejected(self): + def test_budget_percent_negative_rejected(self) -> None: with pytest.raises(ValidationError): TemplateDepartmentConfig(name="eng", budget_percent=-1.0) - def test_budget_percent_over_100_rejected(self): + def test_budget_percent_over_100_rejected(self) -> None: with pytest.raises(ValidationError): TemplateDepartmentConfig(name="eng", budget_percent=101.0) - def test_blank_name_rejected(self): + def test_blank_name_rejected(self) -> None: with pytest.raises(ValidationError): TemplateDepartmentConfig(name="") @@ -128,20 +135,20 @@ def test_blank_name_rejected(self): @pytest.mark.unit class TestTemplateMetadata: - def test_valid_minimal(self): - m = TemplateMetadata(name="Test", company_type="custom") + def test_valid_minimal(self) -> None: + m = TemplateMetadata(name="Test", company_type=CompanyType.CUSTOM) assert m.name == "Test" assert m.company_type == CompanyType.CUSTOM assert m.min_agents == 1 assert m.max_agents == 100 assert m.tags == () - def test_valid_full(self): + def test_valid_full(self) -> None: m = TemplateMetadata( name="My Template", description="A description", version="2.0.0", - company_type="startup", + company_type=CompanyType.STARTUP, min_agents=2, max_agents=10, tags=("startup", "mvp"), @@ -149,22 +156,22 @@ def test_valid_full(self): assert m.version == "2.0.0" assert m.tags == ("startup", "mvp") - def test_min_greater_than_max_rejected(self): + def test_min_greater_than_max_rejected(self) -> None: with pytest.raises(ValidationError, match="min_agents"): TemplateMetadata( name="Bad", - company_type="custom", + company_type=CompanyType.CUSTOM, min_agents=10, max_agents=5, ) - def test_blank_name_rejected(self): + def test_blank_name_rejected(self) -> None: with pytest.raises(ValidationError): - TemplateMetadata(name="", company_type="custom") + TemplateMetadata(name="", company_type=CompanyType.CUSTOM) - def test_invalid_company_type_rejected(self): + def test_invalid_company_type_rejected(self) -> None: with pytest.raises(ValidationError): - TemplateMetadata(name="T", company_type="nonexistent_type") + TemplateMetadata(name="T", company_type="nonexistent_type") # type: ignore[arg-type] # ── CompanyTemplate ────────────────────────────────────────────── @@ -172,7 +179,10 @@ def test_invalid_company_type_rejected(self): @pytest.mark.unit class TestCompanyTemplate: - def test_valid_minimal(self, make_template_dict): + def test_valid_minimal( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: t = CompanyTemplate(**make_template_dict()) assert t.metadata.name == "Test" assert len(t.agents) == 1 @@ -181,7 +191,10 @@ def test_valid_minimal(self, make_template_dict): assert t.budget_monthly == 50.0 assert t.autonomy == 0.5 - def test_agent_count_below_min_rejected(self, make_template_dict): + def test_agent_count_below_min_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: with pytest.raises(ValidationError, match="minimum"): CompanyTemplate( **make_template_dict( @@ -194,7 +207,10 @@ def test_agent_count_below_min_rejected(self, make_template_dict): ) ) - def test_agent_count_above_max_rejected(self, make_template_dict): + def test_agent_count_above_max_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: agents = tuple({"role": f"Dev{i}", "level": "mid"} for i in range(5)) with pytest.raises(ValidationError, match="maximum"): CompanyTemplate( @@ -208,7 +224,10 @@ def test_agent_count_above_max_rejected(self, make_template_dict): ) ) - def test_duplicate_variable_names_rejected(self, make_template_dict): + def test_duplicate_variable_names_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: with pytest.raises(ValidationError, match="Duplicate variable names"): CompanyTemplate( **make_template_dict( @@ -219,7 +238,10 @@ def test_duplicate_variable_names_rejected(self, make_template_dict): ) ) - def test_duplicate_department_names_rejected(self, make_template_dict): + def test_duplicate_department_names_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: with pytest.raises(ValidationError, match="Duplicate department names"): CompanyTemplate( **make_template_dict( @@ -230,7 +252,10 @@ def test_duplicate_department_names_rejected(self, make_template_dict): ) ) - def test_unique_variables_accepted(self, make_template_dict): + def test_unique_variables_accepted( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: t = CompanyTemplate( **make_template_dict( variables=( @@ -241,15 +266,24 @@ def test_unique_variables_accepted(self, make_template_dict): ) assert len(t.variables) == 2 - def test_autonomy_out_of_range_rejected(self, make_template_dict): + def test_autonomy_out_of_range_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: with pytest.raises(ValidationError): CompanyTemplate(**make_template_dict(autonomy=1.5)) - def test_negative_budget_rejected(self, make_template_dict): + def test_negative_budget_rejected( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: with pytest.raises(ValidationError): CompanyTemplate(**make_template_dict(budget_monthly=-10.0)) - def test_frozen(self, make_template_dict): + def test_frozen( + self, + make_template_dict: Callable[..., dict[str, Any]], + ) -> None: t = CompanyTemplate(**make_template_dict()) with pytest.raises(ValidationError): t.workflow = "scrum" # type: ignore[misc] diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index ec380a5f0e..f5e3a2ccdc 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -32,7 +32,7 @@ def test_version_format() -> None: @pytest.mark.unit def test_markers_registered(pytestconfig: pytest.Config) -> None: """Verify custom markers are registered in pyproject.toml.""" - raw_markers: list[str] = pytestconfig.getini("markers") # type: ignore[assignment] + raw_markers: list[str] = pytestconfig.getini("markers") marker_names = {m.split(":")[0].strip() for m in raw_markers} expected = {"unit", "integration", "e2e", "slow"} missing = expected - marker_names