Metadata-Version: 2.4
Name: py-multi-agent-search
Version: 0.1.0
Summary: Async Python library for querying multiple search providers in parallel
Project-URL: Homepage, https://github.com/paperfoot/search-cli
Project-URL: Repository, https://github.com/paperfoot/search-cli
Author: paperfoot
License-Expression: MIT
License-File: LICENSE
Keywords: async,brave,exa,jina,perplexity,search,serper,tavily
Classifier: Framework :: AsyncIO
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic-settings>=2.0
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.22; extra == 'dev'
Description-Content-Type: text/markdown

# search-cli (Python)

Python port of search-cli. Async library for querying multiple search providers in parallel with result deduplication.

## Requirements

- Python 3.11+

## Installation

```bash
pip install -e .

# With dev dependencies (pytest, respx)
pip install -e ".[dev]"
```

## Configuration

Set API keys as environment variables. Only providers with configured keys are used.

| Variable | Provider |
|----------|----------|
| `BRAVE_API_KEY` | Brave Search |
| `SERPER_API_KEY` | Serper (Google) |
| `EXA_API_KEY` | Exa (neural search) |
| `JINA_API_KEY` | Jina |
| `TAVILY_API_KEY` | Tavily |
| `PERPLEXITY_API_KEY` | Perplexity |

## Usage

```python
import asyncio
from search_cli import search, Mode

async def main():
    # General web search
    response = await search("python asyncio tutorial")
    for r in response.results:
        print(r.title, r.url)

    # News search
    response = await search("AI news", mode=Mode.NEWS, count=10)

    # Deep search (maximum coverage)
    response = await search("quantum computing", mode=Mode.DEEP)

    # JSON output
    print(response.model_dump_json(indent=2))

asyncio.run(main())
```

### Modes

| Mode | Providers |
|------|-----------|
| `Mode.GENERAL` | brave, serper, exa, jina, tavily, perplexity |
| `Mode.NEWS` | brave, serper, tavily, perplexity |
| `Mode.DEEP` | brave, exa, serper, tavily, perplexity |

### Advanced: Manage context lifecycle

```python
from search_cli import search, Mode, SearchContext, AppConfig

async def main():
    # Reuse a single httpx client across multiple searches
    async with SearchContext(config=AppConfig()) as ctx:
        r1 = await search("query one", ctx=ctx)
        r2 = await search("query two", mode=Mode.NEWS, ctx=ctx)
```

## Public API

| Symbol | Type | Description |
|--------|------|-------------|
| `search()` | `async function` | Main entry point — query, mode, count, opts, ctx |
| `Mode` | `Enum` | GENERAL, NEWS, DEEP |
| `SearchResult` | `BaseModel` | title, url, snippet, source, published?, image_url?, extra? |
| `SearchResponse` | `BaseModel` | version, status, query, mode, results, metadata |
| `SearchOpts` | `BaseModel` | include_domains, exclude_domains, freshness |
| `AppConfig` | `BaseSettings` | keys (ApiKeys), timeout (10s), count (5) |
| `ApiKeys` | `BaseSettings` | 6 provider API keys, loaded from env vars |
| `SearchContext` | `class` | Async context manager holding httpx client + config |
| `SearchError` | `Exception` | Base exception with code, message, suggestion |

## Architecture

```
src/search_cli/
├── __init__.py          # Public exports
├── enums.py             # Mode enum
├── models.py            # Pydantic models (SearchResult, SearchResponse, etc.)
├── config.py            # ApiKeys + AppConfig (pydantic-settings, env vars)
├── context.py           # SearchContext (httpx.AsyncClient lifecycle)
├── engine.py            # Parallel search orchestration, URL dedup
├── errors.py            # SearchError hierarchy
└── providers/
    ├── __init__.py      # Registry: PROVIDERS_FOR_MODE, build_providers()
    ├── base.py          # BaseProvider ABC (resolve_key, retry_request)
    ├── brave.py         # Brave Search API
    ├── serper.py        # Serper (Google Search) API
    ├── exa.py           # Exa neural search API
    ├── jina.py          # Jina search API
    ├── tavily.py        # Tavily search API
    └── perplexity.py    # Perplexity chat completions API
```

### How it works

1. `search()` receives a query + mode
2. `providers_for_mode()` selects providers mapped to that mode, filtered to those with configured API keys
3. `asyncio.TaskGroup` launches all provider searches in parallel, each with per-provider timeout
4. Individual provider failures are caught and recorded (other providers continue)
5. Results are deduplicated by normalized URL (first occurrence wins)
6. `SearchResponse` is returned with results + metadata (timing, provider stats)

## Testing

```bash
pytest -v
```

23 tests covering models, engine, and all 6 providers. All tests use `respx` for HTTP mocking — no real API keys required.

## Dependencies

| Package | Purpose |
|---------|---------|
| `httpx` | Async HTTP client (maps to Rust reqwest) |
| `pydantic` | Data models + JSON serialization (maps to Rust serde) |
| `pydantic-settings` | Config from env vars (maps to Rust figment) |

### Dev only

| Package | Purpose |
|---------|---------|
| `pytest` | Test runner |
| `pytest-asyncio` | Async test support |
| `respx` | httpx request mocking |
