""" Tests for type annotation correctness across the crawl4ai public API. Catches issues like #1898 where arun() was annotated as returning RunManyReturn (includes AsyncGenerator) but actually always returns CrawlResultContainer. These tests use static analysis (inspect + typing introspection) to verify return type annotations match actual behavior — without needing pyright/mypy installed in CI. """ import asyncio import collections.abc import inspect import typing from typing import Union, AsyncGenerator, get_type_hints import pytest # ── Return type annotation tests ───────────────────────────────────────── class TestReturnTypeAnnotations: """Verify that public method return types are correctly annotated.""" def _get_return_annotation(self, cls, method_name): """Get the return type annotation for a method.""" method = getattr(cls, method_name) hints = typing.get_type_hints(method) return hints.get("return") def _annotation_contains(self, annotation, target_type): """Check if a type annotation contains a specific type (including in unions). Handles both typing.AsyncGenerator and collections.abc.AsyncGenerator, since typing generics have __origin__ pointing to the abc class. """ if annotation is target_type: return True origin = getattr(annotation, "__origin__", None) if origin is target_type: return True if origin is Union: return any( self._annotation_contains(arg, target_type) for arg in annotation.__args__ ) return False def test_arun_does_not_include_async_generator(self): """ #1898: arun() always returns CrawlResultContainer, never AsyncGenerator. The return type should NOT include AsyncGenerator. """ from crawl4ai import AsyncWebCrawler ret = self._get_return_annotation(AsyncWebCrawler, "arun") assert ret is not None, "arun() has no return type annotation" # Check both typing.AsyncGenerator and collections.abc.AsyncGenerator # (typing generics have __origin__ = collections.abc.AsyncGenerator) has_async_gen = ( self._annotation_contains(ret, collections.abc.AsyncGenerator) or self._annotation_contains(ret, AsyncGenerator) ) assert not has_async_gen, ( f"arun() return type includes AsyncGenerator but arun() never yields. " f"Current annotation: {ret}. " f"Should be CrawlResultContainer or CrawlResult." ) def test_arun_many_return_type_exists(self): """arun_many() should have a return type annotation.""" from crawl4ai import AsyncWebCrawler ret = self._get_return_annotation(AsyncWebCrawler, "arun_many") assert ret is not None, "arun_many() has no return type annotation" def test_aprocess_html_return_type_exists(self): """aprocess_html() should have a return type annotation.""" from crawl4ai import AsyncWebCrawler ret = self._get_return_annotation(AsyncWebCrawler, "aprocess_html") assert ret is not None, "aprocess_html() has no return type annotation" # ── Parameter annotation tests ─────────────────────────────────────────── class TestParameterAnnotations: """Verify that public method parameters have type annotations.""" def _get_untyped_params(self, cls, method_name, ignore=("self", "kwargs")): """Find parameters without type annotations.""" method = getattr(cls, method_name) sig = inspect.signature(method) untyped = [] for name, param in sig.parameters.items(): if name in ignore: continue if param.annotation is inspect.Parameter.empty: untyped.append(name) return untyped def test_arun_params_annotated(self): """arun() public params should have type annotations.""" from crawl4ai import AsyncWebCrawler untyped = self._get_untyped_params(AsyncWebCrawler, "arun") # Allow **kwargs to be untyped, but core params should be typed assert "url" not in untyped, "arun(url=...) missing type annotation" assert "config" not in untyped, "arun(config=...) missing type annotation" def test_arun_many_params_annotated(self): """arun_many() public params should have type annotations.""" from crawl4ai import AsyncWebCrawler untyped = self._get_untyped_params(AsyncWebCrawler, "arun_many") assert "urls" not in untyped, "arun_many(urls=...) missing type annotation" # ── Consistency tests ──────────────────────────────────────────────────── class TestAnnotationConsistency: """Verify that annotations match actual runtime behavior.""" def test_arun_actually_returns_what_annotation_says(self): """ Whatever arun()'s return annotation says, verify that CrawlResultContainer is compatible with it. """ from crawl4ai import AsyncWebCrawler from crawl4ai.models import CrawlResultContainer hints = typing.get_type_hints(AsyncWebCrawler.arun) ret = hints.get("return") if ret is None: pytest.skip("No return annotation to check") # Get all types in the annotation origin = getattr(ret, "__origin__", None) if origin is Union: allowed_types = [ getattr(arg, "__origin__", arg) for arg in ret.__args__ ] else: allowed_types = [getattr(ret, "__origin__", ret)] # CrawlResultContainer should be in the allowed types is_compatible = any( t is CrawlResultContainer or (isinstance(t, type) and issubclass(CrawlResultContainer, t)) for t in allowed_types ) assert is_compatible, ( f"arun() returns CrawlResultContainer at runtime, but annotation {ret} " f"doesn't include it. Allowed types: {allowed_types}" ) def test_config_classes_init_params_match_attributes(self): """ CrawlerRunConfig.__init__ params should become attributes. Catches cases where a param is added to __init__ but not stored. """ from crawl4ai import CrawlerRunConfig sig = inspect.signature(CrawlerRunConfig.__init__) config = CrawlerRunConfig() missing = [] for name in sig.parameters: if name == "self": continue if not hasattr(config, name): missing.append(name) assert not missing, ( f"CrawlerRunConfig.__init__ has params that aren't stored as attributes: {missing}" ) # ── Public API export tests ────────────────────────────────────────────── class TestPublicAPIExports: """Verify that types referenced in public annotations are importable.""" def test_crawl_result_importable(self): from crawl4ai import CrawlResult assert CrawlResult is not None def test_crawl_result_container_importable(self): from crawl4ai.models import CrawlResultContainer assert CrawlResultContainer is not None def test_run_many_return_importable(self): from crawl4ai.models import RunManyReturn assert RunManyReturn is not None def test_markdown_generation_result_importable(self): from crawl4ai import MarkdownGenerationResult assert MarkdownGenerationResult is not None