diff --git a/.env.example b/.env.example index 64351df1..712edcb0 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ # LLM_BASE_URL=http://host.docker.internal:8000/v1 # OPENAI_API_KEY=dummy # ANTHROPIC_API_KEY=sk-ant-... # set instead of OPENAI_API_KEY for Anthropic -# TAVILY_API_KEY=tvly-... # web-search fallback (local models only) +# TURNSTONE_SEARXNG_URL=http://searxng:8080 # web_search backend (default: bundled service; set to an external SearxNG) # MODEL= # default model alias # -- Secrets ------------------------------------------------------------------ diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a90f41..ac5c9f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ Three release tracks are maintained: ### Added +- **Self-hosted SearxNG web search** — the `web_search` tool's backend for + local/vLLM models is now a bundled [SearxNG](https://searxng.org) service + (`searxng` in both compose stacks; internal docker network only, JSON API + enabled, rate limiter off). Two new settings configure it: `tools.searxng_url` + (default `http://searxng:8080`, env `TURNSTONE_SEARXNG_URL`) and + `tools.searxng_engines` (env `TURNSTONE_SEARXNG_ENGINES`). Commercial providers + (Anthropic, OpenAI) continue to use their own native server-side search and + never touch SearxNG; the `mcp:server:tool` backend is unchanged. A persistent + `searxng-cache` volume keeps its favicon/internal cache across restarts, and + Caddy can serve SearxNG's own web UI on a dedicated port (dev stack: + `https://localhost:8444`, localhost-only; production: opt-in). See + [docs/docker.md](docs/docker.md) for the AGPL-3.0 §13 note that applies to + operators who expose the bundled SearxNG publicly. + - **`turnstone-admin` reads `config.toml`** — the admin CLI now honors the same `[database]` section that `turnstone-server` does, with the same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`). @@ -26,6 +40,24 @@ Three release tracks are maintained: silently dropped these. A new `--config PATH` flag mirrors the one already on `turnstone-server`. +### Changed + +- **`tools.web_search_backend` accepted values** *(BREAKING)* — now `""` + (auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and `"ddg"` + values are gone; a config still set to either disables web search and logs a + warning. Auto-detect resolves to SearxNG when `searxng_url` is set, otherwise + no client (the `web_search` tool is dropped for models without native search). + +### Removed + +- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)* — replaced by the + bundled self-hosted SearxNG service (see Added). Removed: the + `tools.tavily_api_key` setting, the `$TAVILY_API_KEY` env var, the + `[api].tavily_key` config key, and the `ddg` install extra (the `ddgs` + dependency). Migration: use the bundled SearxNG (it ships in the compose stacks + by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No + database migration required. + ### Security - **Permissive `config.toml` now warns** — `turnstone.core.config.load_config` diff --git a/compose.yaml b/compose.yaml index 796adbe3..c72457c7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -59,6 +59,7 @@ volumes: postgres-data: caddy-data: caddy-config: + searxng-cache: # -- Shared values (scalar anchors) ------------------------------------------- # Defined once here, referenced (*alias) by every service so the dev defaults @@ -168,6 +169,8 @@ services: - console ports: - "${CONSOLE_HTTPS_PORT:-8443}:443" + # SearxNG web UI — localhost-only (it has no auth). Browse https://localhost:8444. + - "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444" volumes: - ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data # persist Caddy's local CA across restarts @@ -206,6 +209,29 @@ services: condition: service_healthy restart: unless-stopped + # ------------------------------------------------------------------- + # searxng — self-hosted metasearch backing the web_search tool. + # Internal-network only (no published port): nodes reach it at + # http://searxng:8080. Config (JSON output on, limiter off) lives in + # turnstone/deploy/searxng/settings.yml, mounted read-only. Commercial + # models use native provider search and never hit this; it serves + # local/vLLM models. Override the tag with SEARXNG_IMAGE_TAG in .env. + # ------------------------------------------------------------------- + searxng: + image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest} + volumes: + - ./turnstone/deploy/searxng:/etc/searxng:ro + - searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts) + networks: + - turnstone-net + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + # =================================================================== # Server fleet — node-1 … node-10 # @@ -243,7 +269,9 @@ services: # Bootstrap LLM defaults — real backends are configured in the console UI. LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1} OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} - TAVILY_API_KEY: ${TAVILY_API_KEY:-} + # web_search backend. Defaults to the bundled searxng service; point at an + # external SearxNG by setting TURNSTONE_SEARXNG_URL in .env (empty disables). + TURNSTONE_SEARXNG_URL: ${TURNSTONE_SEARXNG_URL:-http://searxng:8080} MODEL: ${MODEL:-} MCP_CONFIG: ${MCP_CONFIG:-} SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} @@ -256,6 +284,8 @@ services: depends_on: postgres: condition: service_healthy + searxng: + condition: service_healthy healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s diff --git a/deploy/openshell/turnstone-policy.yaml b/deploy/openshell/turnstone-policy.yaml index 23d1f1ba..f45e14dd 100644 --- a/deploy/openshell/turnstone-policy.yaml +++ b/deploy/openshell/turnstone-policy.yaml @@ -103,13 +103,18 @@ network_policies: - path: /usr/bin/python3* - path: /usr/local/bin/python3* - # --- Web search fallback (Tavily) --- + # --- Web search (SearxNG) --- + # Turnstone talks only to its SearxNG instance over HTTP; SearxNG itself makes + # the outbound calls to search engines (and is NOT governed by this policy — + # it runs as a separate service). The host/port below is the bundled compose + # service name; if your SearxNG runs elsewhere, set it to match + # TURNSTONE_SEARXNG_URL. - tavily_api: - name: tavily-search + searxng: + name: searxng-search endpoints: - - host: api.tavily.com - port: 443 + - host: searxng + port: 8080 binaries: - path: /usr/bin/python3* - path: /usr/local/bin/python3* diff --git a/docs/architecture.md b/docs/architecture.md index 0d600d42..47f303a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -474,7 +474,7 @@ from each schema and builds: - `edit_file` -- string replacement in an existing file (requires prior `read_file`) - `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`) - `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`) -- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models) +- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models) - `notify` -- send a user-facing notification (Discord/Slack, optional reply routing) - `watch` -- schedule a recurring poll with condition DSL @@ -650,7 +650,7 @@ annotations are formatted as footnotes. Extended prompt cache retention (`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no additional cost. Cached token counts are extracted from `usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get -permissive defaults with `supports_vision=False` and use Tavily for web search. +permissive defaults with `supports_vision=False` and use SearxNG for web search. **AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to Anthropic content blocks, maps `system`/`developer` roles to the `system` diff --git a/docs/diagrams/04-conversation-turn.puml b/docs/diagrams/04-conversation-turn.puml index 1d76a576..09962dd4 100644 --- a/docs/diagrams/04-conversation-turn.puml +++ b/docs/diagrams/04-conversation-turn.puml @@ -142,7 +142,7 @@ group loop [while tool_calls present] task/plan → _run_agent() sub-loop math → sandboxed subprocess web_fetch → httpx + LLM summarize - web_search → provider-native or Tavily fallback + web_search → provider-native or SearxNG fallback memory/recall → SQLite end note diff --git a/docs/diagrams/05-tool-pipeline.puml b/docs/diagrams/05-tool-pipeline.puml index 3b330887..f1992b91 100644 --- a/docs/diagrams/05-tool-pipeline.puml +++ b/docs/diagrams/05-tool-pipeline.puml @@ -113,7 +113,7 @@ partition "Phase 3: Execute" #E3F2FD { ├─ _exec_math: sandboxed subprocess ├─ _exec_man: man/info subprocess ├─ _exec_web_fetch: httpx.get + LLM summary - ├─ _exec_web_search: Tavily API POST (fallback for local models) + ├─ _exec_web_search: SearxNG JSON GET (fallback for local models) ├─ _exec_tool_search: BM25 search + expand_visible() ├─ _exec_task: _run_agent(TASK_AGENT_TOOLS) ├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only) diff --git a/docs/diagrams/png/04-conversation-turn.png b/docs/diagrams/png/04-conversation-turn.png index da47e704..817de57f 100644 --- a/docs/diagrams/png/04-conversation-turn.png +++ b/docs/diagrams/png/04-conversation-turn.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c14dfbb2db8dcb22cd332b2cf0e53ba75141adb213dae47dd9dbfd389ed482fe -size 354799 +oid sha256:d9c7769a600c38e6387390e6c42db8152e0f80c31d17b2218f7f636b71c7b868 +size 355459 diff --git a/docs/diagrams/png/05-tool-pipeline.png b/docs/diagrams/png/05-tool-pipeline.png index 26bc06ca..ddd52a68 100644 --- a/docs/diagrams/png/05-tool-pipeline.png +++ b/docs/diagrams/png/05-tool-pipeline.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e -size 281519 +oid sha256:23ca090b5656baaf70820cbe4ab6c27f0a3a02e18b4db0695614cf9489c23980 +size 281440 diff --git a/docs/docker.md b/docs/docker.md index 2ef9f150..d9f12ac4 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -147,7 +147,8 @@ overrides. |----------|---------|-------------| | `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | Bootstrap OpenAI-compatible API URL (real backends go in the UI) | | `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) | -| `TAVILY_API_KEY` | — | Web-search fallback (only for local/vLLM models; Anthropic/OpenAI use native search) | +| `TURNSTONE_SEARXNG_URL` | `http://searxng:8080` | SearxNG URL for the `web_search` tool (local/vLLM models only; Anthropic/OpenAI use native search). Defaults to the bundled `searxng` service; set to an external instance's URL. To turn web search off, clear `tools.searxng_url` in the admin Settings tab. | +| `SEARXNG_IMAGE_TAG` | `latest` | Tag for the bundled `searxng/searxng` image | | `MODEL` | — | Override the default model alias | ### Auth & database @@ -172,12 +173,14 @@ overrides. ### Ports -Both stacks publish the same two host ports (everything else is reached through -Caddy or proxied by the console): +Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally +publishes the SearxNG UI on localhost. Everything else is reached through Caddy or +proxied by the console: | Variable | Default | Description | |----------|---------|-------------| | `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) | +| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) | | `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) | | `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access | @@ -193,6 +196,39 @@ Caddy or proxied by the console): The channel runs HTTP-only with no adapters until a token is set, so it's safe to leave running. See [Channel Integrations](channels.md) for app setup. +### Web search (SearxNG) + +The `web_search` tool for local/vLLM models is backed by a self-hosted +[SearxNG](https://searxng.org) metasearch service, bundled into both stacks as the +`searxng` service. The Turnstone nodes reach it over the internal docker network at +`http://searxng:8080` — its API port is **not** published. Its config — +[`turnstone/deploy/searxng/settings.yml`](../turnstone/deploy/searxng/settings.yml), +mounted read-only — enables the JSON API and leaves the rate limiter off (the +limiter would need a separate Valkey/Redis instance). A `searxng-cache` volume +persists its favicon + internal cache across restarts. Commercial providers +(Anthropic, OpenAI) use their own native search and never touch this service. + +Point at an existing SearxNG instead of the bundled one with `TURNSTONE_SEARXNG_URL`, +or narrow the engines via `tools.searxng_engines` in the admin Settings tab (e.g. +`duckduckgo,wikipedia`). + +**SearxNG web UI.** Caddy can also serve SearxNG's own search/Preferences UI on a +dedicated port. The dev stack publishes it at **`https://localhost:8444`** bound to +localhost only; the production stack does **not** publish it by default (uncomment +the `8444` port on the `caddy` service to opt in). Change the port with +`SEARXNG_HTTPS_PORT`. **SearxNG has no authentication** — never bind this to a public +interface, or anyone who can reach it can search through your instance. + +> **AGPL note for operators.** SearxNG is licensed AGPL-3.0. Kept on the internal +> network (or bound to localhost), no external user interacts with it — so the AGPL +> §13 (remote network interaction) source-offer obligation does not attach. If you +> publish SearxNG to remote users (bind its port to a public interface, or front it +> with your own reverse proxy) you become the operator of a network-reachable AGPL +> service and must offer its corresponding source; that is trivially satisfied by +> linking to upstream . Turnstone's own license is +> unaffected: it talks to SearxNG over HTTP as a separate process (mere aggregation), +> not by linking. + ### Other | Variable | Default | Description | diff --git a/docs/openshell.md b/docs/openshell.md index 36a61f10..08330e6e 100644 --- a/docs/openshell.md +++ b/docs/openshell.md @@ -94,7 +94,7 @@ cannot bypass the proxy. |--------|-------|---------| | `openai_api` | `api.openai.com` | OpenAI LLM API | | `anthropic_api` | `api.anthropic.com` | Anthropic LLM API | -| `tavily_api` | `api.tavily.com` | Web search fallback | +| `searxng` | `searxng:8080` (bundled service) | Web search backend | | `skills_registry` | `skills.sh` | Skill discovery | | `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API | | `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery | diff --git a/docs/tools.md b/docs/tools.md index ef778ac6..d66adb1f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -132,7 +132,7 @@ Each item's `execute` callable is invoked: - `edit_file` -- modifies file content - `math` -- sandboxed computation (confirmation required despite being sandboxed) - `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests) -- `web_search` -- web search via Tavily API (makes network requests) +- `web_search` -- web search via self-hosted SearxNG (makes network requests) - `task` -- spawns an autonomous sub-agent - `plan` -- spawns a planning sub-agent, plus post-execution review gate @@ -344,12 +344,12 @@ Search the web using a text query. |---------------|---------|----------|-------------| | `query` | string | yes | The search query. | | `max_results` | integer | no | Max results to return (default 5, max 20). | -| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). | +| `topic` | string | no | Search topic: `general` or `news` (default `general`). | - **What it does**: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available: - - **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed. + - **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No backend needed. - **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations. - - **Local/vLLM models**: Falls back to the Tavily API. Requires `tavily_key` in `config.toml` or `$TAVILY_API_KEY`. + - **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`. - **Auto-approve**: Yes (auto-approved for all tool dispatch paths). - **Agent availability**: `agent` and `task_agent`. diff --git a/pyproject.toml b/pyproject.toml index b4849c34..b8fb8ff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,12 +50,11 @@ dev = ["ruff>=0.9", "mypy>=1.14"] console = ["croniter>=3.0"] anthropic = ["anthropic>=0.39"] postgres = ["psycopg[binary]>=3.2"] -ddg = ["ddgs>=9.0"] discord = ["discord.py>=2.4"] tls = ["lacme>=1.0.5"] sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"] slack = ["slack-bolt>=1.18", "aiohttp>=3.9"] -all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox,slack]"] +all = ["turnstone[console,anthropic,postgres,discord,tls,sandbox,slack]"] [project.scripts] turnstone = "turnstone.cli:main" @@ -89,6 +88,7 @@ include = [ "turnstone/sdk/py.typed", "turnstone/deploy/*.yaml", "turnstone/deploy/Caddyfile", + "turnstone/deploy/searxng/settings.yml", ] [tool.pytest.ini_options] @@ -172,10 +172,6 @@ ignore_missing_imports = true module = ["frontmatter", "frontmatter.*"] ignore_missing_imports = true -[[tool.mypy.overrides]] -module = ["ddgs", "ddgs.*"] -ignore_missing_imports = true - [[tool.mypy.overrides]] module = ["lacme", "lacme.*"] ignore_missing_imports = true diff --git a/tests/test_config.py b/tests/test_config.py index 2a5c3701..b2e86f93 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -184,35 +184,77 @@ def test_apply_config_model_section(tmp_path): assert args.temperature == 0.3 -def test_tavily_key_from_config(tmp_path, monkeypatch): - """get_tavily_key() reads from config.toml [api] tavily_key.""" +def _reset_searxng_cache(): + config_mod._searxng_url = None + config_mod._searxng_url_loaded = False + config_mod._searxng_engines = None + config_mod._searxng_engines_loaded = False + + +def test_searxng_url_from_config(tmp_path, monkeypatch): + """get_searxng_url() reads from config.toml [tools] searxng_url.""" _reset_cache() - config_mod._tavily_key = None - config_mod._tavily_key_loaded = False + _reset_searxng_cache() cfg = tmp_path / "config.toml" - cfg.write_text('[api]\ntavily_key = "tvly-from-config"\n') + cfg.write_text('[tools]\nsearxng_url = "http://searx.local:8080"\n') set_config_path(str(cfg)) - monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("TURNSTONE_SEARXNG_URL", raising=False) - key = config_mod.get_tavily_key() - assert key == "tvly-from-config" + assert config_mod.get_searxng_url() == "http://searx.local:8080" -def test_tavily_key_fallback_to_env(tmp_path, monkeypatch): - """get_tavily_key() falls back to $TAVILY_API_KEY env var.""" +def test_searxng_url_fallback_to_env(tmp_path, monkeypatch): + """get_searxng_url() falls back to $TURNSTONE_SEARXNG_URL.""" _reset_cache() - config_mod._tavily_key = None - config_mod._tavily_key_loaded = False + _reset_searxng_cache() - # Config exists but no tavily_key in it + # Config exists but no searxng_url in it cfg = tmp_path / "config.toml" - cfg.write_text("[api]\n") + cfg.write_text("[tools]\n") set_config_path(str(cfg)) - monkeypatch.setenv("TAVILY_API_KEY", "tvly-from-env") + monkeypatch.setenv("TURNSTONE_SEARXNG_URL", "http://env-searx:8080") - key = config_mod.get_tavily_key() - assert key == "tvly-from-env" + assert config_mod.get_searxng_url() == "http://env-searx:8080" + + +def test_searxng_url_none_when_unset(tmp_path, monkeypatch): + """get_searxng_url() returns None when neither config nor env is set.""" + _reset_cache() + _reset_searxng_cache() + + cfg = tmp_path / "config.toml" + cfg.write_text("[tools]\n") + set_config_path(str(cfg)) + monkeypatch.delenv("TURNSTONE_SEARXNG_URL", raising=False) + + assert config_mod.get_searxng_url() is None + + +def test_searxng_engines_config_wins_over_env(tmp_path, monkeypatch): + """get_searxng_engines(): config.toml [tools] searxng_engines wins over env.""" + _reset_cache() + _reset_searxng_cache() + + cfg = tmp_path / "config.toml" + cfg.write_text('[tools]\nsearxng_engines = "duckduckgo,wikipedia"\n') + set_config_path(str(cfg)) + monkeypatch.setenv("TURNSTONE_SEARXNG_ENGINES", "ignored") + + assert config_mod.get_searxng_engines() == "duckduckgo,wikipedia" + + +def test_searxng_engines_default_empty(tmp_path, monkeypatch): + """get_searxng_engines() defaults to '' when nothing is configured.""" + _reset_cache() + _reset_searxng_cache() + + cfg = tmp_path / "config.toml" + cfg.write_text("[tools]\n") + set_config_path(str(cfg)) + monkeypatch.delenv("TURNSTONE_SEARXNG_ENGINES", raising=False) + + assert config_mod.get_searxng_engines() == "" def test_apply_config_judge_section(tmp_path): diff --git a/tests/test_providers.py b/tests/test_providers.py index 5d66156e..06312082 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -2737,8 +2737,8 @@ class TestOpenAIWebSearch: assert "[Result](https://example.com)" in info_chunks[0].info_delta -class TestTavilyFallback: - """Tests for Tavily fallback when providers don't support native search.""" +class TestClientSearchFallback: + """Tests for the client-side web_search fallback when providers lack native search.""" def test_local_model_no_web_search(self) -> None: """Local/vLLM models should not have supports_web_search.""" diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 9b1b57e1..7548c276 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -895,15 +895,14 @@ class TestWebSearchGating: caps = ModelCapabilities(supports_web_search=False) with ( patch.object(session, "_get_capabilities", return_value=caps), - patch("turnstone.core.session.get_tavily_key", return_value=None), - patch("turnstone.core.web_search._ddg_available", return_value=False), + patch("turnstone.core.session.get_searxng_url", return_value=None), ): tools = session._get_active_tools() names = [t.get("function", {}).get("name") for t in tools] assert "web_search" not in names - def test_web_search_kept_when_tavily_available(self, tmp_db, mock_openai_client): + def test_web_search_kept_when_searxng_configured(self, tmp_db, mock_openai_client): from unittest.mock import patch from turnstone.core.providers._protocol import ModelCapabilities @@ -921,7 +920,7 @@ class TestWebSearchGating: caps = ModelCapabilities(supports_web_search=False) with ( patch.object(session, "_get_capabilities", return_value=caps), - patch("turnstone.core.session.get_tavily_key", return_value="tvly-test-key"), + patch("turnstone.core.session.get_searxng_url", return_value="http://searxng:8080"), ): tools = session._get_active_tools() @@ -946,13 +945,63 @@ class TestWebSearchGating: caps = ModelCapabilities(supports_web_search=True) with ( patch.object(session, "_get_capabilities", return_value=caps), - patch("turnstone.core.session.get_tavily_key", return_value=None), + patch("turnstone.core.session.get_searxng_url", return_value=None), ): tools = session._get_active_tools() names = [t.get("function", {}).get("name") for t in tools] assert "web_search" in names + def test_resolve_search_client_config_store_precedence(self, tmp_db, mock_openai_client): + """ConfigStore (admin Settings) is authoritative over the env/config + fallback: an explicit URL resolves a client; an explicit empty string + disables web search even when the env var would supply a URL; an unset + key falls through to env (storage -> toml -> env -> default).""" + from unittest.mock import patch + + from turnstone.core.web_search import SearXNGClient + + class _StubStore: + def __init__(self, values): + self._v = values + + def stored_keys(self): + return frozenset(self._v) + + def get(self, key, default=None): + return self._v.get(key, default) + + def _session(values): + return ChatSession( + client=mock_openai_client, + model="local-model", + ui=MagicMock(), + instructions=None, + temperature=0.5, + max_tokens=1000, + tool_timeout=10, + config_store=_StubStore(values), + ) + + # (a) explicit DB URL wins over a None env fallback + s_db = _session({"tools.searxng_url": "http://db-searx:8080"}) + with patch("turnstone.core.session.get_searxng_url", return_value=None): + client = s_db._resolve_search_client() + assert isinstance(client, SearXNGClient) + assert client._base_url == "http://db-searx:8080" + + # (b) explicit empty string disables, even though env would supply a URL + s_off = _session({"tools.searxng_url": ""}) + with patch("turnstone.core.session.get_searxng_url", return_value="http://env-searx:8080"): + assert s_off._resolve_search_client() is None + + # (c) unset key falls through to the env/config layer + s_env = _session({}) + with patch("turnstone.core.session.get_searxng_url", return_value="http://env-searx:8080"): + client_env = s_env._resolve_search_client() + assert isinstance(client_env, SearXNGClient) + assert client_env._base_url == "http://env-searx:8080" + class TestMCPToolGating: """MCP tools should not be offered when no MCP servers provide them.""" diff --git a/tests/test_settings_api.py b/tests/test_settings_api.py index ceecb671..c0c834b8 100644 --- a/tests/test_settings_api.py +++ b/tests/test_settings_api.py @@ -86,6 +86,28 @@ def client(storage): return TestClient(app) +@pytest.fixture +def secret_key(): + """Register a synthetic ``is_secret`` setting for the duration of a test. + + The settings registry currently has no production ``is_secret`` setting, but + the write-only / ``"***"``-sentinel masking machinery remains and must stay + covered for the next secret setting that lands. Injecting a test-only def + exercises that machinery without coupling the tests to any specific + production key. + """ + from turnstone.core import settings_registry as reg + + key = "tools.test_secret" + reg.SETTINGS[key] = reg.SettingDef( + key, "str", "", "Test-only secret setting", "tools", is_secret=True + ) + try: + yield key + finally: + reg.SETTINGS.pop(key, None) + + # --------------------------------------------------------------------------- # List settings # --------------------------------------------------------------------------- @@ -227,10 +249,10 @@ class TestSettingsSchema: by_key = {s["key"]: s for s in r.json()["schema"]} assert by_key["tools.search"]["choices"] == ["auto", "on", "off"] - def test_secret_flag(self, client): + def test_secret_flag(self, client, secret_key): r = client.get("/v1/api/admin/settings/schema") by_key = {s["key"]: s for s in r.json()["schema"]} - assert by_key["tools.tavily_api_key"]["is_secret"] is True + assert by_key[secret_key]["is_secret"] is True assert by_key["tools.timeout"]["is_secret"] is False @@ -240,11 +262,11 @@ class TestSettingsSchema: class TestSecretMasking: - def test_secret_masked_in_list(self, client, storage): + def test_secret_masked_in_list(self, client, storage, secret_key): from turnstone.core.settings_registry import serialize_value storage.upsert_system_setting( - key="tools.tavily_api_key", + key=secret_key, value=serialize_value("sk-real-secret"), node_id="", is_secret=True, @@ -252,48 +274,48 @@ class TestSecretMasking: ) r = client.get("/v1/api/admin/settings") by_key = {s["key"]: s for s in r.json()["settings"]} - assert by_key["tools.tavily_api_key"]["value"] == "***" + assert by_key[secret_key]["value"] == "***" - def test_secret_writable_via_api(self, client): + def test_secret_writable_via_api(self, client, secret_key): """Secret settings can be written via API (write-only pattern).""" r = client.put( - "/v1/api/admin/settings/tools.tavily_api_key", + f"/v1/api/admin/settings/{secret_key}", json={"value": "sk-secret-123"}, ) assert r.status_code == 200 # Response value is masked even for the write confirmation assert r.json()["value"] == "***" - def test_secret_sentinel_preserves_existing(self, client): + def test_secret_sentinel_preserves_existing(self, client, secret_key): """Submitting '***' for a secret setting is a no-op (preserve existing).""" # First write a real value r1 = client.put( - "/v1/api/admin/settings/tools.tavily_api_key", + f"/v1/api/admin/settings/{secret_key}", json={"value": "sk-real-key"}, ) assert r1.status_code == 200 # Now submit the sentinel — should return unchanged with full response shape r2 = client.put( - "/v1/api/admin/settings/tools.tavily_api_key", + f"/v1/api/admin/settings/{secret_key}", json={"value": "***"}, ) assert r2.status_code == 200 data = r2.json() assert data.get("unchanged") is True - assert data["key"] == "tools.tavily_api_key" + assert data["key"] == secret_key assert data["value"] == "***" assert data["type"] == "str" assert data["is_secret"] is True - def test_secret_still_masked_in_list(self, client): + def test_secret_still_masked_in_list(self, client, secret_key): """After writing a secret, list still shows '***'.""" client.put( - "/v1/api/admin/settings/tools.tavily_api_key", + f"/v1/api/admin/settings/{secret_key}", json={"value": "sk-written-via-api"}, ) r = client.get("/v1/api/admin/settings") by_key = {s["key"]: s for s in r.json()["settings"]} - assert by_key["tools.tavily_api_key"]["value"] == "***" + assert by_key[secret_key]["value"] == "***" # --------------------------------------------------------------------------- diff --git a/tests/test_web_search.py b/tests/test_web_search.py index dd120cb2..7048cf92 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -4,101 +4,194 @@ from __future__ import annotations from unittest.mock import MagicMock, patch +import httpx +import pytest + from turnstone.core.web_search import ( - DuckDuckGoClient, MCPSearchClient, - TavilyClient, - _format_ddg, - _format_tavily, + SearXNGClient, + _format_searxng, resolve_web_search_client, ) +# A faithful SearxNG JSON payload — shape verified against searxng/searxng:latest +# (top-level keys, per-result fields, [name, reason] unresponsive_engines pairs). +SEARXNG_JSON = { + "query": "python", + "results": [ + { + "title": "Python.org", + "url": "https://python.org", + "content": "The official home of the Python programming language.", + "engine": "duckduckgo", + "score": 3.0, + }, + { + "title": "PyPI", + "url": "https://pypi.org", + "content": "Find, install and publish Python packages.", + "engine": "brave", + "score": 2.0, + }, + ], + "answers": [{"answer": "Python is a programming language.", "engine": "wikidata"}], + "infoboxes": [{"infobox": "Python", "content": "A high-level language.", "urls": []}], + "corrections": [], + "suggestions": ["python tutorial"], + "unresponsive_engines": [], +} + + +def _mock_httpx_get(handler): + """Patch target for ``web_search.httpx.get`` that routes the call through a + real ``httpx.MockTransport``. The request flows through genuine httpx URL/ + param encoding and response parsing — a true boundary, not a bare MagicMock. + """ + client = httpx.Client(transport=httpx.MockTransport(handler)) + + def _get(url, **kwargs): + return client.get(url, **kwargs) + + return _get + + # --------------------------------------------------------------------------- -# Formatters +# Formatter # --------------------------------------------------------------------------- -class TestFormatTavily: - def test_formats_answer_and_results(self): - data = { - "answer": "Python is great", - "results": [ - {"title": "Python.org", "url": "https://python.org", "content": "Official site"}, - {"title": "PyPI", "url": "https://pypi.org", "content": "Package index"}, - ], - } - out = _format_tavily(data, "python") - assert "Answer: Python is great" in out +class TestFormatSearXNG: + def test_answer_infobox_and_results(self): + out = _format_searxng(SEARXNG_JSON, "python") + assert "Answer: Python is a programming language." in out + assert "A high-level language." in out # infobox content assert "[Python.org](https://python.org)" in out assert "[PyPI](https://pypi.org)" in out - def test_no_results(self): - out = _format_tavily({"results": []}, "nothing") - assert "No results for 'nothing'" in out - - def test_no_answer(self): - data = { - "results": [{"title": "T", "url": "http://t", "content": "C"}], - } - out = _format_tavily(data, "q") + def test_results_only(self): + data = {"results": [{"title": "T", "url": "http://t", "content": "C"}]} + out = _format_searxng(data, "q") assert "Answer:" not in out assert "[T](http://t)" in out + def test_respects_max_results(self): + data = { + "results": [ + {"title": f"T{i}", "url": f"http://t/{i}", "content": ""} for i in range(10) + ] + } + out = _format_searxng(data, "q", max_results=3) + assert "[T0]" in out and "[T2]" in out + assert "[T3]" not in out -class TestFormatDDG: - def test_formats_results(self): - results = [ - {"title": "DDG Result", "href": "https://ddg.example.com", "body": "Search body"}, - ] - out = _format_ddg(results, "test") - assert "[DDG Result](https://ddg.example.com)" in out - assert "Search body" in out + def test_answer_as_bare_string(self): + # Older SearxNG builds put bare strings in ``answers``. + data = {"answers": ["42"], "results": []} + out = _format_searxng(data, "q") + assert "Answer: 42" in out + + def test_answer_non_string_value_coerced(self): + # Some engines (calculator/wikidata) return a non-string answer value; + # the formatter must coerce it, not crash on .strip(). + data = {"answers": [{"answer": 42}], "results": []} + out = _format_searxng(data, "q") + assert "Answer: 42" in out def test_no_results(self): - out = _format_ddg([], "nothing") + out = _format_searxng({"results": []}, "nothing") assert "No results for 'nothing'" in out - -# --------------------------------------------------------------------------- -# Client tests -# --------------------------------------------------------------------------- - - -class TestTavilyClient: - def test_search_calls_api(self): - mock_resp = MagicMock() - mock_resp.json.return_value = { - "answer": "42", - "results": [{"title": "T", "url": "http://t", "content": "C"}], + def test_no_results_surfaces_unresponsive_engines(self): + data = { + "results": [], + "unresponsive_engines": [["duckduckgo", "CAPTCHA"], ["google", "timeout"]], } - with patch("turnstone.core.web_search.httpx.post", return_value=mock_resp) as mock_post: - client = TavilyClient("test-key", timeout=10) - result = client.search("meaning of life", max_results=3) - - mock_post.assert_called_once() - call_kwargs = mock_post.call_args - assert call_kwargs.kwargs["json"]["query"] == "meaning of life" - assert call_kwargs.kwargs["json"]["max_results"] == 3 - assert "Answer: 42" in result + out = _format_searxng(data, "q") + assert "No results for 'q'" in out + assert "duckduckgo" in out and "google" in out -class TestDuckDuckGoClient: - def test_integration_via_mock_ddgs(self): - """Patch the ddgs import inside DuckDuckGoClient.search.""" - mock_ddgs = MagicMock() - mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) - mock_ddgs.__exit__ = MagicMock(return_value=False) - mock_ddgs.text.return_value = [ - {"title": "DDG Result", "href": "https://ddg.co", "body": "Found it"}, - ] - mock_module = MagicMock() - mock_module.DDGS.return_value = mock_ddgs - with patch.dict("sys.modules", {"ddgs": mock_module}): - client = DuckDuckGoClient(timeout=10) - result = client.search("test query", max_results=3) - mock_ddgs.text.assert_called_once_with("test query", max_results=3) - assert "[DDG Result](https://ddg.co)" in result - assert "Found it" in result +# --------------------------------------------------------------------------- +# SearXNGClient +# --------------------------------------------------------------------------- + + +class TestSearXNGClient: + def test_request_construction_boundary(self): + """Drive a real httpx request through MockTransport and assert the + URL + query params the client builds.""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["params"] = dict(request.url.params) + return httpx.Response(200, json=SEARXNG_JSON) + + with patch("turnstone.core.web_search.httpx.get", _mock_httpx_get(handler)): + client = SearXNGClient( + "http://searxng:8080", engines="duckduckgo,wikipedia", timeout=10 + ) + out = client.search("python", max_results=2, topic="news") + + assert captured["url"].startswith("http://searxng:8080/search") + assert captured["params"]["q"] == "python" + assert captured["params"]["format"] == "json" + assert captured["params"]["categories"] == "news" # topic -> categories + assert captured["params"]["engines"] == "duckduckgo,wikipedia" + assert "[Python.org](https://python.org)" in out + + def test_general_topic_omits_categories(self): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["params"] = dict(request.url.params) + return httpx.Response(200, json={"results": []}) + + with patch("turnstone.core.web_search.httpx.get", _mock_httpx_get(handler)): + SearXNGClient("http://searxng:8080").search("q", topic="general") + + assert "categories" not in captured["params"] + + def test_no_engines_omits_param(self): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["params"] = dict(request.url.params) + return httpx.Response(200, json={"results": []}) + + with patch("turnstone.core.web_search.httpx.get", _mock_httpx_get(handler)): + SearXNGClient("http://searxng:8080", engines="").search("q") + + assert "engines" not in captured["params"] + + def test_base_url_trailing_slash_normalized(self): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return httpx.Response(200, json={"results": []}) + + with patch("turnstone.core.web_search.httpx.get", _mock_httpx_get(handler)): + SearXNGClient("http://searxng:8080/").search("q") + + assert captured["url"].startswith("http://searxng:8080/search") # no // dupe + + def test_4xx_raises(self): + """A 403 (the classic 'json not enabled' failure) propagates as an + HTTPStatusError so the caller surfaces a real error.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, text="forbidden") + + with patch("turnstone.core.web_search.httpx.get", _mock_httpx_get(handler)): + client = SearXNGClient("http://searxng:8080") + with pytest.raises(httpx.HTTPStatusError): + client.search("q") + + +# --------------------------------------------------------------------------- +# MCPSearchClient +# --------------------------------------------------------------------------- class TestMCPSearchClient: @@ -121,57 +214,49 @@ class TestMCPSearchClient: class TestResolveClient: - def test_auto_tavily_when_key_present(self): - client = resolve_web_search_client("", tavily_key="key") - assert isinstance(client, TavilyClient) + def test_auto_searxng_when_url_set(self): + client = resolve_web_search_client("", searxng_url="http://searxng:8080") + assert isinstance(client, SearXNGClient) - def test_auto_ddg_when_no_tavily(self): - with patch("turnstone.core.web_search._ddg_available", return_value=True): - client = resolve_web_search_client("", tavily_key=None) - assert isinstance(client, DuckDuckGoClient) + def test_auto_none_when_no_url(self): + assert resolve_web_search_client("", searxng_url=None) is None + assert resolve_web_search_client("", searxng_url="") is None + assert resolve_web_search_client("", searxng_url=" ") is None - def test_auto_none_when_nothing_available(self): - with patch("turnstone.core.web_search._ddg_available", return_value=False): - client = resolve_web_search_client("", tavily_key=None) - assert client is None + def test_explicit_searxng(self): + client = resolve_web_search_client("searxng", searxng_url="http://searxng:8080") + assert isinstance(client, SearXNGClient) - def test_explicit_tavily(self): - client = resolve_web_search_client("tavily", tavily_key="key") - assert isinstance(client, TavilyClient) + def test_explicit_searxng_no_url(self): + assert resolve_web_search_client("searxng", searxng_url=None) is None - def test_explicit_tavily_no_key(self): - client = resolve_web_search_client("tavily", tavily_key=None) - assert client is None - - def test_explicit_ddg(self): - with patch("turnstone.core.web_search._ddg_available", return_value=True): - client = resolve_web_search_client("ddg", tavily_key=None) - assert isinstance(client, DuckDuckGoClient) - - def test_explicit_ddg_not_installed(self): - with patch("turnstone.core.web_search._ddg_available", return_value=False): - client = resolve_web_search_client("ddg", tavily_key=None) - assert client is None + def test_engines_threaded_through(self): + client = resolve_web_search_client( + "searxng", searxng_url="http://searxng:8080", searxng_engines="duckduckgo" + ) + assert isinstance(client, SearXNGClient) + assert client._engines == "duckduckgo" def test_mcp_backend(self): mcp = MagicMock() mcp.is_mcp_tool.return_value = True - client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=mcp) + mcp.server_auth_type.return_value = None + client = resolve_web_search_client("mcp:search:web", searxng_url=None, mcp_client=mcp) assert isinstance(client, MCPSearchClient) - mcp.is_mcp_tool.assert_called_with("mcp__ddg__search") + mcp.is_mcp_tool.assert_called_with("mcp__search__web") def test_mcp_backend_not_connected(self): mcp = MagicMock() mcp.is_mcp_tool.return_value = False - client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=mcp) + client = resolve_web_search_client("mcp:search:web", searxng_url=None, mcp_client=mcp) assert client is None def test_mcp_backend_no_client(self): - client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=None) + client = resolve_web_search_client("mcp:search:web", searxng_url=None, mcp_client=None) assert client is None def test_unknown_backend_returns_none(self): - client = resolve_web_search_client("typo_backend", tavily_key="key") + client = resolve_web_search_client("typo_backend", searxng_url="http://searxng:8080") assert client is None def test_resolve_web_search_client_rejects_oauth_user_backend(self): @@ -194,7 +279,7 @@ class TestResolveClient: mcp.is_mcp_tool.return_value = True # name resolves mcp.server_auth_type.return_value = "oauth_user" client = resolve_web_search_client( - "mcp:oauth-search:search", tavily_key=None, mcp_client=mcp + "mcp:oauth-search:search", searxng_url=None, mcp_client=mcp ) assert client is None, ( "oauth_user-backed web_search backend resolved to a non-None client; " @@ -217,6 +302,6 @@ class TestResolveClient: mcp.is_mcp_tool.return_value = True mcp.server_auth_type.return_value = None client = resolve_web_search_client( - "mcp:static-search:search", tavily_key=None, mcp_client=mcp + "mcp:static-search:search", searxng_url=None, mcp_client=mcp ) assert isinstance(client, MCPSearchClient) diff --git a/turnstone.example.toml b/turnstone.example.toml index d3da111f..c640f662 100644 --- a/turnstone.example.toml +++ b/turnstone.example.toml @@ -102,6 +102,17 @@ [tools] # timeout = 120 # Tool execution timeout in seconds # skip_permissions = false # Auto-approve all tool calls +# +# web_search backend (local/vLLM models only — commercial providers use their +# own native server-side search). The docker-compose stack bundles a SearxNG +# service and points at it by default. +# web_search_backend = "" # "" (auto), "searxng", or "mcp:server:tool" +# searxng_url = "http://searxng:8080" # SearxNG base URL; env: TURNSTONE_SEARXNG_URL + # (the admin Settings value, if set, wins; clear it + # there to disable web search) +# searxng_engines = "" # comma-separated engines (e.g. "duckduckgo,wikipedia"); + # empty = the instance's default mix + # env: TURNSTONE_SEARXNG_ENGINES # --- Judge (turnstone, node) --- diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py index d69d5131..cf0798ef 100644 --- a/turnstone/bootstrap.py +++ b/turnstone/bootstrap.py @@ -78,7 +78,9 @@ From inside Docker, use `http://host.docker.internal:/v1` to reach the hos authentication, set this to `dummy` (the compose.yaml defaults to `dummy` if unset). \ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key. - `MODEL` — Model name (optional, auto-detected if blank) -- `TAVILY_API_KEY` — Web search API key (optional) +- `TURNSTONE_SEARXNG_URL` — Web-search backend URL (optional; defaults to the \ +bundled `searxng` service, so it can usually be left unset. Set it only to point \ +at an external SearxNG instance.) ### Database - `TURNSTONE_DB_BACKEND` — `sqlite` (default) or `postgresql` @@ -161,8 +163,8 @@ Walk the user through setting up their deployment step by step: 1. **First**: Call `check_docker`, `read_file` on `.env`, and `read_file` on `compose.yaml` \ to detect existing state. If `compose.yaml` does not exist, call `write_compose` to \ -extract the bundled compose.yaml and its companion Caddyfile. This is essential — \ -without it, `docker compose` will fail. +extract the bundled compose.yaml and its companion files (the Caddyfile and the \ +SearxNG web-search config). This is essential — without it, `docker compose` will fail. 2. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \ (may differ from this wizard's model). Ask for base URL, API key, model name. 3. **Database**: SQLite (dev/simple) vs PostgreSQL (production). \ @@ -175,7 +177,9 @@ If the user's deployment will use an external identity provider (Okta, Azure AD, offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \ Optionally configure role mapping and OIDC-only mode. 5. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts. -6. **Optional features**: Discord integration, web search (Tavily key). +6. **Optional features**: Discord integration. Web search works out of the box \ +via the bundled `searxng` service (no API key) — only ask about it if the user \ +wants to point at an existing external SearxNG instance instead. 7. **Generate .env**: Call `write_file` with the complete `.env` content. \ Include `TURNSTONE_IMAGE_TAG` set to the version matching the installed package. 8. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \ @@ -190,8 +194,8 @@ dashboard URL, https://localhost:8443. - ALWAYS use `generate_secret` for passwords and secrets — never invent them. - When writing files, use `write_file` — the user will see a preview and confirm. - If `compose.yaml` is missing, call `write_compose` before anything else (it also \ -writes the Caddyfile the compose mounts). The compose uses pre-built ghcr.io images — \ -no local Docker build is needed. +writes the Caddyfile and SearxNG config the compose mounts). The compose uses pre-built \ +ghcr.io images — no local Docker build is needed. - If an existing .env is detected, summarize what's configured and ask what to change. - The `TURNSTONE_DB_URL` for docker compose internal networking uses the hostname `postgres` \ (e.g., `postgresql+psycopg://turnstone:@postgres:5432/turnstone`). @@ -350,10 +354,11 @@ TOOLS: list[dict[str, Any]] = [ "name": "write_compose", "description": ( "Write the production Docker Compose file (and its companion " - "Caddyfile) to the project directory. This extracts the compose.yaml " - "bundled with Turnstone, which uses pre-built images from ghcr.io " - "(no local Docker build required) and a Caddy reverse proxy that " - "fronts the console over HTTPS. The user will see a preview and confirm." + "Caddyfile and SearxNG web-search config) to the project directory. " + "This extracts the compose.yaml bundled with Turnstone, which uses " + "pre-built images from ghcr.io (no local Docker build required), a Caddy " + "reverse proxy that fronts the console over HTTPS, and a bundled SearxNG " + "service for web search. The user will see a preview and confirm." ), "parameters": { "type": "object", @@ -582,37 +587,43 @@ def _tool_check_docker(args: dict[str, Any]) -> str: def _tool_write_compose(project_dir: Path, args: dict[str, Any]) -> str: - """Extract the bundled production compose.yaml + Caddyfile to the project dir. + """Extract the bundled compose.yaml + Caddyfile + SearxNG settings. - The compose file's ``caddy`` service bind-mounts ``./Caddyfile``, so the two - must land together — otherwise ``docker compose up`` fails to start Caddy. + The compose file bind-mounts ``./Caddyfile`` (Caddy) and ``./searxng`` + (the SearxNG web-search service), so all three must land together — + otherwise ``docker compose up`` fails to start those services. """ dest = project_dir / "compose.yaml" caddy_dest = project_dir / "Caddyfile" + searxng_dest = project_dir / "searxng" / "settings.yml" # Read the bundled templates try: deploy = importlib.resources.files("turnstone.deploy") content = deploy.joinpath("compose.yaml").read_text(encoding="utf-8") caddy_content = deploy.joinpath("Caddyfile").read_text(encoding="utf-8") + searxng_content = deploy.joinpath("searxng/settings.yml").read_text(encoding="utf-8") except Exception as exc: return f"Error: could not read bundled compose templates: {exc}" - # Skip if both already identical - if dest.exists() and caddy_dest.exists(): + # Skip if all already identical + if dest.exists() and caddy_dest.exists() and searxng_dest.exists(): try: if ( dest.read_text(encoding="utf-8") == content and caddy_dest.read_text(encoding="utf-8") == caddy_content + and searxng_dest.read_text(encoding="utf-8") == searxng_content ): - return "compose.yaml and Caddyfile already exist with identical content." + return "compose.yaml, Caddyfile and searxng/settings.yml already exist." except (OSError, UnicodeDecodeError): pass # best-effort duplicate check line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0) # Show preview - print(f"\n{YELLOW} Writing compose.yaml ({line_count} lines) + Caddyfile{RESET}") + print( + f"\n{YELLOW} Writing compose.yaml ({line_count} lines) + Caddyfile + searxng/settings.yml{RESET}" + ) print(f"{DIM}{'─' * 50}{RESET}") for line in content.split("\n")[:30]: print(f" {DIM}{line}{RESET}") @@ -629,11 +640,14 @@ def _tool_write_compose(project_dir: Path, args: dict[str, Any]) -> str: dest.write_text(content, encoding="utf-8") caddy_dest.write_text(caddy_content, encoding="utf-8") + searxng_dest.parent.mkdir(parents=True, exist_ok=True) + searxng_dest.write_text(searxng_content, encoding="utf-8") return ( - f"compose.yaml + Caddyfile written successfully. " + f"compose.yaml + Caddyfile + searxng/settings.yml written successfully. " f"The compose uses ghcr.io/turnstonelabs/turnstone images; Caddy fronts the " - f"console dashboard over HTTPS at https://localhost:8443. " + f"console dashboard over HTTPS at https://localhost:8443, and the bundled " + f"SearxNG service provides web search. " f"Add TURNSTONE_IMAGE_TAG={__version__} to .env to pin the image " f"to the currently installed version, or omit it to use 'latest'." ) diff --git a/turnstone/cli.py b/turnstone/cli.py index 5173068e..280172db 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -1002,7 +1002,7 @@ def main() -> None: "--web-search-backend", default="", metavar="BACKEND", - help="Web search backend: '' (auto), 'tavily', 'ddg', or 'mcp:server:tool'", + help="Web search backend: '' (auto), 'searxng', or 'mcp:server:tool'", ) parser.add_argument( "--resume", diff --git a/turnstone/core/config.py b/turnstone/core/config.py index c23f2323..5e14538c 100644 --- a/turnstone/core/config.py +++ b/turnstone/core/config.py @@ -184,29 +184,49 @@ _CONFIG_MAP: dict[str, dict[str, str]] = { }, } -# -- Tavily API key (cached) -------------------------------------------------- +# -- SearxNG web search (cached) ---------------------------------------------- -_tavily_key: str | None = None -_tavily_key_loaded: bool = False +_searxng_url: str | None = None +_searxng_url_loaded: bool = False +_searxng_engines: str | None = None +_searxng_engines_loaded: bool = False -def get_tavily_key() -> str | None: - """Load Tavily API key (cached after first call). +def get_searxng_url() -> str | None: + """Load the SearxNG base URL (cached after first call). - Precedence: config.toml [api] tavily_key -> $TAVILY_API_KEY + Precedence: config.toml [tools] searxng_url -> $TURNSTONE_SEARXNG_URL. + Returns None when neither is set (web_search then has no backend unless + the model supports native search). The docker-compose stack sets the env + var to the bundled ``searxng`` service. """ - global _tavily_key, _tavily_key_loaded - if _tavily_key_loaded: - return _tavily_key - _tavily_key_loaded = True - cfg_key = load_config("api").get("tavily_key", "").strip() - if cfg_key: - _tavily_key = cfg_key - return _tavily_key - env_key = os.environ.get("TAVILY_API_KEY", "").strip() - if env_key: - _tavily_key = env_key - return _tavily_key + global _searxng_url, _searxng_url_loaded + if _searxng_url_loaded: + return _searxng_url + _searxng_url_loaded = True + cfg_url = load_config("tools").get("searxng_url", "").strip() + if cfg_url: + _searxng_url = cfg_url + return _searxng_url + env_url = os.environ.get("TURNSTONE_SEARXNG_URL", "").strip() + if env_url: + _searxng_url = env_url + return _searxng_url + + +def get_searxng_engines() -> str: + """Load the SearxNG engine list (cached after first call). + + Precedence: config.toml [tools] searxng_engines -> $TURNSTONE_SEARXNG_ENGINES + -> "" (use the instance's default engine mix). Comma-separated engine names. + """ + global _searxng_engines, _searxng_engines_loaded + if _searxng_engines_loaded: + return _searxng_engines or "" + _searxng_engines_loaded = True + cfg = load_config("tools").get("searxng_engines", "").strip() + _searxng_engines = cfg or os.environ.get("TURNSTONE_SEARXNG_ENGINES", "").strip() + return _searxng_engines def nonneg_float(val: str) -> float: diff --git a/turnstone/core/env.py b/turnstone/core/env.py index f112576c..2422df1f 100644 --- a/turnstone/core/env.py +++ b/turnstone/core/env.py @@ -64,7 +64,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset( { "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "TAVILY_API_KEY", "TURNSTONE_JWT_SECRET", "TURNSTONE_DISCORD_TOKEN", "TURNSTONE_GITHUB_TOKEN", diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 98808804..79bf7fcc 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -44,7 +44,7 @@ from turnstone.core.attachments import ( Attachment, unreadable_placeholder, ) -from turnstone.core.config import get_tavily_key +from turnstone.core.config import get_searxng_engines, get_searxng_url from turnstone.core.edit import find_occurrences, pick_nearest from turnstone.core.history_decoration import ( attach_vllm_chat_reasoning_field, @@ -1162,7 +1162,7 @@ class ChatSession: # Replaces affected tool dicts with deep copies — module-level # constants are not mutated. self._render_agent_tool_descriptions() - # Web search backend (pluggable: auto/tavily/ddg/mcp:server:tool) + # Web search backend (pluggable: auto/searxng/mcp:server:tool) self._web_search_backend = web_search_backend # Dynamic tool search: defer MCP tools when tool count is high self._tool_search_setting = tool_search @@ -1270,22 +1270,42 @@ class ChatSession: return self._web_search_backend def _resolve_search_client(self) -> WebSearchClient | None: - """Return a web search client for the configured backend, or None.""" + """Return a web search client for the configured backend, or None. + + SearxNG URL/engine resolution follows the project precedence + ``storage → config.toml → env → registry default``: + + 1. An explicit admin (ConfigStore) value wins — including ``""``, + which means "disabled" and is NOT overridden by env/config. + 2. ``config.toml`` / the ``TURNSTONE_SEARXNG_*`` env vars. + 3. The registry default (``http://searxng:8080``) the store returns + for unset keys, so the bundled SearxNG resolves out of the box. + + A bare CLI has no ConfigStore and uses (2) only, disabling the tool + when nothing is set. ``stored_keys()`` distinguishes an explicit empty + value (deliberate disable) from an unset key (fall through). + """ from turnstone.core.web_search import resolve_web_search_client - # ConfigStore (DB) takes precedence over config.toml / env var - tavily_key: str | None = None cs = getattr(self, "_config_store", None) - if cs is not None: - db_key = cs.get("tools.tavily_api_key") - if db_key: - tavily_key = str(db_key) - if not tavily_key: - tavily_key = get_tavily_key() + stored = cs.stored_keys() if cs is not None else frozenset() + + def _setting(key: str, env_value: str | None) -> str: + if cs is not None and key in stored: # explicit admin value wins + return str(cs.get(key) or "").strip() + if env_value: # config.toml / env var + return env_value + if cs is not None: # registry default, surfaced by the store + return str(cs.get(key) or "").strip() + return "" + + searxng_url = _setting("tools.searxng_url", get_searxng_url()) or None + searxng_engines = _setting("tools.searxng_engines", get_searxng_engines()) return resolve_web_search_client( backend=self._get_web_search_backend(), - tavily_key=tavily_key, + searxng_url=searxng_url, + searxng_engines=searxng_engines, mcp_client=self._mcp_client, timeout=self.tool_timeout, ) @@ -3179,7 +3199,7 @@ class ChatSession: Web search gating: ``web_search`` is removed when the model has no native search support and no search backend is available - (Tavily, DDG, or MCP — see ``_resolve_search_client``). + (SearxNG or MCP — see ``_resolve_search_client``). MCP tool gating: ``read_resource`` is removed when no MCP servers expose resources; ``use_prompt`` is removed when none expose prompts. @@ -6929,7 +6949,7 @@ class ChatSession: } def _prepare_web_search(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: - """Prepare a web search via Tavily for approval.""" + """Prepare a web search via the configured backend for approval.""" query = (args.get("query") or "").strip() if not query: return { @@ -6949,8 +6969,8 @@ class ChatSession: "needs_approval": False, "error": ( "Error: No web search backend available. " - "Install the ddg extra (`pip install turnstone[ddg]`), " - "configure a Tavily API key, or set tools.web_search_backend." + "Set tools.searxng_url (or $TURNSTONE_SEARXNG_URL) to a SearxNG " + "instance, or set tools.web_search_backend to an MCP search tool." ), } try: @@ -6958,7 +6978,7 @@ class ChatSession: except (ValueError, TypeError): max_results = 5 topic = args.get("topic", "general") or "general" - if topic not in ("general", "news", "finance"): + if topic not in ("general", "news"): topic = "general" q_preview = query[:200] + ("..." if len(query) > 200 else "") preview = f" {q_preview}" @@ -12575,7 +12595,7 @@ class ChatSession: return call_id, answer def _exec_web_search(self, item: dict[str, Any]) -> tuple[str, str]: - """Search the web via the configured backend (Tavily, DDG, or MCP).""" + """Search the web via the configured backend (SearxNG or MCP).""" self._check_cancelled() call_id = item["call_id"] query = item["query"] diff --git a/turnstone/core/settings_registry.py b/turnstone/core/settings_registry.py index 4dc92d23..dddd5711 100644 --- a/turnstone/core/settings_registry.py +++ b/turnstone/core/settings_registry.py @@ -241,27 +241,38 @@ def _build_registry() -> dict[str, SettingDef]: max_value=50, ), SettingDef( - "tools.tavily_api_key", + "tools.searxng_url", + "str", + "http://searxng:8080", + "SearxNG base URL for web search", + "tools", + help="Base URL of a SearxNG instance for web_search tool calls. The default " + "points at the 'searxng' service bundled in the docker-compose stack. The " + "instance must have JSON output enabled (search.formats includes 'json'). " + "Overrides $TURNSTONE_SEARXNG_URL and config.toml [tools] searxng_url. Empty " + "disables the SearxNG backend.", + reference_url="https://docs.searxng.org/dev/search_api.html", + ), + SettingDef( + "tools.searxng_engines", "str", "", - "Tavily API key for web search (write-only)", + "Comma-separated SearxNG engines (empty = instance default mix)", "tools", - is_secret=True, - help="API key for the Tavily web search service. When set, enables the Tavily " - "backend for web_search tool calls (higher quality than DuckDuckGo). " - "Overrides $TAVILY_API_KEY and config.toml [api] tavily_key.", - reference_url="https://tavily.com", + help="Restrict SearxNG web_search to specific engines (e.g. " + "'duckduckgo,wikipedia,github'). Empty uses the instance's default engine mix. " + "Overrides $TURNSTONE_SEARXNG_ENGINES and config.toml [tools] searxng_engines.", ), SettingDef( "tools.web_search_backend", "str", "", - "Web search backend: '' (auto), 'tavily', 'ddg', or 'mcp:server:tool'", + "Web search backend: '' (auto), 'searxng', or 'mcp:server:tool'", "tools", help="Controls which service handles web_search calls when the model lacks native " - "search support. Empty string auto-detects (Tavily if key present, else DuckDuckGo " - "if installed). 'ddg' uses DuckDuckGo (free, no API key). 'tavily' forces Tavily. " - "'mcp:server:tool' routes to an MCP server (e.g. 'mcp:ddg:search').", + "search support. Empty string auto-detects (SearxNG when searxng_url is set). " + "'searxng' forces the SearxNG backend. 'mcp:server:tool' routes to an MCP server " + "(e.g. 'mcp:search:web_search').", ), # -- server --------------------------------------------------------- SettingDef( diff --git a/turnstone/core/skill_sources.py b/turnstone/core/skill_sources.py index b5dc8e20..82f8b4fe 100644 --- a/turnstone/core/skill_sources.py +++ b/turnstone/core/skill_sources.py @@ -53,7 +53,7 @@ def _accept_resource(rel_path: str, byte_size: int) -> bool: def _split_skills_sh_id(skill_id: str) -> tuple[str, str, str]: """Split a skills.sh canonical id into (owner, repo, skill_name). - skills.sh ids are 3-segment paths like ``tavily-ai/skills/tavily-search`` + skills.sh ids are 3-segment paths like ``owner/repo/skill-name`` that map directly to its REST routes (``/api/download/[owner]/[repo]/[skill]``). """ parts = skill_id.strip().strip("/").split("/") diff --git a/turnstone/core/web_search.py b/turnstone/core/web_search.py index 8fb022bf..00f06865 100644 --- a/turnstone/core/web_search.py +++ b/turnstone/core/web_search.py @@ -2,11 +2,16 @@ ``web_search`` is an abstract capability with swappable clients: -* **TavilyClient** — paid, high quality, requires API key -* **DuckDuckGoClient** — free, no API key, uses ``ddgs`` +* **SearXNGClient** — self-hosted `SearxNG `_ metasearch, + no API key. Aggregates DuckDuckGo, Wikipedia, and ~200 other engines behind a + stable JSON API. Bundled into the docker-compose stack as the ``searxng`` + service. +* **MCPSearchClient** — delegates to a web-search tool exposed by an MCP server. -Auto-detection (default): Tavily if key present, else DDG if installed, -else ``None`` (tool removed from tool list). +Auto-detection (default): SearxNG when a base URL is configured, else ``None`` +(tool removed from the tool list). Native provider-side web search on Anthropic +and OpenAI search models is handled at the API boundary and never reaches these +clients. """ from __future__ import annotations @@ -31,43 +36,38 @@ class WebSearchClient(Protocol): ... -class TavilyClient: - """Tavily search backend (paid, requires API key).""" +class SearXNGClient: + """Self-hosted SearxNG metasearch backend (no API key). - def __init__(self, api_key: str, timeout: float = 120) -> None: - self._api_key = api_key + Talks to the JSON API (``GET /search?format=json``). The instance MUST have + ``json`` enabled in ``search.formats`` — the bundled ``deploy/searxng`` + config does this; a stock instance returns 403/HTML otherwise. + """ + + # The web_search tool's ``topic`` arg → SearxNG ``categories``. ``"general"`` + # is intentionally absent so the param is omitted and SearxNG uses its + # default category mix. + _TOPIC_CATEGORIES = {"news": "news"} + + def __init__(self, base_url: str, engines: str = "", timeout: float = 120) -> None: + self._base_url = base_url.rstrip("/") + self._engines = engines.strip() self._timeout = timeout def search(self, query: str, max_results: int = 5, **kwargs: Any) -> str: - topic = kwargs.get("topic", "general") - resp = httpx.post( - "https://api.tavily.com/search", - json={ - "query": query, - "max_results": max_results, - "topic": topic, - "include_answer": True, - }, - headers={"Authorization": f"Bearer {self._api_key}"}, + params: dict[str, str] = {"q": query, "format": "json"} + category = self._TOPIC_CATEGORIES.get(str(kwargs.get("topic", "general"))) + if category: + params["categories"] = category + if self._engines: + params["engines"] = self._engines + resp = httpx.get( + f"{self._base_url}/search", + params=params, timeout=self._timeout, ) resp.raise_for_status() - data = resp.json() - return _format_tavily(data, query) - - -class DuckDuckGoClient: - """DuckDuckGo search backend (free, no API key).""" - - def __init__(self, timeout: float = 120) -> None: - self._timeout = timeout - - def search(self, query: str, max_results: int = 5, **kwargs: Any) -> str: - from ddgs import DDGS - - with DDGS(timeout=int(self._timeout)) as ddgs: - raw = list(ddgs.text(query, max_results=max_results)) - return _format_ddg(raw, query) + return _format_searxng(resp.json(), query, max_results) class MCPSearchClient: @@ -95,33 +95,51 @@ class MCPSearchClient: # --------------------------------------------------------------------------- -def _format_tavily(data: dict[str, Any], query: str) -> str: +def _format_searxng(data: dict[str, Any], query: str, max_results: int = 5) -> str: parts: list[str] = [] - answer = (data.get("answer") or "").strip() - if answer: - parts.append(f"Answer: {answer}") + + # Instant answers (calculator, Wikipedia summaries, …) — engine-dependent, + # often absent. Each entry is a dict (``{"answer": ...}``) on modern + # SearxNG, a bare string on older builds. + answers: list[str] = [] + for a in data.get("answers") or []: + raw = a.get("answer", "") if isinstance(a, dict) else a + text = str(raw or "").strip() # coerce: some engines return non-str answers + if text: + answers.append(text) + if answers: + parts.append("Answer: " + " ".join(answers)) + + # Infoboxes (Wikipedia/Wikidata side panels). One is plenty of context. + for box in data.get("infoboxes") or []: + content = (box.get("content") or "").strip() + if content: + parts.append(content[:500]) + break + results = data.get("results") or [] if results: lines = [] - for i, r in enumerate(results, 1): + for i, r in enumerate(results[:max_results], 1): title = r.get("title", "") url = r.get("url", "") content = (r.get("content") or "")[:500] lines.append(f"{i}. [{title}]({url})\n {content}") parts.append("\n".join(lines)) - return "\n\n".join(parts) if parts else f"No results for '{query}'." + if parts: + return "\n\n".join(parts) -def _format_ddg(results: list[dict[str, Any]], query: str) -> str: - if not results: - return f"No results for '{query}'." - lines = [] - for i, r in enumerate(results, 1): - title = r.get("title", "") - url = r.get("href", "") - body = (r.get("body") or "")[:500] - lines.append(f"{i}. [{title}]({url})\n {body}") - return "\n".join(lines) + # No usable results. Surface unresponsive engines when present — this is + # the tell-tale of an all-rate-limited or misconfigured instance, and a + # plain "no results" would otherwise hide it from the operator. + unresponsive = data.get("unresponsive_engines") or [] + if unresponsive: + names = ", ".join( + str(u[0]) if isinstance(u, (list, tuple)) and u else str(u) for u in unresponsive + ) + return f"No results for '{query}'. Unresponsive engines: {names}." + return f"No results for '{query}'." # --------------------------------------------------------------------------- @@ -129,38 +147,27 @@ def _format_ddg(results: list[dict[str, Any]], query: str) -> str: # --------------------------------------------------------------------------- -def _ddg_available() -> bool: - """Check if the ddgs package is installed.""" - try: - import ddgs # noqa: F401 - - return True - except ImportError: - return False - - def resolve_web_search_client( backend: str, - tavily_key: str | None, + searxng_url: str | None, + searxng_engines: str = "", mcp_client: Any | None = None, timeout: float = 120, ) -> WebSearchClient | None: """Return a search client based on configuration, or None if unavailable. Args: - backend: ``""`` (auto), ``"tavily"``, ``"ddg"``, or ``"mcp:server:tool"`` - tavily_key: Tavily API key (None if not configured) + backend: ``""`` (auto), ``"searxng"``, or ``"mcp:server:tool"`` + searxng_url: SearxNG base URL (None/empty if not configured) + searxng_engines: comma-separated SearxNG engine list ("" = instance default) mcp_client: MCPClientManager instance (for MCP backends) timeout: HTTP/tool timeout in seconds """ - if backend == "tavily": - if tavily_key: - return TavilyClient(tavily_key, timeout=timeout) - return None + url = (searxng_url or "").strip() - if backend == "ddg": - if _ddg_available(): - return DuckDuckGoClient(timeout=timeout) + if backend == "searxng": + if url: + return SearXNGClient(url, engines=searxng_engines, timeout=timeout) return None if backend.startswith("mcp:"): @@ -193,11 +200,10 @@ def resolve_web_search_client( return None if backend == "": - # Auto-detect: Tavily > DDG > None - if tavily_key: - return TavilyClient(tavily_key, timeout=timeout) - if _ddg_available(): - return DuckDuckGoClient(timeout=timeout) + # Auto-detect: SearxNG (URL configured) > None. MCP backends are + # explicit-only — there is no canonical "the search tool" to pick. + if url: + return SearXNGClient(url, engines=searxng_engines, timeout=timeout) return None log.warning("Unknown web_search_backend %r — web search disabled", backend) diff --git a/turnstone/deploy/Caddyfile b/turnstone/deploy/Caddyfile index 162e9d81..8896daf4 100644 --- a/turnstone/deploy/Caddyfile +++ b/turnstone/deploy/Caddyfile @@ -15,3 +15,19 @@ flush_interval -1 # stream the dashboard SSE without buffering } } + +# Optional SearxNG web UI on its own port: +# browser --h2/HTTPS--> caddy:8444 --h1.1/HTTP--> searxng:8080 +# SearxNG has NO authentication, so whoever reaches this port can search through +# it (an open proxy). The dev stack publishes 8444 on 127.0.0.1 only; the +# production stack does NOT publish it by default — uncomment the caddy port to +# opt in, and mind the AGPL-3.0 §13 source-offer obligation (see docs/docker.md). +# Caddy listens here unconditionally, but it's only reachable where the compose +# maps the host port. A dedicated port (not a sub-path) avoids SearxNG's +# base-URL/asset issues under a subdirectory. +:8444 { + tls internal { + on_demand + } + reverse_proxy searxng:8080 +} diff --git a/turnstone/deploy/compose.yaml b/turnstone/deploy/compose.yaml index ab1710b1..5e51fbcf 100644 --- a/turnstone/deploy/compose.yaml +++ b/turnstone/deploy/compose.yaml @@ -40,6 +40,7 @@ volumes: postgres-data: caddy-data: caddy-config: + searxng-cache: # Defined once, referenced (*alias) by every service so values can't drift. x-shared: @@ -131,6 +132,10 @@ services: - console ports: - "${CONSOLE_HTTPS_PORT:-8443}:443" + # SearxNG web UI — NOT exposed by default. SearxNG has no auth, so + # publishing it is an open search proxy and triggers the AGPL-3.0 §13 + # source-offer obligation (see docs/docker.md). Uncomment to opt in: + # - "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data # persist Caddy's local CA across restarts @@ -139,6 +144,29 @@ services: - turnstone-net restart: unless-stopped + # ------------------------------------------------------------------- + # searxng — self-hosted metasearch backing the web_search tool. + # Internal-network only (no published port): the server reaches it at + # http://searxng:8080. Config (JSON output on, limiter off) is the bundled + # ./searxng/settings.yml, mounted read-only. Commercial models use native + # provider search and never hit this; it serves local/vLLM models. Override + # the tag with SEARXNG_IMAGE_TAG in .env. + # ------------------------------------------------------------------- + searxng: + image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest} + volumes: + - ./searxng:/etc/searxng:ro + - searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts) + networks: + - turnstone-net + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + # ------------------------------------------------------------------- # turnstone-server — Web UI + chat workstreams + LLM interaction. # Registers itself in Postgres on boot so the console can see it. Boots @@ -168,7 +196,9 @@ services: # Bootstrap LLM defaults — real backends are configured in the console UI. LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1} OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} - TAVILY_API_KEY: ${TAVILY_API_KEY:-} + # web_search backend. Defaults to the bundled searxng service; point at an + # external SearxNG by setting TURNSTONE_SEARXNG_URL in .env (empty disables). + TURNSTONE_SEARXNG_URL: ${TURNSTONE_SEARXNG_URL:-http://searxng:8080} MODEL: ${MODEL:-} MCP_CONFIG: ${MCP_CONFIG:-} SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} @@ -181,6 +211,8 @@ services: depends_on: postgres: condition: service_healthy + searxng: + condition: service_healthy healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s diff --git a/turnstone/deploy/searxng/settings.yml b/turnstone/deploy/searxng/settings.yml new file mode 100644 index 00000000..9b2ce18a --- /dev/null +++ b/turnstone/deploy/searxng/settings.yml @@ -0,0 +1,38 @@ +# Turnstone-bundled SearxNG configuration. +# +# SearxNG provides the web_search backend for local/vLLM models (commercial +# providers — OpenAI, Anthropic — use their own native server-side search and +# never touch this service). It runs on the internal docker network only — no +# port is published — so Turnstone reaches it at http://searxng:8080 and no +# external user interacts with its UI. +# +# This file overrides only what Turnstone needs on top of SearxNG's upstream +# defaults. `use_default_settings: true` keeps the full ~200-engine default +# mix (DuckDuckGo, Wikipedia, Stack Overflow, GitHub, arXiv, …); set +# `tools.searxng_engines` in Turnstone to narrow it per query. +# +# Exposing this instance publicly (publishing its port or fronting it with your +# own reverse proxy)? Then set a real `secret_key`, weigh enabling the limiter +# (needs a Valkey/Redis instance), and note the AGPL-3.0 §13 source-offer +# obligation that attaches to operators of a network-reachable SearxNG — see +# docs/docker.md. + +use_default_settings: true + +server: + # Fixed and intentionally NOT secret: this instance is docker-network-internal, + # serves no UI to end users, runs no image_proxy and no sessions, so the + # secret_key has no security role in the bundled deployment. Override it if you + # expose SearxNG beyond the internal network. + secret_key: "turnstone-bundled-searxng-not-secret" + # Off by default. The limiter requires a Valkey/Redis instance and would 429 + # Turnstone's own internal requests. Do NOT enable it without adding one. + limiter: false + public_instance: false + +search: + # `json` is REQUIRED for the API — a stock SearxNG ships html-only and returns + # 403 for `format=json`, which is the single most common new-deployment failure. + formats: + - html + - json diff --git a/turnstone/tools/web_search.json b/turnstone/tools/web_search.json index 1478aadc..e3098a59 100644 --- a/turnstone/tools/web_search.json +++ b/turnstone/tools/web_search.json @@ -14,7 +14,7 @@ }, "topic": { "type": "string", - "description": "Search topic: general, news, or finance (default general)." + "description": "Search topic: general or news (default general)." } }, "required": ["query"] diff --git a/uv.lock b/uv.lock index c5f88050..c062701f 100644 --- a/uv.lock +++ b/uv.lock @@ -361,79 +361,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, ] -[[package]] -name = "brotli" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, - { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, - { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, - { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, - { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, - { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, -] - -[[package]] -name = "brotlicffi" -version = "1.2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, -] - [[package]] name = "certifi" version = "2026.5.20" @@ -709,22 +636,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] -[[package]] -name = "ddgs" -version = "9.14.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "fake-useragent" }, - { name = "httpx", extra = ["brotli", "http2", "socks"] }, - { name = "lxml" }, - { name = "primp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/24/9d29eeb7dd4852c27c3673adcaf30c4dc55ced76b303c1fbb792ce7cae52/ddgs-9.14.4.tar.gz", hash = "sha256:f7b118a2b709a9e9c04a1dca6e96b98c25d4dfaca1a4b0a244d74454fcca48ef", size = 59742, upload-time = "2026-05-15T06:53:45.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/5f/32de4d99220eb559b7b1cd1c529a1856efa8097f7a3e10b6c207aa95e36c/ddgs-9.14.4-py3-none-any.whl", hash = "sha256:acb084c34bf1110c974caf7e5e5a2c1973beb4bd9e170bfd191fe5ed2d2b2d6c", size = 70638, upload-time = "2026-05-15T06:53:44.761Z" }, -] - [[package]] name = "discord-py" version = "2.7.1" @@ -756,15 +667,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "fake-useragent" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -956,28 +858,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, -] - -[[package]] -name = "hpack" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -1006,18 +886,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[package.optional-dependencies] -brotli = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, -] -http2 = [ - { name = "h2" }, -] -socks = [ - { name = "socksio" }, -] - [[package]] name = "httpx-sse" version = "0.4.3" @@ -1027,15 +895,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] -[[package]] -name = "hyperframe" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, -] - [[package]] name = "idna" version = "3.16" @@ -1257,108 +1116,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] -[[package]] -name = "lxml" -version = "6.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, - { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, - { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, - { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, - { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, - { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, - { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, - { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, - { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, - { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, - { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, - { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, - { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, - { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, - { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, - { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, - { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, - { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, - { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, - { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, - { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, - { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, - { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, - { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, - { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, - { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, - { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, - { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, - { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, - { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, - { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, - { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, - { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, - { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, - { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, - { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, - { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, - { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, - { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, - { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, - { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, - { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, - { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, -] - [[package]] name = "mako" version = "1.3.12" @@ -1781,44 +1538,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "primp" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/4b/7efa54f38da7de8df6b70dfed173bb41a52b740b144e4be24c1172db4209/primp-1.3.1.tar.gz", hash = "sha256:b04a5941bf9c876d011c5defaf5a25be093d56e7270b8da52c9788b9df2a829a", size = 1360029, upload-time = "2026-05-23T17:39:25.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/80/c4885a783a7493e396d89a592ba19fce63ef6bd6ad47230924a884a30ec0/primp-1.3.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27b87e6370045a0c65c0e4dfdfacbfe637387d05673ce8ddcce400263f7c27f0", size = 5123967, upload-time = "2026-05-23T17:39:08.586Z" }, - { url = "https://files.pythonhosted.org/packages/58/c1/c965cc23f96a364803d44b4331f33e4465bb6f269add37e39d0ad77ffe33/primp-1.3.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:27a8804eb9a3f641f379ee2b443591428cf85c898816e93d04d3e7b6f229ebcb", size = 4743059, upload-time = "2026-05-23T17:39:15.536Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/f4248d8d833d43fd8ba78208f2f4bf7fba7d3aec8c516090a95d18d6f550/primp-1.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:862974796552a51af8e276bb19c5d5e189168ab8bad216aef7ce3726a8d3b1dd", size = 5100121, upload-time = "2026-05-23T17:39:04.64Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ad/519e32e0184763e1a76c9321fdeac0bb9b30bf85746f12058feec0cc4a27/primp-1.3.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ceb24198994799706f4020a00173ba9c1b491aa9805b1e014d87946677bc3c5d", size = 4738042, upload-time = "2026-05-23T17:39:35.967Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7b/723cb40694b47ec79a142ed8492835c0ecae9fef7acbed014f04b018d1de/primp-1.3.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3298b8afcf0a88ba6622bfc18e78aeb11afbb7d5afa4774f24acf7491f54a2d", size = 5001773, upload-time = "2026-05-23T17:39:03.01Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/80a2e3bdab1c51d738b82ea210a5ab93986b443c561e792e42cae296ec10/primp-1.3.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8d38c5a6d0a863274cbcae9678f265fcdcead3c20d12d152244e88f5f2186b", size = 5334228, upload-time = "2026-05-23T17:39:24.214Z" }, - { url = "https://files.pythonhosted.org/packages/19/70/c95b8054c7d1fe2d84226ec60a5f48ce6c95a08b7c8b1702d7742082f444/primp-1.3.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96f831c78ddb5900873f51e294bf9bbb4bbfdac3a2f39ce4023f8c558d299332", size = 5157269, upload-time = "2026-05-23T17:38:48.142Z" }, - { url = "https://files.pythonhosted.org/packages/34/bb/9b66986b7ecf2eff987134cd94bde533142e3085d6f67531f1a369ceaaae/primp-1.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329d0c320841f65b39d80801d8bae126732b84ec1094ca17b14fda0bda1b20ff", size = 5347438, upload-time = "2026-05-23T17:39:17.405Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/5d127748d06f3c6a3367f3c4974e45b98cda61cd28ea79ef91ad3fe9e093/primp-1.3.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6c3c67670c38a03e9e8da45b212243d35afc8efa018317c46ecdce47f05329d1", size = 5264862, upload-time = "2026-05-23T17:39:20.625Z" }, - { url = "https://files.pythonhosted.org/packages/16/f3/1aac229425cac142c48418e2de9f70597161ea936543b5e3c9e7476e1921/primp-1.3.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9409a31028a8c62a609d389554ad4f5339aad075130300cd443beef0336d7179", size = 4969889, upload-time = "2026-05-23T17:39:22.412Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/a94d6e6166139c76ae42eb941328679309ca85139e8753d639657a24474c/primp-1.3.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:88ca36c2bd1b7c64b96ad07ca367d2d111ac8e9670549be5f232da8bf795d21e", size = 5082679, upload-time = "2026-05-23T17:39:28.411Z" }, - { url = "https://files.pythonhosted.org/packages/cf/61/21d297db575ed660c6aaf35c9014c1874ace45d6dcb79d1a4d3d2608bffb/primp-1.3.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74d13800b501aa003fb05c263d38f8d61656c83a60b2951046c0fc412bc73976", size = 5605392, upload-time = "2026-05-23T17:39:38.007Z" }, - { url = "https://files.pythonhosted.org/packages/36/d6/9262a7ebb1d980a2db0cd505bb902bb3e66acd8a1cb763a4c2921f2f6a5b/primp-1.3.1-cp310-abi3-win32.whl", hash = "sha256:09ada1752629fe89d7b128beeb59cb641f404af462e24177ba36aed1cf322299", size = 4270373, upload-time = "2026-05-23T17:38:44.98Z" }, - { url = "https://files.pythonhosted.org/packages/8f/68/f0c6a60fadff0c185aef232b951a6fa4bbb64511facc48d34734db14f16f/primp-1.3.1-cp310-abi3-win_amd64.whl", hash = "sha256:c0d1e294466cd5ec7ef173eedf8df25cbdc050138d40447a906e92b8553e7765", size = 4661498, upload-time = "2026-05-23T17:39:32.213Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/232a52abc77384ac66b9c1741691dec3659b1207bb6c5e55c1e9b59d22f1/primp-1.3.1-cp310-abi3-win_arm64.whl", hash = "sha256:43304cb41cbb46f361de49faf1cbdba57f969f628c9297239c7ed8ef0cac420f", size = 4624481, upload-time = "2026-05-23T17:38:42.724Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/34333b26c533c3122b936dad829f0a6e04f32065d39673c92b157d97aa16/primp-1.3.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:72249a4540d0a8965f36eb9a86cd16801d1c7e8dac2f0b0fa23a0a5a03402d36", size = 5116098, upload-time = "2026-05-23T17:38:55.219Z" }, - { url = "https://files.pythonhosted.org/packages/7a/56/7fe14708adf9a5cb5d6a15ad840a3de036cebfaf20692a5bc3b72e188a73/primp-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db4e2eaa5707e47899eeba6026f420f9b0108a28c08d63f1826d0cab8d50f06f", size = 4736300, upload-time = "2026-05-23T17:38:51.792Z" }, - { url = "https://files.pythonhosted.org/packages/31/cb/521a8c18e8808a75450b6e91dc62cc1149c0178b7d4a8697d3f9b73fa385/primp-1.3.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d62e7609c98b4bc99c9cecc47f16f332fb8fe1a023002176267b0043dedad0c7", size = 5093823, upload-time = "2026-05-23T17:38:50.059Z" }, - { url = "https://files.pythonhosted.org/packages/57/84/90f776fe46aeb0e3b86df72c674c0651326dd6a61846dd86bddbabe903ac/primp-1.3.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d692e912c2b25271163ba7719df0afdb733a7e7c3073c9094e9001882463543", size = 4734511, upload-time = "2026-05-23T17:39:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/d9bfbc0df0394f18a98b512a65f4bcf3dd7d17bd871937127e1ce4549172/primp-1.3.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c08693517dc160a12c0f9e2565c5319173cef738893a303ff2fb28ecccbd84d", size = 4999315, upload-time = "2026-05-23T17:39:12.061Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d8/5a986957ee1874d08567d7749668cd78a063048d47d6e46a874742b7fed1/primp-1.3.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d134ebfa31adc619e4e48289fe3e7eebc8310141560e6a6a04269cc94893d9ab", size = 5329375, upload-time = "2026-05-23T17:39:30.307Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1d/321cac9902cc3992174ed530719141a0da2e426f54f8a90b7b971571d104/primp-1.3.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48e27e7c0e015a6de495cf79c0c8d599ba5f69d091af31572bec2de020522d9c", size = 5140921, upload-time = "2026-05-23T17:38:53.528Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/ccbe6b67e0e91beb5c9d5cf804354225d5a3a7a9adf84fee3d6acc53febd/primp-1.3.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c3d682df08c1b1f37b1f66b21fd173baebcfcb52490830b12292d8fe89b2147", size = 5344288, upload-time = "2026-05-23T17:39:18.965Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e5/a735751bd11558163e83e0961fc866e4f94634df9eb24937c5f59624e393/primp-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fabaac4280df0802377d34b869949d617a0ecf22ca7fd5f9bded3f5c981031f1", size = 5262909, upload-time = "2026-05-23T17:39:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/09/8e/4e3d4520e2e751f2de825dbe2cb43f837d33a5528adc44255f9770ea125a/primp-1.3.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f510e5881e0a4c4b9e7dbc03722c316d58454388b88000a0e7bf18a4b36d601e", size = 4964809, upload-time = "2026-05-23T17:38:59.023Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/e7b7495687d07df693325a12c497a9e5185d5001b7b216f32019fa7437a0/primp-1.3.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0504de2901c97903a9c369856a4b186dc90a782d8320652c142b066e697d5a1a", size = 5083654, upload-time = "2026-05-23T17:39:06.598Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f9/e4652e93beb14a16cc4218cfb1ccc18eaca8ee7d93b517d614a135928ec9/primp-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c3b24e302d95d327e873834b9423823b9c8af2abf5e0bbf57a03f3354cfe528", size = 5594738, upload-time = "2026-05-23T17:39:27.001Z" }, - { url = "https://files.pythonhosted.org/packages/e2/49/e8c8a7bc6b741ab6f15896022eee4f906d04d7ccd15aeeb515dd04bbeb6d/primp-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:4346dcef805279028bf4a54bb87dd43d0920130e25b5790689f5c96c9ba0d9e5", size = 4262615, upload-time = "2026-05-23T17:39:13.88Z" }, - { url = "https://files.pythonhosted.org/packages/a9/84/7ae4a257dec6dff329d4a8d9051d907316095c27ffc8d1ea15c359e6eeb5/primp-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6c55f152a73b6d6af8ac37bdb648d8bbfd7e656f9ef40d87feb3c0d81cee930a", size = 4660584, upload-time = "2026-05-23T17:39:10.081Z" }, - { url = "https://files.pythonhosted.org/packages/99/20/10e0d96bfaeef1f0cd339ccf9bb8feb4bf798fde93198f7a96c73441080a/primp-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:46a529d74583d6ceba52e15bf4c678fcf24e6d669c1ce935262d5490d1b25801", size = 4623226, upload-time = "2026-05-23T17:38:57.256Z" }, -] - [[package]] name = "propcache" version = "0.5.2" @@ -2565,15 +2284,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "socksio" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.50" @@ -2762,7 +2472,6 @@ all = [ { name = "aiohttp" }, { name = "anthropic" }, { name = "croniter" }, - { name = "ddgs" }, { name = "discord-py" }, { name = "lacme" }, { name = "numpy" }, @@ -2778,9 +2487,6 @@ anthropic = [ console = [ { name = "croniter" }, ] -ddg = [ - { name = "ddgs" }, -] dev = [ { name = "mypy" }, { name = "ruff" }, @@ -2822,7 +2528,6 @@ requires-dist = [ { name = "croniter", marker = "extra == 'console'", specifier = ">=3.0" }, { name = "croniter", marker = "extra == 'test'", specifier = ">=3.0" }, { name = "cryptography", specifier = ">=42" }, - { name = "ddgs", marker = "extra == 'ddg'", specifier = ">=9.0" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" }, { name = "httpx", specifier = ">=0.28" }, { name = "httpx-sse", specifier = ">=0.4" }, @@ -2847,10 +2552,10 @@ requires-dist = [ { name = "starlette", specifier = ">=1.0.1" }, { name = "structlog", specifier = ">=24.1" }, { name = "sympy", marker = "extra == 'sandbox'", specifier = ">=1.13" }, - { name = "turnstone", extras = ["console", "anthropic", "postgres", "discord", "ddg", "tls", "sandbox", "slack"], marker = "extra == 'all'" }, + { name = "turnstone", extras = ["console", "anthropic", "postgres", "discord", "tls", "sandbox", "slack"], marker = "extra == 'all'" }, { name = "uvicorn", specifier = ">=0.34" }, ] -provides-extras = ["test", "dev", "console", "anthropic", "postgres", "ddg", "discord", "tls", "sandbox", "slack", "all"] +provides-extras = ["test", "dev", "console", "anthropic", "postgres", "discord", "tls", "sandbox", "slack", "all"] [[package]] name = "typing-extensions"