diff --git a/docs/deweloper/mapa-kodu.md b/docs/deweloper/mapa-kodu.md
index 9107fe645..54f414765 100644
--- a/docs/deweloper/mapa-kodu.md
+++ b/docs/deweloper/mapa-kodu.md
@@ -304,6 +304,8 @@ graph LR
| `notifications/` | WebSocket notifications | `Notification`, `NotificationsConsumer` |
| `ranking_autorow/` | Author rankings | Ranking views and calculations |
| `nowe_raporty/` | Modern reporting interface | Report forms and exports |
+| `mcp_server/` | Hosted MCP server for AI assistants (wraps `bpp-mcp` in-process) | `KontekstZadania`, `StartMcp` |
+| `oauth_mcp/` | OAuth 2.1 auth server backing `/mcp/auth` (DCR + PKCE) | — (no own models) |
---
@@ -511,6 +513,8 @@ from bpp.admin.helpers.fieldsets import (
| `/api/v1/` | REST API | Read-only API |
| `/multiseek/` | Multiseek | Advanced search |
| `/pbn_import/` | ImportDashboard | PBN import interface |
+| `/mcp/` | StronaMcp | Human-readable MCP connection instructions |
+| `/mcp`, `/mcp/auth` | `mcp_server.routing.RouterHttp` (ASGI, not Django URLconf) | Streamable-HTTP MCP endpoint — public / OAuth-required |
---
diff --git a/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md
new file mode 100644
index 000000000..2ad1791a9
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-06-mcp-hostowany-w-serwisie.md
@@ -0,0 +1,2162 @@
+# Hostowany serwer MCP w serwisie BPP — plan wdrożenia
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> superpowers:subagent-driven-development (recommended) or
+> superpowers:executing-plans to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** BPP wystawia własny endpoint MCP pod `/mcp` (publiczny) i `/mcp/auth`
+(wymagający OAuth), wykonując narzędzia z pakietu `bpp-mcp` i pobierając dane
+wywołaniem własnej aplikacji Django w procesie.
+
+**Architecture:** Do istniejącego `ProtocolTypeRouter` w `django_bpp/asgi.py`
+dochodzi klucz `"lifespan"` oraz router HTTP rozdzielający `/mcp` od reszty.
+Menedżer sesji MCP startuje w osobnym, długowiecznym zadaniu (bo Daphne nie
+implementuje lifespanu). Każde żądanie dostaje własnego `BppClient`
+skonfigurowanego na host tego żądania, wołającego `django_asgi_app` przez
+`httpx.ASGITransport`.
+
+**Tech Stack:** Django 5.2, channels 4.3, DRF 3.18, `mcp>=2,<3`
+(przez `bpp-mcp>=0.4,<0.5`), `httpx`, `anyio`, pytest.
+
+**Spec:** `docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md`
+
+## Global Constraints
+
+- Wszystkie komendy przez `uv run` (`uv run pytest`, `uv run ruff …`).
+- **Max długość linii: 88 znaków** (ruff).
+- Komentarze i docstringi **po polsku**, w stylu repo (uzasadnienie „dlaczego").
+- **NIGDY** gołe `except:` ani `except Exception: pass`.
+- Testy: pytest, funkcje bez klas, `@pytest.mark.django_db`, `model_bakery.baker`.
+- **Repo nie ma `pytest-asyncio`.** Korutyny odpala się przez `asyncio.run`
+ w osobnym wątku (wzorzec: `src/django_bpp/tests/test_asgi_notifications.py`).
+- **Nie modyfikować istniejących migracji.**
+- Istniejące testy `oauth_mcp` i `api_v1` muszą przechodzić **bez zmian**.
+- Nie uruchamiać `ruff check --fix` ani innych masowych auto-poprawek.
+- Aplikacja MCP budowana **fabryką** `build_application()`, nigdy przy imporcie
+ modułu — inaczej `settings` nadpisane w teście do niej nie dotrą.
+- `ASGITransport` dostaje **`django_asgi_app`**, nigdy `application` (rekurencja).
+
+---
+
+## Struktura plików
+
+```
+src/mcp_server/
+ __init__.py
+ apps.py # McpServerConfig
+ kontekst.py # ContextVary + KontekstZadania + KontekstMcp
+ klient.py # BppClientInProcess + KlientScope
+ auth.py # BramkaBearera
+ routing.py # RouterHttp + LifespanMcp
+ start.py # StartMcp
+ aplikacja.py # build_application()
+ zdrowie.py # stan dla healthchecku
+ views.py # strona /mcp/ dla człowieka
+ urls.py
+ templates/mcp_server/index.html
+ tests/
+ __init__.py
+ utils.py # uruchom(), zbuduj_scope(), wywolaj()
+ test_start.py
+ test_routing.py
+ test_klient.py
+ test_auth.py
+ test_aplikacja.py
+ test_bezpieczenstwo.py
+ test_wielohost.py
+src/oauth_mcp/views_metadata.py # + oauth_protected_resource_metadata
+src/oauth_mcp/urls.py # + trasa PRM
+src/django_bpp/asgi.py # montaż
+src/django_bpp/settings/base.py # INSTALLED_APPS
+pyproject.toml # bpp-mcp
+```
+
+---
+
+## Task 1: Zależność `bpp-mcp` i szkielet aplikacji
+
+**Files:**
+- Modify: `pyproject.toml`
+- Modify: `src/django_bpp/settings/base.py` (INSTALLED_APPS)
+- Create: `src/mcp_server/__init__.py`, `src/mcp_server/apps.py`
+- Create: `src/mcp_server/tests/__init__.py`
+- Test: `src/mcp_server/tests/test_aplikacja.py`
+
+**Interfaces:**
+- Produces: aplikacja Django `mcp_server`; dostępny import `bpp_mcp`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_aplikacja.py
+"""Aplikacja mcp_server jest zarejestrowana, a pakiet bpp-mcp dostępny."""
+
+from django.apps import apps
+
+
+def test_aplikacja_zarejestrowana():
+ assert apps.is_installed("mcp_server")
+
+
+def test_pakiet_bpp_mcp_dostepny():
+ from bpp_mcp import register_tools
+
+ assert callable(register_tools)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v`
+Expected: FAIL — brak modułu `mcp_server`.
+
+- [ ] **Step 3: Dodaj zależność**
+
+W `pyproject.toml`, w sekcji `dependencies`, po linii z `django-multiseek`:
+
+```toml
+ # Narzędzia MCP + szwy do hostowania ich w naszym procesie (spec §2.2).
+ # Ciągnie mcp>=2,<3, starlette i httpx — patrz spec §9.
+ "bpp-mcp>=0.4,<0.5",
+```
+
+Następnie: `uv lock && uv sync`
+
+- [ ] **Step 4: Utwórz aplikację**
+
+```python
+# src/mcp_server/__init__.py
+"""Hostowany endpoint MCP serwisu BPP (/mcp, /mcp/auth).
+
+Narzędzia pochodzą z pakietu ``bpp-mcp``; ta aplikacja dostarcza wyłącznie
+warstwę montażu: routing ASGI, kontekst żądania, uwierzytelnianie i klienta
+wołającego własną aplikację Django w procesie.
+"""
+```
+
+```python
+# src/mcp_server/apps.py
+from django.apps import AppConfig
+
+
+class McpServerConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "mcp_server"
+ verbose_name = "Serwer MCP"
+```
+
+```python
+# src/mcp_server/tests/__init__.py
+```
+
+- [ ] **Step 5: Zarejestruj w INSTALLED_APPS**
+
+W `src/django_bpp/settings/base.py`, w `INSTALLED_APPS`, bezpośrednio po
+`"oauth2_provider",`:
+
+```text
+ "mcp_server",
+```
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add pyproject.toml uv.lock src/django_bpp/settings/base.py src/mcp_server
+git commit -m "feat(mcp_server): szkielet aplikacji + zależność bpp-mcp"
+```
+
+---
+
+## Task 2: `StartMcp` — start menedżera sesji w osobnym zadaniu
+
+Menedżer sesji MCP musi wejść w `session_manager.run()` dokładnie raz. Wejście
+z zadania **żądania** czyni je host taskiem grupy zadań anyio — a wtedy aktywny
+w tym zadaniu cancel scope (np. nasz budżet czasu) rzuca `RuntimeError` przy
+wyjściu, i grupa zostaje trwale zepsuta (spec §5.1, bloker BL-3).
+
+**Files:**
+- Create: `src/mcp_server/start.py`
+- Create: `src/mcp_server/tests/utils.py`
+- Test: `src/mcp_server/tests/test_start.py`
+
+**Interfaces:**
+- Produces: `StartMcp(app)` z `await zapewnij()`, `wystartowany: bool`,
+ `zywy: bool`.
+- Produces: `mcp_server.tests.utils.uruchom(coro)` — odpala korutynę w osobnym
+ wątku (repo nie ma `pytest-asyncio`).
+
+- [ ] **Step 1: Napisz pomocnik testowy**
+
+```python
+# src/mcp_server/tests/utils.py
+"""Pomocniki testowe warstwy ASGI.
+
+Repo nie ma ``pytest-asyncio``, a ``async_to_sync`` rzuca, gdy w bieżącym
+wątku biegnie już pętla (flaky zależny od kolejności testów — patrz
+``django_bpp/tests/test_asgi_notifications.py``). Odpalamy więc każdą korutynę
+przez ``asyncio.run`` w dedykowanym wątku ze świeżą pętlą.
+"""
+
+import asyncio
+import json
+from concurrent.futures import ThreadPoolExecutor
+
+
+def uruchom(coro_factory):
+ """Odpal fabrykę korutyn w osobnym wątku i zwróć jej wynik.
+
+ Przyjmujemy FABRYKĘ, nie korutynę: korutyna utworzona w wątku wołającym
+ byłaby związana z jego (nieistniejącą) pętlą.
+ """
+ with ThreadPoolExecutor(max_workers=1) as ex:
+ return ex.submit(lambda: asyncio.run(coro_factory())).result()
+
+
+def zbuduj_scope(sciezka, metoda="POST", host="bpp.example.test", naglowki=None):
+ """Minimalny scope HTTP/ASGI do wołania aplikacji wprost."""
+ bazowe = [(b"host", host.encode())]
+ for k, v in (naglowki or {}).items():
+ bazowe.append((k.lower().encode(), v.encode()))
+ return {
+ "type": "http",
+ "asgi": {"version": "3.0", "spec_version": "2.3"},
+ "http_version": "1.1",
+ "method": metoda,
+ "scheme": "http",
+ "path": sciezka,
+ "raw_path": sciezka.encode(),
+ "query_string": b"",
+ "root_path": "",
+ "headers": bazowe,
+ "client": ("203.0.113.7", 54321),
+ "server": (host, 80),
+ }
+
+
+async def wywolaj(app, scope, cialo=None):
+ """Zawołaj aplikację ASGI i zwróć ``(status, naglowki, tekst)``."""
+ raw = json.dumps(cialo).encode() if cialo is not None else b""
+ do_odebrania = [{"type": "http.request", "body": raw, "more_body": False}]
+ wyslane = []
+
+ async def receive():
+ return do_odebrania.pop(0) if do_odebrania else {"type": "http.disconnect"}
+
+ async def send(msg):
+ wyslane.append(msg)
+
+ await app(scope, receive, send)
+ start = next((m for m in wyslane if m["type"] == "http.response.start"), None)
+ tresc = b"".join(
+ m.get("body", b"") for m in wyslane if m["type"] == "http.response.body"
+ )
+ naglowki = {k.decode(): v.decode() for k, v in (start or {}).get("headers", [])}
+ return (start or {}).get("status"), naglowki, tresc.decode(errors="replace")
+```
+
+- [ ] **Step 2: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_start.py
+"""StartMcp: wejście w lifespan aplikacji MCP dokładnie raz, w osobnym zadaniu.
+
+Kluczowa regresja (spec §5.1, bloker BL-3): wejście w grupę zadań anyio
+z zadania ŻĄDANIA czyni je host taskiem; aktywny w tym zadaniu cancel scope
+rzuca wtedy RuntimeError przy wyjściu, a grupa zostaje trwale zepsuta.
+"""
+
+import asyncio
+from contextlib import asynccontextmanager
+
+import anyio
+import pytest
+
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom
+
+
+class _AplikacjaZLifespanem:
+ """Namiastka aplikacji Starlette: liczy wejścia w lifespan."""
+
+ def __init__(self, blad=None):
+ self.wejscia = 0
+ self._blad = blad
+ self.router = self
+
+ @asynccontextmanager
+ async def lifespan_context(self, _app):
+ if self._blad is not None:
+ raise self._blad
+ self.wejscia += 1
+ yield
+
+
+def test_zapewnij_wchodzi_dokladnie_raz():
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ await start.zapewnij()
+ await start.zapewnij()
+ await start.zapewnij()
+ return start.wystartowany
+
+ assert uruchom(scenariusz) is True
+ assert app.wejscia == 1
+
+
+def test_zapewnij_pod_aktywnym_cancel_scope_nie_psuje_zadania():
+ """REGRESJA BL-3: wywołanie z zadania, w którym trwa fail_after, nie może
+ ani wywalić tego zadania, ani zepsuć kolejnych wywołań."""
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ with anyio.fail_after(5):
+ await start.zapewnij()
+ # wyjście z fail_after musi się udać — i drugie wywołanie też
+ await start.zapewnij()
+ return app.wejscia
+
+ assert uruchom(scenariusz) == 1
+
+
+def test_blad_startu_jest_podnoszony_i_zapamietany():
+ app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa"))
+ start = StartMcp(app)
+
+ async def scenariusz():
+ with pytest.raises(RuntimeError):
+ await start.zapewnij()
+ return start.zywy
+
+ assert uruchom(scenariusz) is False
+
+
+def test_zadanie_przezywa_zadanie_ktore_je_utworzylo():
+ """Grupa zadań ma żyć dłużej niż żądanie, z którego wystartowała."""
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ async def udaje_zadanie():
+ await start.zapewnij()
+
+ await asyncio.create_task(udaje_zadanie())
+ await asyncio.sleep(0.05)
+ return start.zywy
+
+ assert uruchom(scenariusz) is True
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_start.py -v`
+Expected: FAIL — `ModuleNotFoundError: mcp_server.start`
+
+- [ ] **Step 4: Write implementation**
+
+```python
+# src/mcp_server/start.py
+"""Jednorazowe wejście w lifespan aplikacji MCP, w osobnym zadaniu.
+
+Menedżer sesji MCP zakłada grupę zadań anyio (``session_manager.run()``).
+Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM — a to ma dwie
+konsekwencje, przez które nie wolno robić tego z zadania obsługującego
+żądanie (spec §5.1):
+
+* jeżeli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu),
+ wyjście z niego rzuci ``RuntimeError: Attempted to exit a cancel scope that
+ isn't the current task's current cancel scope`` — a grupa jest już wtedy
+ oznaczona jako wystartowana, więc padają też WSZYSTKIE kolejne żądania;
+* anulowanie host taska (timeout, ``application_close_timeout`` Daphne)
+ zatruwa grupę nieodwracalnie, bo ``run()`` wchodzi się raz na instancję.
+
+Dlatego host taskiem jest zadanie tła, żyjące tyle, co proces.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+
+class StartMcp:
+ """Trzyma lifespan aplikacji MCP przy życiu przez cały czas życia procesu."""
+
+ def __init__(self, aplikacja) -> None:
+ self._aplikacja = aplikacja
+ self._gotowe = asyncio.Event()
+ self._zadanie: asyncio.Task | None = None
+ self._blad: BaseException | None = None
+
+ @property
+ def wystartowany(self) -> bool:
+ """Czy lifespan został pomyślnie otwarty."""
+ return self._gotowe.is_set() and self._blad is None
+
+ @property
+ def zywy(self) -> bool:
+ """Czy zadanie trzymające grupę nadal działa (healthcheck, spec §10.3)."""
+ if self._blad is not None:
+ return False
+ return self._zadanie is not None and not self._zadanie.done()
+
+ async def zapewnij(self) -> None:
+ """Wystartuj menedżera sesji, jeśli jeszcze nie działa, i poczekaj.
+
+ Idempotentne. Wołane z ``LifespanMcp`` (uvicorn) albo z pierwszego
+ żądania (Daphne — nie implementuje protokołu lifespan).
+ """
+ if self._zadanie is None:
+ # create_task nie ma punktu przerwania między testem a przypisaniem
+ # w tej samej pętli, więc blokada nie jest potrzebna.
+ self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj())
+ await self._gotowe.wait()
+ if self._blad is not None:
+ raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad
+
+ async def _trzymaj(self) -> None:
+ """Host task grupy zadań — czeka w nieskończoność."""
+ try:
+ async with self._aplikacja.router.lifespan_context(self._aplikacja):
+ self._gotowe.set()
+ await asyncio.Event().wait()
+ except BaseException as exc:
+ self._blad = exc
+ self._gotowe.set()
+ raise
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_start.py -v`
+Expected: PASS (4 passed)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/mcp_server/start.py src/mcp_server/tests/
+git commit -m "feat(mcp_server): StartMcp — lifespan MCP w osobnym zadaniu"
+```
+
+---
+
+## Task 3: Kontekst żądania i `KlientScope`
+
+**Files:**
+- Create: `src/mcp_server/kontekst.py`
+- Create: `src/mcp_server/klient.py` (część: `KlientScope`)
+- Test: `src/mcp_server/tests/test_klient.py`
+
+**Interfaces:**
+- Produces: `kontekst.dane_zadania` (ContextVar `DaneZadania | None`),
+ `kontekst.DaneZadania(host, scheme, ip, bearer, deadline)`.
+- Produces: `klient.KlientScope(app)` — ASGI wrapper nadpisujący
+ `scope["client"]`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_klient.py
+"""KlientScope: wewnętrzne żądanie musi nieść PRAWDZIWE IP pytającego.
+
+httpx.ASGITransport buduje scope sam i wpisuje tam własne ``client``
+(127.0.0.1). Bez podmiany wszyscy anonimowi użytkownicy MCP dzieliliby jeden
+kubełek throttlingu DRF — jeden klient DoS-owałby wszystkich (spec §7.3).
+"""
+
+import pytest
+
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.klient import KlientScope
+from mcp_server.tests.utils import uruchom
+
+
+async def _echo_client(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": str(scope["client"]).encode()})
+
+
+def test_klient_scope_wstawia_ip_z_kontekstu():
+ app = KlientScope(_echo_client)
+
+ async def scenariusz():
+ zeton = dane_zadania.set(
+ DaneZadania(
+ host="bpp.example.test",
+ scheme="https",
+ ip="198.51.100.9",
+ bearer=None,
+ deadline=None,
+ )
+ )
+ try:
+ wyslane = []
+
+ async def send(m):
+ wyslane.append(m)
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ await app({"type": "http", "client": ("127.0.0.1", 123)}, receive, send)
+ return b"".join(m.get("body", b"") for m in wyslane).decode()
+ finally:
+ dane_zadania.reset(zeton)
+
+ assert "198.51.100.9" in uruchom(scenariusz)
+
+
+def test_klient_scope_bez_kontekstu_rzuca():
+ """FAIL-CLOSED: brak kontekstu to błąd programisty, nie powód do cichego
+ fallbacku na 127.0.0.1 (spec §5.2)."""
+ app = KlientScope(_echo_client)
+
+ async def scenariusz():
+ with pytest.raises(RuntimeError):
+ await app({"type": "http", "client": ("127.0.0.1", 1)}, None, None)
+
+ uruchom(scenariusz)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_klient.py -v`
+Expected: FAIL — `ModuleNotFoundError: mcp_server.kontekst`
+
+- [ ] **Step 3: Write implementation**
+
+```python
+# src/mcp_server/kontekst.py
+"""Dane zewnętrznego żądania, przenoszone do warstwy wykonującej narzędzia.
+
+Klient MCP żyje w lifespanie (jeden na proces), a host, protokół, IP i token
+zależą od KONKRETNEGO żądania. Przenosimy je ContextVarem — MCP SDK celowo
+kopiuje kontekst przez granicę zadań (transport zapisuje ``copy_context()``
+nadawcy, dispatcher odtwarza go przy uruchamianiu handlera), więc wartość
+ustawiona w warstwie ASGI jest widoczna w kodzie narzędzia.
+"""
+
+from __future__ import annotations
+
+from contextvars import ContextVar
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class DaneZadania:
+ """Wszystko, czego wewnętrzne żądanie nie ma skąd wziąć samo."""
+
+ host: str
+ scheme: str
+ ip: str
+ bearer: str | None
+ deadline: float | None # monotoniczny znacznik końca budżetu
+
+
+dane_zadania: ContextVar[DaneZadania | None] = ContextVar(
+ "mcp_dane_zadania", default=None
+)
+
+
+def biezace() -> DaneZadania:
+ """Zwróć dane bieżącego żądania albo rzuć — NIGDY nie zgaduj.
+
+ Fallback na wartości domyślne oznaczałby ciche żądanie z nieznanego hosta,
+ czyli obejście bramki API i wyciek między uczelniami (spec §7.2).
+ """
+ dane = dane_zadania.get()
+ if dane is None:
+ raise RuntimeError(
+ "Brak kontekstu żądania MCP — warstwa KontekstMcp nie została "
+ "uruchomiona albo ContextVar został zresetowany za wcześnie."
+ )
+ return dane
+```
+
+```python
+# src/mcp_server/klient.py
+"""Klient wołający własną aplikację Django w procesie."""
+
+from __future__ import annotations
+
+from mcp_server.kontekst import biezace
+
+
+class KlientScope:
+ """Nadpisuje ``scope["client"]`` prawdziwym IP pytającego.
+
+ ``httpx.ASGITransport`` buduje scope wewnętrznego żądania sam i wpisuje
+ tam ``("127.0.0.1", 123)``. Warstwa stojąca PRZED aplikacją MCP nie ma jak
+ tego dosięgnąć — to jedyne miejsce, w którym da się to poprawić. Bez tego
+ ``SearchAnonThrottle`` (liczy po IP) zlewa wszystkich anonimów do jednego
+ kubełka (spec §7.3).
+ """
+
+ def __init__(self, aplikacja) -> None:
+ self._aplikacja = aplikacja
+
+ async def __call__(self, scope, receive, send):
+ dane = biezace() # fail-closed
+ scope = dict(scope)
+ scope["client"] = (dane.ip, 0)
+ await self._aplikacja(scope, receive, send)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_klient.py -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/kontekst.py src/mcp_server/klient.py src/mcp_server/tests/test_klient.py
+git commit -m "feat(mcp_server): kontekst żądania + KlientScope z prawdziwym IP"
+```
+
+---
+
+## Task 4: `BppClientInProcess` — klient per żądanie z budżetem czasu
+
+**Files:**
+- Modify: `src/mcp_server/klient.py`
+- Test: `src/mcp_server/tests/test_klient.py` (dopisanie)
+
+**Interfaces:**
+- Consumes: `kontekst.biezace()`, `KlientScope`.
+- Produces: `klient.zbuduj_klienta() -> BppClientInProcess`,
+ `klient.BppClientInProcess`.
+
+- [ ] **Step 1: Write the failing test (dopisz do pliku)**
+
+```python
+# --- dopisz na końcu src/mcp_server/tests/test_klient.py ---
+
+import time
+
+import httpx
+
+from mcp_server.klient import BppClientInProcess, zbuduj_klienta
+
+
+def _dane(deadline=None, host="bpp.example.test"):
+ return DaneZadania(
+ host=host, scheme="https", ip="198.51.100.9", bearer=None, deadline=deadline
+ )
+
+
+def test_api_root_bierze_sie_z_hosta_zadania():
+ """Sedno D9: adres API zależy od hosta KONKRETNEGO żądania (spec §5.3)."""
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(host="bpp.umlub.pl"))
+ try:
+ klient = zbuduj_klienta()
+ return str(klient._full_url("autor/"))
+ finally:
+ dane_zadania.reset(zeton)
+
+ assert uruchom(scenariusz) == "https://bpp.umlub.pl/api/v1/autor/"
+
+
+def test_dwa_zadania_dwa_rozne_adresy():
+ async def scenariusz():
+ adresy = []
+ for host in ("bpp.a.test", "bpp.b.test"):
+ zeton = dane_zadania.set(_dane(host=host))
+ try:
+ adresy.append(str(zbuduj_klienta()._full_url("autor/")))
+ finally:
+ dane_zadania.reset(zeton)
+ return adresy
+
+ a, b = uruchom(scenariusz)
+ assert a != b
+ assert "bpp.a.test" in a and "bpp.b.test" in b
+
+
+def test_przekroczony_budzet_zatrzymuje_zadania():
+ """REGRESJA BL-2: po wyczerpaniu budżetu klient NIE wykonuje kolejnych
+ żądań — limit ma zatrzymywać pracę, nie tylko przestać na nią czekać."""
+ wywolania = []
+
+ def handler(request):
+ wywolania.append(request)
+ return httpx.Response(200, json={"ok": 1})
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(deadline=time.monotonic() - 1))
+ try:
+ klient = BppClientInProcess(transport=httpx.MockTransport(handler))
+ with pytest.raises(Exception):
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ assert wywolania == []
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_klient.py -v`
+Expected: FAIL — `ImportError: cannot import name 'BppClientInProcess'`
+
+- [ ] **Step 3: Write implementation (dopisz do `klient.py`)**
+
+```python
+# --- dopisz na końcu src/mcp_server/klient.py ---
+
+import time
+
+import httpx
+from bpp_mcp.client import BppClient, BppError, TrybAuth
+from bpp_mcp.config import Config
+
+
+class BppClientInProcess(BppClient):
+ """``BppClient`` wołający własną aplikację Django, bez gniazda TCP.
+
+ Tworzony PER ŻĄDANIE (spec D9): ``BppClient`` zapamiętuje ``api_root``
+ w ``__init__``, a adres zależy od nagłówka ``Host`` konkretnego żądania.
+ Przy okazji rozwiązuje to izolację cache (świeży klient = świeży słownik)
+ i sprowadza semafor do zasięgu jednego wywołania narzędzia.
+ """
+
+ def __init__(self, *, transport=None) -> None:
+ dane = biezace()
+ if transport is None:
+ from django_bpp.asgi import django_asgi_app
+
+ transport = httpx.ASGITransport(
+ app=KlientScope(django_asgi_app),
+ # 500 z aplikacji ma wrócić jako BppNetworkError z czytelnym
+ # komunikatem, a nie jako surowy traceback w wyniku narzędzia.
+ raise_app_exceptions=False,
+ )
+ super().__init__(
+ Config(base_url=f"{dane.scheme}://{dane.host}", transport="stdio"),
+ transport=transport,
+ tryb_auth=TrybAuth.W_PROCESIE,
+ max_retries=0, # nie ma sieci, która mogłaby zamigotać
+ )
+ # Przekierowania WYŁĄCZONE (spec D12): SECURE_SSL_REDIRECT po cichu
+ # podwajałby każde żądanie, a FirstRunWizardMiddleware przekierowuje
+ # /api/v1/ na HTML kreatora, co kończyłoby się ValueError w resp.json().
+ self._client.follow_redirects = False
+
+ async def _request(self, full, *, retry_5xx=True):
+ """Egzekwuj budżet czasu PRZED wykonaniem żądania.
+
+ Limit wokół aplikacji MCP nie działa (spec §5.2, BL-2): handler biegnie
+ w zadaniu grupy menedżera, więc anulowanie zadania żądania odcina tylko
+ odpowiedź. Sprawdzenie musi być tutaj, gdzie faktycznie wykonuje się
+ praca.
+ """
+ dane = biezace()
+ if dane.deadline is not None and time.monotonic() >= dane.deadline:
+ raise BppError(
+ "Przekroczono budżet czasu wywołania narzędzia — zawęź "
+ "zapytanie albo poproś o mniej danych."
+ )
+ if not str(full.path).startswith("/api/v1/"):
+ raise BppError(f"Ścieżka spoza /api/v1/ jest niedozwolona: {full.path}")
+ if full.host and full.host != dane.host:
+ raise BppError(
+ f"Host spoza bieżącego żądania jest niedozwolony: {full.host}"
+ )
+ return await super()._request(full, retry_5xx=retry_5xx)
+
+
+def zbuduj_klienta() -> BppClientInProcess:
+ """Zbuduj klienta dla bieżącego żądania."""
+ return BppClientInProcess()
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_klient.py -v`
+Expected: PASS (5 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/klient.py src/mcp_server/tests/test_klient.py
+git commit -m "feat(mcp_server): BppClientInProcess per żądanie z budżetem czasu"
+```
+
+---
+
+## Task 5: `BramkaBearera` — 401 z `WWW-Authenticate`
+
+SDK nie zna trybu „anonim dozwolony, zły token odrzucony": `RequireAuthMiddleware`
+montuje się tylko z `token_verifier` i wtedy odrzuca **każde** żądanie bez
+tożsamości. Piszemy własną warstwę (spec §5.4, D10).
+
+**Files:**
+- Create: `src/mcp_server/auth.py`
+- Test: `src/mcp_server/tests/test_auth.py`
+
+**Interfaces:**
+- Produces: `auth.BramkaBearera(app, *, wymagany: bool)`,
+ `auth.zweryfikuj_token(raw) -> User | None` (async).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_auth.py
+"""BramkaBearera: 401 z WWW-Authenticate, przy zachowaniu dostępu anonimowego.
+
+Bramka MUSI sprawdzać dokładnie to, co StrictOAuth2Authentication: ważność
+tokenu, aktywność konta ORAZ scope ``read``. Gdyby sprawdzała mniej, token bez
+scope'u przeszedłby tutaj i padł dopiero w DRF — czyli wróciłby jako błąd
+w treści JSON-RPC z HTTP 200 i klient nigdy nie ponowiłby autoryzacji
+(spec §5.4).
+"""
+
+import pytest
+from django.contrib.auth import get_user_model
+from model_bakery import baker
+from oauth2_provider.models import get_access_token_model, get_application_model
+
+from mcp_server.auth import BramkaBearera
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+async def _ok(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"PRZESZLO"})
+
+
+def _token(scope="read", aktywny=True, wygasly=False):
+ from datetime import timedelta
+
+ from django.utils import timezone
+
+ user = baker.make(get_user_model(), is_active=aktywny)
+ app = get_application_model().objects.create(
+ name="test", client_type="public", authorization_grant_type="authorization-code"
+ )
+ return get_access_token_model().objects.create(
+ user=user,
+ application=app,
+ token="TOKEN123",
+ scope=scope,
+ expires=timezone.now() + timedelta(seconds=-1 if wygasly else 3600),
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_publiczny_bez_naglowka_przepuszcza():
+ app = BramkaBearera(_ok, wymagany=False)
+ status, _, tresc = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp")))
+ assert status == 200 and tresc == "PRZESZLO"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wymagany_bez_naglowka_daje_401_z_naglowkiem():
+ app = BramkaBearera(_ok, wymagany=True)
+ status, naglowki, _ = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp/auth")))
+ assert status == 401
+ assert "resource_metadata=" in naglowki["www-authenticate"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_zly_token_daje_401_takze_na_publicznym():
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer NIEISTNIEJE"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_bez_scope_read_odrzucony_juz_w_bramce():
+ _token(scope="write")
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_konto_nieaktywne_odrzucone():
+ _token(aktywny=False)
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wazny_token_przechodzi():
+ _token()
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, tresc = uruchom(lambda: wywolaj(app, scope))
+ assert status == 200 and tresc == "PRZESZLO"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_auth.py -v`
+Expected: FAIL — `ModuleNotFoundError: mcp_server.auth`
+
+- [ ] **Step 3: Write implementation**
+
+```python
+# src/mcp_server/auth.py
+"""Weryfikacja bearera przed aplikacją MCP.
+
+SDK MCP montuje ``RequireAuthMiddleware`` wyłącznie przy podanym
+``token_verifier``, a ten odrzuca 401-ką KAŻDE żądanie bez tożsamości — czyli
+wyklucza dostęp anonimowy, na którym nam zależy. Stąd własna warstwa
+(spec §5.4).
+"""
+
+from __future__ import annotations
+
+import json
+
+from asgiref.sync import sync_to_async
+from django.db import close_old_connections
+
+WYMAGANY_SCOPE = "read"
+
+
+def _sprawdz(raw: str) -> bool:
+ """Czy token jest ważny wg TYCH SAMYCH reguł, co DRF.
+
+ ``StrictOAuth2Authentication`` wymaga ważnego tokenu, aktywnego konta ORAZ
+ scope'u ``read``. Sprawdzanie tu mniej oznaczałoby, że token bez scope'u
+ przechodzi bramkę i pada dopiero w DRF — czyli wraca jako błąd w treści
+ JSON-RPC z HTTP 200, a klient nigdy nie ponawia autoryzacji.
+ """
+ from oauth2_provider.models import get_access_token_model
+
+ close_old_connections() # poza cyklem żądania Django — brak sygnałów
+ try:
+ token = (
+ get_access_token_model()
+ .objects.select_related("user")
+ .filter(token=raw)
+ .first()
+ )
+ if token is None or not token.is_valid():
+ return False
+ if token.user is None or not token.user.is_active:
+ return False
+ return bool(token.allow_scopes([WYMAGANY_SCOPE]))
+ finally:
+ close_old_connections()
+
+
+zweryfikuj_token = sync_to_async(_sprawdz, thread_sensitive=False)
+
+
+class BramkaBearera:
+ """Przepuszcza anonima (gdy wolno) i odrzuca nieważny token 401-ką."""
+
+ def __init__(self, aplikacja, *, wymagany: bool) -> None:
+ self._aplikacja = aplikacja
+ self._wymagany = wymagany
+
+ async def __call__(self, scope, receive, send):
+ naglowki = {k.lower(): v for k, v in scope.get("headers", [])}
+ surowy = naglowki.get(b"authorization", b"").decode(errors="replace")
+ schemat, _, wartosc = surowy.partition(" ")
+
+ if not surowy:
+ if self._wymagany:
+ await self._odmow(scope, send)
+ return
+ await self._aplikacja(scope, receive, send)
+ return
+
+ if schemat.lower() != "bearer" or not await zweryfikuj_token(wartosc.strip()):
+ await self._odmow(scope, send)
+ return
+
+ await self._aplikacja(scope, receive, send)
+
+ async def _odmow(self, scope, send) -> None:
+ naglowki = {k.lower(): v for k, v in scope.get("headers", [])}
+ host = naglowki.get(b"host", b"localhost").decode()
+ schemat = "https" if scope.get("scheme") in ("https", "wss") else "http"
+ prm = f"{schemat}://{host}/.well-known/oauth-protected-resource"
+ cialo = json.dumps(
+ {
+ "error": "invalid_token",
+ "error_description": "Wymagany ważny token OAuth.",
+ }
+ ).encode()
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 401,
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"www-authenticate", f'Bearer resource_metadata="{prm}"'.encode()),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": cialo})
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_auth.py -v`
+Expected: PASS (6 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/auth.py src/mcp_server/tests/test_auth.py
+git commit -m "feat(mcp_server): BramkaBearera — 401 z PRM przy zachowaniu anonima"
+```
+
+---
+
+## Task 6: `RouterHttp` i `LifespanMcp`
+
+**Files:**
+- Create: `src/mcp_server/routing.py`
+- Test: `src/mcp_server/tests/test_routing.py`
+
+**Interfaces:**
+- Consumes: `StartMcp`, `BramkaBearera`, `kontekst.DaneZadania`.
+- Produces: `routing.RouterHttp(mcp_app, django_app, start)`,
+ `routing.LifespanMcp(start)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_routing.py
+"""RouterHttp: dopasowanie DOKŁADNE + przepisanie ścieżki dla /mcp/auth.
+
+SDK montuje trasę jako Starlette ``Route`` (regex ^/mcp$), nie ``Mount``, więc
+bez przepisania ścieżki /mcp/auth dostałoby 404 (spec §5.1, bloker BL-1).
+Dopasowanie prefiksowe byłoby dodatkowo sprzeczne z §8: /mcp/ ma iść do Django.
+"""
+
+from mcp_server.routing import LifespanMcp, RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+class _AtrapaLifespanu:
+ def __init__(self):
+ self.router = self
+
+ def lifespan_context(self, _app):
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _ctx():
+ yield
+
+ return _ctx()
+
+
+async def _echo_sciezki(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"MCP:" + scope["path"].encode()})
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send(
+ {"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()}
+ )
+
+
+def _router():
+ return RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu()))
+
+
+def test_mcp_trafia_do_aplikacji_mcp():
+ status, _, tresc = uruchom(lambda: wywolaj(_router(), zbuduj_scope("/mcp")))
+ assert status == 200 and tresc == "MCP:/mcp"
+
+
+def test_mcp_auth_ma_przepisana_sciezke_na_mcp():
+ """REGRESJA BL-1: bez przepisania SDK oddałoby 404."""
+ scope = zbuduj_scope("/mcp/auth")
+ status, _, tresc = uruchom(lambda: wywolaj(_router(), scope))
+ assert status == 200 and tresc == "MCP:/mcp"
+
+
+def test_mcp_ze_slashem_idzie_do_django():
+ status, _, tresc = uruchom(
+ lambda: wywolaj(_router(), zbuduj_scope("/mcp/", metoda="GET"))
+ )
+ assert tresc == "DJANGO:/mcp/"
+
+
+def test_reszta_idzie_do_django():
+ status, _, tresc = uruchom(
+ lambda: wywolaj(_router(), zbuduj_scope("/api/v1/", metoda="GET"))
+ )
+ assert tresc == "DJANGO:/api/v1/"
+
+
+def test_get_mcp_z_przegladarki_przekierowuje_na_slash():
+ """Człowiek wkleja adres z instrukcji; bez tego SDK oddałby 406."""
+ scope = zbuduj_scope("/mcp", metoda="GET", naglowki={"accept": "text/html"})
+ status, naglowki, _ = uruchom(lambda: wywolaj(_router(), scope))
+ assert status == 307 and naglowki["location"] == "/mcp/"
+
+
+def test_bearer_trafia_do_contextvara_pakietu():
+ """`BppClient` czyta token z WŁASNEGO ContextVara pakietu bpp_mcp, nie
+ z naszego. Bez tego mostka zalogowany użytkownik po cichu leciałby
+ anonimowo, a warstwa OAuth byłaby dekoracją."""
+ from bpp_mcp.auth import current_bearer
+
+ widziany = []
+
+ async def _podglada(scope, receive, send):
+ widziany.append(current_bearer())
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+ router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu()))
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN-XYZ"})
+ uruchom(lambda: wywolaj(router, scope))
+ assert widziany == ["TOKEN-XYZ"]
+
+
+def test_bearer_nie_wycieka_do_nastepnego_zadania():
+ """Po zakończeniu żądania ContextVar pakietu musi być wyczyszczony."""
+ from bpp_mcp.auth import current_bearer
+
+ widziany = []
+
+ async def _podglada(scope, receive, send):
+ widziany.append(current_bearer())
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+ async def scenariusz():
+ router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu()))
+ z_tokenem = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer T1"})
+ await wywolaj(router, z_tokenem)
+ await wywolaj(router, zbuduj_scope("/mcp"))
+
+ uruchom(scenariusz)
+ assert widziany == ["T1", None]
+
+
+def test_lifespan_startup_complete():
+ start = StartMcp(_AtrapaLifespanu())
+ app = LifespanMcp(start)
+
+ async def scenariusz():
+ zdarzenia = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]
+ odpowiedzi = []
+
+ async def receive():
+ return zdarzenia.pop(0)
+
+ async def send(m):
+ odpowiedzi.append(m)
+
+ await app({"type": "lifespan"}, receive, send)
+ return [m["type"] for m in odpowiedzi]
+
+ assert uruchom(scenariusz) == [
+ "lifespan.startup.complete",
+ "lifespan.shutdown.complete",
+ ]
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_routing.py -v`
+Expected: FAIL — `ModuleNotFoundError: mcp_server.routing`
+
+- [ ] **Step 3: Write implementation**
+
+```python
+# src/mcp_server/routing.py
+"""Routing ASGI: rozdział /mcp od reszty serwisu oraz obsługa lifespanu."""
+
+from __future__ import annotations
+
+import time
+
+from django.conf import settings
+
+from bpp_mcp.auth import set_current_bearer
+
+from django_bpp.client_ip import get_client_ip
+from mcp_server.auth import BramkaBearera
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.start import StartMcp
+
+#: Budżet czasu jednego wywołania narzędzia (spec D11).
+BUDZET_SEKUND = getattr(settings, "MCP_BUDZET_SEKUND", 25.0)
+
+SCIEZKA_PUBLICZNA = "/mcp"
+SCIEZKA_Z_LOGOWANIEM = "/mcp/auth"
+
+
+class LifespanMcp:
+ """Obsługuje scope ``lifespan``, którego ProtocolTypeRouter nie zna.
+
+ Klucza ``"lifespan"`` po prostu nie ma dziś w mapie routera, więc uvicorn
+ dostaje ValueError, loguje „lifespan appears unsupported" i jedzie dalej —
+ a menedżer sesji MCP nigdy nie wstaje (spec §2.4).
+ """
+
+ def __init__(self, start: StartMcp) -> None:
+ self._start = start
+
+ async def __call__(self, scope, receive, send):
+ while True:
+ komunikat = await receive()
+ if komunikat["type"] == "lifespan.startup":
+ try:
+ await self._start.zapewnij()
+ except Exception as exc:
+ # Świadomie NIE połykamy: pod gunicornem worker padnie
+ # i zostanie respawnowany, czyli awaria będzie widoczna
+ # jako boot-loop, a nie jako cisza (spec §5.1).
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
+ return
+ await send({"type": "lifespan.startup.complete"})
+ elif komunikat["type"] == "lifespan.shutdown":
+ await send({"type": "lifespan.shutdown.complete"})
+ return
+
+
+class RouterHttp:
+ """Kieruje ``/mcp`` i ``/mcp/auth`` do MCP, resztę do Django."""
+
+ def __init__(self, mcp_app, django_app, start: StartMcp) -> None:
+ self._django = django_app
+ self._start = start
+ self._publiczny = BramkaBearera(mcp_app, wymagany=False)
+ self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True)
+
+ async def __call__(self, scope, receive, send):
+ sciezka = scope.get("path", "")
+ if sciezka not in (SCIEZKA_PUBLICZNA, SCIEZKA_Z_LOGOWANIEM):
+ await self._django(scope, receive, send)
+ return
+
+ if scope.get("method") == "GET" and self._chce_html(scope):
+ await self._przekieruj(send, "/mcp/")
+ return
+
+ # PIERWSZA instrukcja i POZA jakimkolwiek cancel scope'em (spec §5.1).
+ await self._start.zapewnij()
+ if not self._start.zywy:
+ await self._niedostepny(send)
+ return
+
+ dane = self._dane(scope)
+ zeton = dane_zadania.set(dane)
+ # DWA ContextVary, nie jeden. `BppClient._auth_kwargs` czyta token
+ # z WŁASNEGO ContextVara pakietu bpp_mcp (`bpp_mcp.auth`), nie
+ # z naszego. Bez tej linii `DaneZadania.bearer` nie ma żadnej drogi
+ # do żądania wychodzącego i KAŻDE wywołanie leci anonimowo — czyli
+ # zalogowany użytkownik po cichu traci dostęp do swoich danych,
+ # a cała warstwa OAuth staje się dekoracją.
+ set_current_bearer(dane.bearer)
+ try:
+ if sciezka == SCIEZKA_Z_LOGOWANIEM:
+ # SDK montuje trasę jako Route (^/mcp$), nie Mount — bez
+ # przepisania drugi adres dostałby 404 (spec §5.1, BL-1).
+ scope = dict(scope, path=SCIEZKA_PUBLICZNA, raw_path=b"/mcp")
+ await self._z_logowaniem(scope, receive, send)
+ else:
+ await self._publiczny(scope, receive, send)
+ finally:
+ dane_zadania.reset(zeton)
+ # set_current_bearer nie zwraca tokenu resetu, więc czyścimy
+ # jawnie — inaczej token wyciekłby do następnego żądania
+ # obsłużonego w tym samym kontekście.
+ set_current_bearer(None)
+
+ @staticmethod
+ def _chce_html(scope) -> bool:
+ for klucz, wartosc in scope.get("headers", []):
+ if klucz.lower() == b"accept" and b"text/html" in wartosc.lower():
+ return True
+ return False
+
+ @staticmethod
+ def _dane(scope) -> DaneZadania:
+ # Klucze nagłówków w ASGI to BYTES — bez .decode() na kluczu
+ # każdy odczyt po stringu chybia i bearer cicho ginie.
+ naglowki = {k.lower().decode(): v.decode() for k, v in scope.get("headers", [])}
+ host = naglowki.get("host", "localhost")
+ # nginx→uvicorn jest plaintext; prawdziwy schemat niesie
+ # X-Forwarded-Proto (spec §7.6). Nagłówka NIE przekazujemy dalej —
+ # służy wyłącznie do zbudowania adresu bazowego.
+ scheme = naglowki.get("x-forwarded-proto", scope.get("scheme", "http"))
+ bearer = None
+ surowy = naglowki.get("authorization", "")
+ if surowy.lower().startswith("bearer "):
+ bearer = surowy.split(" ", 1)[1].strip()
+ return DaneZadania(
+ host=host,
+ scheme="https" if scheme == "https" else "http",
+ ip=get_client_ip(_UdajeRequest(scope, naglowki)),
+ bearer=bearer,
+ deadline=time.monotonic() + BUDZET_SEKUND,
+ )
+
+ @staticmethod
+ async def _przekieruj(send, gdzie: str) -> None:
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 307,
+ "headers": [(b"location", gdzie.encode())],
+ }
+ )
+ await send({"type": "http.response.body", "body": b""})
+
+ @staticmethod
+ async def _niedostepny(send) -> None:
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 503,
+ "headers": [(b"content-type", b"application/json")],
+ }
+ )
+ await send(
+ {"type": "http.response.body", "body": b'{"error":"mcp_unavailable"}'}
+ )
+
+
+class _UdajeRequest:
+ """Minimalny obiekt zgodny z tym, czego oczekuje ``get_client_ip``."""
+
+ def __init__(self, scope, naglowki):
+ klient = scope.get("client") or ("", 0)
+ self.META = {"REMOTE_ADDR": klient[0]}
+ xff = naglowki.get("x-forwarded-for")
+ if xff:
+ self.META["HTTP_X_FORWARDED_FOR"] = xff
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_routing.py -v`
+Expected: PASS (6 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/routing.py src/mcp_server/tests/test_routing.py
+git commit -m "feat(mcp_server): RouterHttp z przepisaniem /mcp/auth + LifespanMcp"
+```
+
+---
+
+## Task 7: `build_application()` — aplikacja MCP z narzędziami
+
+**Files:**
+- Create: `src/mcp_server/aplikacja.py`
+- Test: `src/mcp_server/tests/test_aplikacja.py` (dopisanie)
+
+**Interfaces:**
+- Consumes: `zbuduj_klienta`, `StartMcp`, `RouterHttp`, `LifespanMcp`.
+- Produces: `aplikacja.build_application() -> (mcp_app, start)`,
+ `aplikacja.KontekstZadania`.
+
+- [ ] **Step 1: Write the failing test (dopisz do pliku)**
+
+```python
+# --- dopisz na końcu src/mcp_server/tests/test_aplikacja.py ---
+
+from mcp_server.aplikacja import build_application
+from mcp_server.tests.utils import uruchom
+
+
+def test_zarejestrowano_komplet_narzedzi():
+ """11 narzędzi + 1 prompt — tyle daje register_tools z bpp-mcp 0.4.0."""
+ mcp_app, _ = build_application()
+
+ async def scenariusz():
+ serwer = mcp_app.state.serwer_mcp
+ return len(await serwer.list_tools()), len(await serwer.list_prompts())
+
+ narzedzia, prompty = uruchom(scenariusz)
+ assert narzedzia == 11
+ assert prompty == 1
+
+
+def test_fabryka_daje_nowa_instancje_za_kazdym_razem():
+ """Aplikacja MUSI powstawać fabryką: allowlista hostów liczona przy
+ imporcie nie zobaczyłaby settings nadpisanych w teście (spec §11)."""
+ a, _ = build_application()
+ b, _ = build_application()
+ assert a is not b
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v`
+Expected: FAIL — `ModuleNotFoundError: mcp_server.aplikacja`
+
+- [ ] **Step 3: Write implementation**
+
+```python
+# src/mcp_server/aplikacja.py
+"""Budowa aplikacji MCP: serwer SDK + narzędzia z bpp-mcp + polityka hostów."""
+
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+
+from bpp_mcp import register_tools
+from django.conf import settings
+from mcp.server.mcpserver import MCPServer
+from mcp.server.transport_security import TransportSecuritySettings
+
+from mcp_server.klient import zbuduj_klienta
+from mcp_server.start import StartMcp
+
+
+class KontekstZadania:
+ """Kontrakt lifespanu ``register_tools``, ale klient jest PER ŻĄDANIE.
+
+ ``register_tools`` dokumentuje, że lifespan ma oddać ``KontekstApp`` *albo
+ obiekt o tych samych atrybutach*. Korzystamy z tej furtki: ``client`` jest
+ property, więc wrappery narzędzi — które czytają go przy każdym wywołaniu —
+ dostają klienta zbudowanego dla bieżącego żądania (spec D9).
+ """
+
+ #: Host wielo-użytkownikowy: token jednej osoby nie może trafić do żądania
+ #: innej, więc NIGDY nie sięgamy do lokalnego cache tokenów.
+ bearer_provider = None
+
+ @property
+ def client(self):
+ return zbuduj_klienta()
+
+
+def _dozwolone_hosty() -> list[str]:
+ """Przetłumacz ``ALLOWED_HOSTS`` na semantykę SDK.
+
+ SDK dopasowuje host dokładnie albo wzorcem ``host:*`` — Django-owe
+ ``.domena`` i ``*`` nie działają (spec §6.1). ``*`` w ALLOWED_HOSTS
+ (używane w testach) mapujemy na wyłączenie sprawdzania, bo nie ma dla
+ niego odpowiednika.
+ """
+ hosty: list[str] = []
+ for wpis in settings.ALLOWED_HOSTS:
+ if wpis == "*":
+ return []
+ hosty.append(wpis.lstrip("."))
+ hosty.append(f"{wpis.lstrip('.')}:*")
+ return hosty
+
+
+def build_application():
+ """Zbuduj aplikację ASGI serwera MCP i jej ``StartMcp``.
+
+ FABRYKA, nie moduł: allowlista hostów jest liczona z ``settings``, więc
+ zbudowanie aplikacji przy imporcie uniemożliwiłoby testom nadpisanie
+ ``ALLOWED_HOSTS`` (spec §11).
+ """
+
+ @asynccontextmanager
+ async def lifespan(_serwer):
+ yield KontekstZadania()
+
+ serwer = MCPServer("bpp", version="1", lifespan=lifespan)
+ register_tools(serwer)
+
+ hosty = _dozwolone_hosty()
+ bezpieczenstwo = TransportSecuritySettings(
+ enable_dns_rebinding_protection=bool(hosty),
+ allowed_hosts=hosty,
+ allowed_origins=[f"https://{h}" for h in hosty if not h.endswith(":*")],
+ )
+
+ aplikacja = serwer.streamable_http_app(
+ streamable_http_path="/mcp",
+ # Bezstanowo: recykling workera (--max-requests) zabiłby sesję
+ # stateful w środku pracy użytkownika. json_response znosi SSE, więc
+ # proxy_read_timeout nginksa przestaje być tematem (spec D3).
+ stateless_http=True,
+ json_response=True,
+ transport_security=bezpieczenstwo,
+ )
+ aplikacja.state.serwer_mcp = serwer
+ return aplikacja, StartMcp(aplikacja)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_aplikacja.py -v`
+Expected: PASS (4 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/aplikacja.py src/mcp_server/tests/test_aplikacja.py
+git commit -m "feat(mcp_server): build_application z narzędziami i polityką hostów"
+```
+
+---
+
+## Task 8: Montaż w `asgi.py` — test end-to-end
+
+**Files:**
+- Modify: `src/django_bpp/asgi.py`
+- Test: `src/mcp_server/tests/test_e2e.py`
+
+**Interfaces:**
+- Consumes: `build_application`, `RouterHttp`, `LifespanMcp`.
+- Produces: `django_bpp.asgi.application` z kluczem `"lifespan"`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_e2e.py
+"""Pełna ścieżka: POST /mcp → initialize → tools/list, bez sieci."""
+
+import pytest
+
+from mcp_server.aplikacja import build_application
+from mcp_server.routing import RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 404, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+
+def _router(settings):
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+ mcp_app, start = build_application()
+ return RouterHttp(mcp_app, _django, start)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_initialize_zwraca_serverinfo(settings):
+ router = _router(settings)
+ scope = zbuduj_scope(
+ "/mcp",
+ naglowki={
+ "content-type": "application/json",
+ "accept": "application/json, text/event-stream",
+ },
+ )
+ cialo = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "0"},
+ },
+ }
+ status, _, tresc = uruchom(lambda: wywolaj(router, scope, cialo))
+ assert status == 200
+ assert '"serverInfo"' in tresc and '"bpp"' in tresc
+
+
+@pytest.mark.django_db(transaction=True)
+def test_zle_host_daje_421(settings):
+ """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS."""
+ router = _router(settings)
+ scope = zbuduj_scope(
+ "/mcp",
+ host="zly.host",
+ naglowki={"content-type": "application/json"},
+ )
+ status, _, _ = uruchom(
+ lambda: wywolaj(router, scope, {"jsonrpc": "2.0", "id": 1, "method": "ping"})
+ )
+ assert status in (403, 421)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_e2e.py -v`
+Expected: FAIL — brak `state.serwer_mcp` / brak montażu.
+
+- [ ] **Step 3: Zmodyfikuj `src/django_bpp/asgi.py`**
+
+Zastąp blok `application = ProtocolTypeRouter({...})` na końcu pliku:
+
+```python
+# Aplikacja MCP i jej routing. Import PO get_asgi_application() — mcp_server
+# sięga po modele przez klienta w procesie.
+from mcp_server.aplikacja import build_application # noqa: E402
+from mcp_server.routing import LifespanMcp, RouterHttp # noqa: E402
+
+_mcp_app, _mcp_start = build_application()
+
+application = ProtocolTypeRouter(
+ {
+ "http": RouterHttp(_mcp_app, django_asgi_app, _mcp_start),
+ "websocket": AllowedHostsOriginValidator(
+ AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
+ ),
+ # ProtocolTypeRouter nie „nie obsługuje" lifespanu — po prostu nie ma
+ # dla niego klucza i rzuca ValueError, który uvicorn loguje jako
+ # „appears unsupported" i ignoruje. Menedżer sesji MCP nigdy wtedy nie
+ # wstaje (spec §2.4, §5.1).
+ "lifespan": LifespanMcp(_mcp_start),
+ }
+)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_e2e.py -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Sprawdź, że nic się nie zepsuło**
+
+Run: `uv run pytest src/django_bpp/tests/ src/oauth_mcp/ -q`
+Expected: PASS bez zmian w tych plikach.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/django_bpp/asgi.py src/mcp_server/tests/test_e2e.py
+git commit -m "feat(asgi): zamontuj /mcp obok Django, dopisz klucz lifespan"
+```
+
+---
+
+## Task 9: Metadane Protected Resource (RFC 9728)
+
+**Files:**
+- Modify: `src/oauth_mcp/views_metadata.py`
+- Modify: `src/oauth_mcp/urls.py`
+- Test: `src/oauth_mcp/tests/test_metadata_prm.py`
+
+**Interfaces:**
+- Produces: `GET /.well-known/oauth-protected-resource`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/oauth_mcp/tests/test_metadata_prm.py
+"""PRM (RFC 9728) — wskazuje klientowi, gdzie jest serwer autoryzacji."""
+
+import pytest
+
+
+@pytest.mark.django_db
+def test_prm_zwraca_wlasciwe_urle(client):
+ odp = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="bpp.example.test"
+ )
+ assert odp.status_code == 200
+ dane = odp.json()
+ assert dane["resource"] == "http://bpp.example.test/mcp"
+ assert dane["authorization_servers"] == ["http://bpp.example.test"]
+ assert dane["scopes_supported"] == ["read"]
+
+
+@pytest.mark.django_db
+def test_prm_jest_per_host(client):
+ """Wielo-domenowość: każda uczelnia widzi swój adres."""
+ a = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia1.localhost"
+ ).json()
+ b = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia2.localhost"
+ ).json()
+ assert a["resource"] != b["resource"]
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/oauth_mcp/tests/test_metadata_prm.py -v`
+Expected: FAIL — 404.
+
+- [ ] **Step 3: Dopisz widok do `src/oauth_mcp/views_metadata.py`**
+
+```python
+def oauth_protected_resource_metadata(request):
+ """RFC 9728 — mówi klientowi MCP, który serwer autoryzacji obsługuje /mcp.
+
+ Poprzedni spec przypisywał ten dokument pakietowi bpp-mcp; odkąd Resource
+ Serverem jest sama instancja BPP, mieszka on tutaj. URL-e przez
+ build_absolute_uri — poprawny scheme z SECURE_PROXY_SSL_HEADER, host
+ per-request (wielo-domenowość).
+ """
+ return JsonResponse(
+ {
+ "resource": request.build_absolute_uri("/mcp"),
+ "authorization_servers": [request.build_absolute_uri("/").rstrip("/")],
+ "scopes_supported": ["read"],
+ "bearer_methods_supported": ["header"],
+ }
+ )
+```
+
+- [ ] **Step 4: Dopisz trasę w `src/oauth_mcp/urls.py`**
+
+W `urlpatterns`, po wpisie `oauth-as-metadata`:
+
+```text
+ path(
+ ".well-known/oauth-protected-resource",
+ oauth_protected_resource_metadata,
+ name="oauth-prm",
+ ),
+```
+
+oraz rozszerz import na górze pliku:
+
+```python
+from oauth_mcp.views_metadata import (
+ oauth_authorization_server_metadata,
+ oauth_protected_resource_metadata,
+)
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `uv run pytest src/oauth_mcp/tests/test_metadata_prm.py -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/oauth_mcp/views_metadata.py src/oauth_mcp/urls.py src/oauth_mcp/tests/test_metadata_prm.py
+git commit -m "feat(oauth_mcp): metadane protected-resource (RFC 9728)"
+```
+
+---
+
+## Task 10: Strona `/mcp/` dla człowieka
+
+**Files:**
+- Create: `src/mcp_server/views.py`, `src/mcp_server/urls.py`
+- Create: `src/mcp_server/templates/mcp_server/index.html`
+- Modify: `src/django_bpp/urls.py`
+- Test: `src/mcp_server/tests/test_strona.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# src/mcp_server/tests/test_strona.py
+"""Strona /mcp/ — instrukcja podłączenia dla człowieka."""
+
+import pytest
+
+
+@pytest.mark.django_db
+def test_strona_pokazuje_oba_adresy(client):
+ odp = client.get("/mcp/", HTTP_HOST="bpp.example.test")
+ assert odp.status_code == 200
+ tresc = odp.content.decode()
+ assert "http://bpp.example.test/mcp" in tresc
+ assert "http://bpp.example.test/mcp/auth" in tresc
+
+
+@pytest.mark.django_db
+def test_adresy_sa_per_host(client):
+ a = client.get("/mcp/", HTTP_HOST="uczelnia1.localhost").content.decode()
+ assert "uczelnia1.localhost/mcp" in a
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest src/mcp_server/tests/test_strona.py -v`
+Expected: FAIL — 404.
+
+- [ ] **Step 3: Write implementation**
+
+```python
+# src/mcp_server/views.py
+from django.views.generic import TemplateView
+
+
+class StronaMcp(TemplateView):
+ """Instrukcja podłączenia klienta AI — adresy składane per host."""
+
+ template_name = "mcp_server/index.html"
+
+ def get_context_data(self, **kwargs):
+ kontekst = super().get_context_data(**kwargs)
+ kontekst["adres_publiczny"] = self.request.build_absolute_uri("/mcp")
+ kontekst["adres_z_logowaniem"] = self.request.build_absolute_uri("/mcp/auth")
+ return kontekst
+```
+
+```python
+# src/mcp_server/urls.py
+from django.urls import path
+
+from mcp_server.views import StronaMcp
+
+app_name = "mcp_server"
+
+urlpatterns = [
+ path("", StronaMcp.as_view(), name="strona"),
+]
+```
+
+```html
+{# src/mcp_server/templates/mcp_server/index.html #}
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block content %}
+
+
+
{% trans "Bibliografia w Twoim asystencie AI" %}
+
+
{% trans "Wklej adres poniżej do klienta AI obsługującego zdalne serwery MCP." %}
+
+
{% trans "Dostęp publiczny" %}
+
{{ adres_publiczny }}
+
{% trans "Bez logowania. Widzi to samo, co publiczna część bibliografii." %}
+
+
{% trans "Dostęp z logowaniem" %}
+
{{ adres_z_logowaniem }}
+
{% trans "Poprosi o zalogowanie w przeglądarce. Odblokowuje dane niepubliczne." %}
+
+
{% trans "Claude Code" %}
+
claude mcp add bpp --transport http {{ adres_publiczny }}
+
+
+{% endblock %}
+```
+
+- [ ] **Step 4: Zamontuj w `src/django_bpp/urls.py`**
+
+W `urlpatterns`, obok innych `path(...)`:
+
+```text
+ path("mcp/", include("mcp_server.urls", namespace="mcp_server")),
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `uv run pytest src/mcp_server/tests/test_strona.py -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/mcp_server/views.py src/mcp_server/urls.py src/mcp_server/templates src/django_bpp/urls.py src/mcp_server/tests/test_strona.py
+git commit -m "feat(mcp_server): strona /mcp/ z instrukcją podłączenia"
+```
+
+---
+
+## Task 11: Testy bezpieczeństwa i wielo-hostowe
+
+Każdy z tych testów pilnuje konkretnej dziury z recenzji specu. Bez nich
+projekt nie ma prawa wejść.
+
+**Files:**
+- Test: `src/mcp_server/tests/test_bezpieczenstwo.py`
+- Test: `src/mcp_server/tests/test_wielohost.py`
+
+- [ ] **Step 1: Write the tests**
+
+```python
+# src/mcp_server/tests/test_bezpieczenstwo.py
+"""Regresje bezpieczeństwa warstwy /mcp (spec §7)."""
+
+import httpx
+import pytest
+
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.klient import BppClientInProcess
+from mcp_server.tests.utils import uruchom
+
+
+def _dane(host="bpp.example.test", bearer=None):
+ return DaneZadania(
+ host=host, scheme="https", ip="198.51.100.9", bearer=bearer, deadline=None
+ )
+
+
+def _przechwytujacy():
+ zebrane = []
+
+ def handler(request):
+ zebrane.append(request)
+ return httpx.Response(200, json={"ok": 1})
+
+ return httpx.MockTransport(handler), zebrane
+
+
+def test_cookie_nie_trafia_do_zadania_wewnetrznego():
+ """Cookie uwierzytelniłoby sesją przez SessionAuthentication — z pełnymi
+ uprawnieniami, bez zgody, bez scope i bez revoke (spec §7.1)."""
+ transport, zebrane = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane())
+ try:
+ klient = BppClientInProcess(transport=transport)
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ assert "cookie" not in {k.lower() for k in zebrane[0].headers}
+
+
+def test_basic_nie_trafia_do_zadania_wewnetrznego():
+ """BasicAuthentication jest trzecia w DEFAULT_AUTHENTICATION_CLASSES,
+ a ApiReadOnlyForBearerMiddleware sprawdza tylko prefiks 'bearer ', więc
+ dla Basica warstwa read-only NIE istnieje (spec §7.1)."""
+ transport, zebrane = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(bearer=None))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ naglowek = zebrane[0].headers.get("authorization", "")
+ assert not naglowek.lower().startswith("basic")
+
+
+def test_sciezka_spoza_api_v1_odrzucona():
+ transport, _ = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane())
+ try:
+ klient = BppClientInProcess(transport=transport)
+ with pytest.raises(Exception):
+ await klient.get_json("https://bpp.example.test/admin/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+
+
+def test_obcy_host_w_url_odrzucony():
+ """_full_url przyjmuje URL-e bezwzględne wprost, a paginacyjne `next` je
+ niosą — kontrola musi obejmować host, nie tylko prefiks (spec §7.4)."""
+ transport, _ = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(host="bpp.a.test"))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ with pytest.raises(Exception):
+ await klient.get_json("https://bpp.zly.test/api/v1/autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+```
+
+```python
+# src/mcp_server/tests/test_wielohost.py
+"""Wewnętrzne żądanie musi nieść Host zewnętrznego — inaczej Uczelnia=None,
+BramkaApiV1 przepuszcza bezwarunkowo i wyciekają ukryte statusy (spec §7.2)."""
+
+import pytest
+
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.klient import BppClientInProcess
+from mcp_server.tests.utils import uruchom
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def _pobierz(host):
+ async def scenariusz():
+ zeton = dane_zadania.set(
+ DaneZadania(
+ host=host, scheme="http", ip="198.51.100.9", bearer=None, deadline=None
+ )
+ )
+ try:
+ klient = BppClientInProcess()
+ return await klient.get_json("uczelnia/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ return uruchom(scenariusz)
+
+
+def test_kazdy_host_widzi_swoja_uczelnie(settings, uczelnia1, uczelnia2):
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"]
+ a = _pobierz("uczelnia1.localhost")
+ b = _pobierz("uczelnia2.localhost")
+ assert a["results"][0]["nazwa"] != b["results"][0]["nazwa"]
+
+
+def test_wylaczone_api_odmawia_takze_przez_mcp(settings, uczelnia1):
+ """Dowód, że BramkaApiV1 działa na tej ścieżce."""
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost"]
+ uczelnia1.api_v1_wlaczone = False
+ uczelnia1.save(update_fields=["api_v1_wlaczone"])
+ with pytest.raises(Exception):
+ _pobierz("uczelnia1.localhost")
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `uv run pytest src/mcp_server/tests/test_bezpieczenstwo.py src/mcp_server/tests/test_wielohost.py -v`
+Expected: PASS. Jeśli któryś padnie — **to jest realna dziura**, napraw kod,
+nie test.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/mcp_server/tests/test_bezpieczenstwo.py src/mcp_server/tests/test_wielohost.py
+git commit -m "test(mcp_server): regresje bezpieczeństwa i wielo-hostowe"
+```
+
+---
+
+## Task 12: Rollbar, healthcheck, newsfragment, dokumentacja
+
+**Files:**
+- Modify: `src/mcp_server/aplikacja.py` (wrapper Rollbar)
+- Create: `src/mcp_server/zdrowie.py`
+- Create: `src/bpp/newsfragments/mcp-hostowany.feature.rst`
+- Modify: `docs/deweloper/mapa-kodu.md`
+
+- [ ] **Step 1: Wrapper Rollbar w `aplikacja.py`**
+
+SDK zamienia każdy wyjątek narzędzia na `CallToolResult(is_error=True)`, więc
+do warstwy ASGI **nic nie dociera** — bez tego awarie narzędzi są całkowicie
+niewidoczne w monitoringu (spec §7.5).
+
+Dopisz przed `register_tools(serwer)`:
+
+```python
+def _z_raportowaniem(serwer):
+ """Owija zarejestrowane narzędzia raportowaniem do Rollbara.
+
+ SDK przechwytuje wyjątki handlerów i zamienia je na wynik z flagą błędu,
+ więc CustomRollbarNotifierMiddleware ich nie zobaczy — musimy zgłosić je
+ sami, zanim SDK je pochłonie (spec §7.5).
+ """
+ import functools
+
+ import rollbar
+
+ oryginalny = serwer.tool
+
+ def tool(*args, **kwargs):
+ dekorator = oryginalny(*args, **kwargs)
+
+ def opakuj(fn):
+ @functools.wraps(fn)
+ async def wrapper(*a, **kw):
+ try:
+ return await fn(*a, **kw)
+ except Exception:
+ rollbar.report_exc_info()
+ raise
+
+ return dekorator(wrapper)
+
+ return opakuj
+
+ serwer.tool = tool
+ return serwer
+```
+
+i zmień wywołanie na:
+
+```text
+ register_tools(_z_raportowaniem(serwer))
+```
+
+- [ ] **Step 2: Healthcheck**
+
+```python
+# src/mcp_server/zdrowie.py
+"""Stan endpointu MCP dla monitoringu.
+
+Grupa zadań menedżera może zostać zatruta wyjątkiem dziecka — proces wtedy
+żyje, ale każde /mcp zwraca błąd aż do recyklingu workera. Healthcheck musi
+więc odróżniać „proces działa" od „endpoint MCP działa" (spec §10.3).
+"""
+
+from __future__ import annotations
+
+
+def stan_mcp() -> dict:
+ """Zwróć stan startu serwera MCP."""
+ from django_bpp.asgi import _mcp_start
+
+ return {
+ "wystartowany": _mcp_start.wystartowany,
+ "zywy": _mcp_start.zywy,
+ }
+```
+
+- [ ] **Step 3: Newsfragment**
+
+```rst
+.. src/bpp/newsfragments/mcp-hostowany.feature.rst
+
+Serwis wystawia własny serwer MCP pod adresem ``/mcp`` (publiczny) oraz
+``/mcp/auth`` (z logowaniem OAuth). Asystenci AI obsługujący zdalne serwery
+MCP łączą się jednym adresem — bez instalowania czegokolwiek i bez podawania
+adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``.
+```
+
+- [ ] **Step 4: Uruchom pełną suitę**
+
+```bash
+uv run pytest src/mcp_server/ src/oauth_mcp/ src/api_v1/ -q
+uv run ruff format .
+uv run ruff check .
+uv run pre-commit
+```
+
+Expected: wszystko zielone. **`pre-commit` bez argumentów.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/mcp_server/ src/bpp/newsfragments/ docs/
+git commit -m "feat(mcp_server): raportowanie błędów narzędzi, healthcheck, newsfragment"
+```
+
+---
+
+## Self-review planu
+
+**Pokrycie specu.** §1 → Task 10 (strona z oboma adresami). §2 → kontekst,
+nie kod. §4 D1 → Task 1+7; D2 → Task 2+6+8; D3 → Task 7; D4/D9/D12 → Task 4;
+D5 → Task 3+6; D6 → Task 11; D7/D10 → Task 5+6; D8 → narzędzia są read-only
+z pakietu; D11 → Task 4+6. §5 → Tasks 2–8. §6 → Task 9 + `_dozwolone_hosty`
+w Task 7. §7 → Task 11 (+ §7.5 w Task 12). §8 → Task 10. §9 → Task 1.
+§10 → Task 12 (healthcheck); ModSecurity i `limit_req` są w `bpp-deploy`,
+poza tym repo — **do zgłoszenia przy wdrożeniu, odnotowane w §15 specu**.
+§11 → Tasks 2–11. §12 → kryteria pokryte testami.
+
+**Placeholdery.** Brak — każdy krok ma kod albo dokładną komendę.
+
+**Spójność typów.** `StartMcp.zapewnij/wystartowany/zywy` używane w Task 6, 8
+i 12 zgodnie z definicją z Task 2. `DaneZadania(host, scheme, ip, bearer,
+deadline)` konstruowane identycznie w Taskach 3, 4, 6, 11. `build_application()`
+zwraca krotkę `(aplikacja, start)` — rozpakowywaną tak samo w Taskach 7 i 8.
+
+**Luka świadoma:** `limit_req` i ModSecurity wymagają zmian w repozytorium
+`bpp-deploy`; nie da się ich wykonać w tym planie. Wchodzą jako osobne
+zadanie wdrożeniowe po scaleniu.
diff --git a/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md
new file mode 100644
index 000000000..224ba3ccc
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-05-mcp-hostowany-w-serwisie-design.md
@@ -0,0 +1,1022 @@
+# Hostowany serwer MCP w serwisie BPP (`/mcp`) — projekt 1 z 3
+
+Data: 2026-09-05 (wersja 4, 2026-09-06)
+Status: projekt do zatwierdzenia
+Poprzednik: `2026-07-11-mcp-oauth-authorization-design.md` (warstwa OAuth po
+stronie BPP — **zrealizowana**, `src/oauth_mcp/`)
+
+> **Historia dokumentu.** Wersja 1: „wymaga przeprojektowania". Wersja 2:
+> „niewdrażalne — trzy blokery" (B1–B3). Wersja 3 zamknęła B1, ale wprowadziła
+> trzy nowe blokery we własnych rozwiązaniach B2/B3 (BL-1…BL-3). Ta wersja
+> zamyka BL-1…BL-3 i jedenaście poprawek. Rozliczenie w §14.
+
+---
+
+## 1. Cel i motywacja
+
+Dostęp AI do danych BPP idzie dziś przez `bpp-mcp` — pakiet z PyPI instalowany
+do klienta MCP jako serwer stdio. Trzy koszty:
+
+1. **Próg wejścia.** Potrzebny Python i `uvx`, a przede wszystkim użytkownik
+ musi **znać i wpisać adres swojej instancji** (`BPP_BASE_URL` celowo bez
+ wartości domyślnej — każde wdrożenie to inna uczelnia).
+2. **Odcięta kategoria klientów.** Klienci webowi (claude.ai) i urządzenia
+ mobilne **nie mają jak uruchomić procesu lokalnego**.
+3. **Skos wersji.** N wersji pakietu × M wdrożeń BPP.
+
+**Cel:** BPP wystawia własny endpoint MCP, wyjeżdżający z tym samym obrazem
+dockerowym co reszta serwisu. Użytkownik wkleja adres swojej instancji
+z dopiskiem `/mcp` i to jest cała konfiguracja.
+
+### 1.1 Zasięg — uczciwie
+
+Wersja 1 obiecywała „claude.ai, ChatGPT, Cursor, VS Code". **To była
+nieprawda.** Allowlista redirect_uri w DCR (`src/oauth_mcp/views_dcr.py:16-22`)
+dopuszcza wyłącznie `claude.ai`, `*.claude.ai`, `claude.com`, `localhost`
+i `127.0.0.1`. ChatGPT i Cursor **nie zarejestrują klienta**.
+
+Realny zasięg **logowania** to ekosystem Claude. **Dostęp anonimowy nie wymaga
+DCR w ogóle**, więc publiczna część działa z każdym klientem obsługującym
+zdalne MCP. Rozszerzenie allowlisty jest osobną decyzją bezpieczeństwa (§13).
+
+---
+
+## 2. Kontekst — ustalenia z kodu i z probe'ów
+
+Numery linii wg `dev` @ `31d3d4612`. **[probe]** = ustalenie z uruchomionego
+kodu, nie z lektury.
+
+### 2.1 Warstwa OAuth po stronie BPP — istnieje i jest kompletna
+
+| Element | Plik |
+|---|---|
+| `/o/authorize/`, `/o/token/`, `/o/revoke_token/` | `oauth_mcp/urls.py` |
+| DCR `/o/register/` (RFC 7591) + allowlista + rate-limit | `oauth_mcp/views_dcr.py:16-22` |
+| AS metadata RFC 8414 | `oauth_mcp/views_metadata.py` |
+| `StrictOAuth2Authentication` — bearer → `request.user`, twarde 401 | `oauth_mcp/authentication.py` |
+| `ApiReadOnlyForBearerMiddleware` — mutacje bearerem → 403 | `oauth_mcp/middleware.py:33` |
+| `WhoAmIView` — routing w `api_v1/urls.py:265`, pod `z_bramka_api_v1` | `oauth_mcp/views_whoami.py` |
+| revoke przy zmianie hasła / dezaktywacji | `oauth_mcp/signals.py` |
+
+Kolejność w `DEFAULT_AUTHENTICATION_CLASSES` (`base.py:1057-1059`):
+`StrictOAuth2Authentication`, `SessionAuthentication`, `BasicAuthentication`.
+
+### 2.2 Pakiet `bpp-mcp` 0.4.0
+
+Wydany (projekt 2, PR-y #21–#23). Wystawia `register_tools(mcp)` (**11 narzędzi
++ 1 prompt**, `server.py:315-330`), `KontekstApp`, `BppClient(transport=...)`,
+`TrybAuth.W_PROCESIE`, `BppClient._slownik_cache()`.
+
+**Ograniczenie, które kształtuje ten projekt:** `BppClient.__init__` zapamiętuje
+`self._api_root = config.api_root` **raz** (`client.py:135`), `Config` jest
+`frozen` (`config.py:25`), a `_full_url` czyta zapamiętaną wartość
+(`client.py:186`). Nie ma szwu na host per żądanie. Stąd D9 (§5.3).
+
+Kontrakt `register_tools` mówi „`KontekstApp` **albo obiekt o tych samych
+atrybutach**" (`server.py:291-315`) — i to jest furtka, z której korzystamy.
+
+### 2.3 MCP SDK 2.0
+
+- **[probe]** `stateless_http` i `json_response` są parametrami
+ `streamable_http_app()` (`mcpserver/server.py:1218-1229`), **nie**
+ konstruktora.
+- **[probe]** `POST /mcp` bez wejścia w lifespan →
+ `RuntimeError: Task group is not initialized` (`streamable_http_manager.py:174`),
+ także przy `stateless_http=True`. Po wejściu — **200** i poprawny `initialize`.
+- **[probe]** Aplikacja MCP ma **własną allowlistę hostów**
+ (`TransportSecuritySettings`), niezależną od `ALLOWED_HOSTS`. Nieznany
+ `Host` → **421**.
+- Semantyka tej allowlisty **różni się od Django**: dopasowanie dokładne albo
+ wzorzec `host:*` (`transport_security.py:49-60`). Django-owe `.domena` i `*`
+ **nie działają**. Sprawdzany jest też `Origin` (`:65-70`).
+- Przekazanie `host=` **bez** `transport_security` po cichu **wyłącza** ochronę
+ (`lowlevel/server.py:738-744`).
+- `RequireAuthMiddleware` owija trasę **wyłącznie** gdy podano `token_verifier`
+ (`lowlevel/server.py:793-806`) — i wtedy odrzuca 401-ką **każde** żądanie bez
+ tożsamości (`bearer_auth.py:93-97`). Czyli SDK nie zna trybu „anonim
+ dozwolony, zły token odrzucony". Stąd D10 (§5.4).
+- `SessionMessage.metadata.request` niesie do narzędzia **cały obiekt
+ `Request` zewnętrznego żądania** (`streamable_http.py:608-611`), z `Cookie`
+ włącznie. Dziś czyta z niego tylko `bearer_from_request` (`bpp_mcp/auth.py:46-53`),
+ ale D6 stoi przez to na **dyscyplinie kodu w `bpp-mcp`**, nie na izolacji
+ transportowej (§7.1).
+- SDK zamienia każdy wyjątek narzędzia na `CallToolResult(is_error=True)`
+ (`mcpserver/server.py:423-424`) — wyjątki narzędzi **nie docierają** do
+ warstwy ASGI (§7.5).
+- Dwie ścieżki protokołu mają **różny model czasu życia**: „modern"
+ (`MCP-Protocol-Version` spoza `HANDSHAKE_PROTOCOL_VERSIONS`, `manager:183-187`)
+ wykonuje handler w zadaniu żądania, „legacy" — w zadaniu grupy menedżera.
+ Budżet czasu musi działać w obu (§5.2).
+- Czy `stateless_http=True` + `GET` daje 405, **nie zostało potwierdzone** —
+ wersja 3 cytowała `streamable_http.py:788-790,848`, ale 788-790 dotyczy
+ DELETE, a 848 to generyczna obsługa nieobsługiwanej metody. Do sprawdzenia
+ empirycznego, zanim powstanie test (§11).
+- Lifespan wchodzi **raz na serwer**; `session_manager.run()` można wejść
+ **raz na instancję** (`streamable_http_manager.py:137-143`).
+
+### 2.4 Serwery ASGI — trzy różne, nie jeden
+
+To jest źródło blokera B2.
+
+| Środowisko | Serwer | Lifespan |
+|---|---|---|
+| produkcja | `gunicorn` + `UvicornWorker` (`entrypoint-appserver.sh:104`) | **tak** |
+| kontener dev | `uvicorn --reload` (`:94`) | **tak** |
+| **lokalnie: `run-site run` → `manage.py runserver`** | **Daphne** (`daphne` w `INSTALLED_APPS`, `base.py:391`) | **NIE** |
+| **testy Playwright: `channels_live_server`** | **Daphne** (`src/channels_live_server.py:11,22`) | **NIE** |
+
+W źródłach `daphne` **nie ma ani jednego wystąpienia słowa `lifespan`**.
+
+`ProtocolTypeRouter` to piętnaście linii (`channels/routing.py:45-52`): lookup
+w słowniku, a dla typu spoza słownika `raise ValueError` (linie 50-52). Klucza
+`"lifespan"` po prostu nie ma, więc uvicorn dostaje `ValueError`, loguje
+„lifespan appears unsupported" (`lifespan/on.py:88-93`) i jedzie dalej — błąd
+jest cichy. **To nie jest ograniczenie channels, tylko brakujący wpis
+w naszej konfiguracji.**
+
+Pozostała infrastruktura:
+
+- `max_requests=1000` + jitter 200 w `docker/appserver/gunicorn_conf.py:44-45`;
+ gunicornowy `timeout` to heartbeat, **nie** limit czasu żądania (`:47-51`).
+- nginx: `Host $host`, `X-Forwarded-For`, `X-Forwarded-Proto`
+ (`_bpp-locations.conf:129-134`), `proxy_read_timeout 300s` (`:140`), trzy
+ strefy `limit_req` (`:74-89`) — `/api/` 60 r/s, `/admin/` 50 r/s, `/` 100 r/s.
+- **ModSecurity/CRS**; jedyne wyłączenie dla DjangoQL to
+ `^/(bpp|api/v1)/zapytanie/` (`modsecurity-override.conf.template:79`).
+- nginx→uvicorn jest **plaintext**; HTTPS Django rozpoznaje z
+ `X-Forwarded-Proto` (`production.py:161`), a `SECURE_SSL_REDIRECT=True`
+ (`:168`).
+
+### 2.5 Bramka API i model tożsamości hosta
+
+Kolejność w `BramkaApiV1` (`src/api_v1/permissions.py:66-68`), cytat
+z docstringu:
+
+> 1. uczelnia nierozstrzygnięta (pusta baza, kreator konfiguracji, brak
+> mapowania Site→Uczelnia) → **przepuszczamy**, bo nie ma kto podjąć decyzji
+
+`Uczelnia` rozstrzyga się z nagłówka `Host` (`SiteResolutionMiddleware`,
+`Uczelnia.objects.get_for_request`).
+
+**Zakres zagrożenia — sprostowanie wobec wersji 3.** Nie jest tak, że nieznany
+host zawsze daje `Uczelnia=None`: `_site_dla_requestu` spada na `SITE_ID`
+(`uczelnia.py:97-104`), a `uczelnia_dla_site` na „jedyną-albo-None"
+(`:126-129`). W instalacji **jednouczelnianej** nieznany host trafi więc na
+właściwą uczelnię. Obejście bramki zachodzi w instalacji **wielouczelnianej**
+(>1 `Uczelnia`) albo bez `SITE_ID`.
+
+Tam jednak skutek jest dokładnie taki, jak opisano: `BramkaApiV1` przepuszcza
+bezwarunkowo, omijając wszystkie sześć przełączników, a
+`UkryjStatusyKorektyMixin.ukryte_statusy` daje `None`
+(`viewsets/common.py:9-13`) → rekordy ukrytych statusów przestają być
+filtrowane. D5 zostaje bez zmian — po prostu uzasadnienie dotyczy
+wielouczelnianości, nie każdego wdrożenia.
+
+`SearchAnonThrottle` dziedziczy po `AnonRateThrottle`. Bez `NUM_PROXIES`
+(nie ma go w settings) DRF kluczuje po **całym** `X-Forwarded-For`, gdy jest
+obecny (`rest_framework/throttling.py:29-39`); bez XFF — po `REMOTE_ADDR`,
+czyli po `scope["client"]`.
+
+`get_client_ip` bierze **skrajnie prawy** XFF (`django_bpp/client_ip.py:33-36`).
+
+---
+
+## 3. Trzy projekty, nie jeden
+
+| # | Projekt | Status |
+|---|---|---|
+| 1 | **Hosting MCP w BPP** — routing ASGI, klient w procesie, auth, strona | **ten spec** |
+| 2 | Szwy w `bpp-mcp` | **zrobione** — 0.4.0 na PyPI |
+| 3 | Konsolidacja reguł tokenu w `oauth_mcp/tokens.py` | osobny spec, §13 |
+
+Projekt 3 wypada świadomie: to zmiana w **testowanej warstwie
+uwierzytelniania**, a wrzucanie jej do środka feature'a było błędem.
+
+---
+
+## 4. Decyzje architektoniczne
+
+| # | Decyzja | Uzasadnienie |
+|---|---|---|
+| D1 | Narzędzia z `bpp-mcp`, zero kopii w `src/` | jedna implementacja |
+| D2 | Klucz `"lifespan"` w istniejącym `ProtocolTypeRouter` **+ leniwy start w osobnym zadaniu** | klucz naprawia uvicorna; leniwy start naprawia Daphne; osobne zadanie jest warunkiem poprawności, nie optymalizacją (§2.4, §5.1) |
+| D3 | `stateless_http=True` + `json_response=True` | recykling workera zabiłby sesję stateful; `json_response` znosi SSE |
+| D4 | Dane przez `httpx.ASGITransport` na `django_asgi_app` | ta sama ścieżka kodu, bez gniazda |
+| D5 | Wewnętrzne żądanie dziedziczy `Host`, scheme i IP klienta | inaczej `Uczelnia=None` → obejście bramki i wyciek (§7.2) |
+| D6 | Przekazujemy **wyłącznie `Bearer`** | inaczej Basic i sesja omijają model OAuth (§7.1) |
+| D7 | Polityka dostępu dziedziczona z `/api/v1/` | anonim czyta publiczne, bearer odblokowuje resztę |
+| D8 | Read-only | narzędzia wykonują wyłącznie GET |
+| **D9** | **`BppClient` per żądanie**, przez property w obiekcie lifespanu | `_api_root` zapamiętany w `__init__` (§2.2); rozwiązuje też cache i semafor |
+| **D10** | **Dwa adresy: `/mcp` (publiczny) i `/mcp/auth` (zawsze 401 bez tokenu)**, z przepisaniem ścieżki | SDK montuje `Route`, nie `Mount`, więc drugi adres wymaga przepisania (§5.1); SDK nie zna trybu „anonim tak, zły token nie" (§2.3); dwa adresy usuwają zależność od nieznanego zachowania klientów |
+| **D11** | **Jawny sufit czasu i współbieżności — egzekwowany w `BppClientInProcess`** | timeouty `httpx` martwe przy `ASGITransport`, gunicornowy `timeout` to heartbeat (§2.4) |
+| **D12** | `follow_redirects=False` w kliencie w procesie | inaczej `SECURE_SSL_REDIRECT` po cichu podwaja każde żądanie, a kreator instalacji przekierowuje na HTML (§7.6) |
+
+---
+
+## 5. Architektura
+
+```
+ gunicorn+uvicorn (prod) | Daphne (dev, testy)
+ │
+ django_bpp.asgi:application = ProtocolTypeRouter
+ │ (ten sam co dziś, +1 klucz)
+ ┌────────────────────┼────────────────────┐
+ "lifespan" "http" "websocket"
+ │ │ │
+ LifespanMcp RouterHttp AllowedHostsOriginValidator
+ (nowy) (nowy) + AuthMiddlewareStack
+ │ ┌─────┴─────┐ + URLRouter
+ │ /mcp* reszta ── BEZ ZMIAN ──
+ │ │ │
+ │ BramkaBearera │ ← 401 + WWW-Authenticate (§5.4)
+ │ │ │
+ │ KontekstMcp │ ← host/scheme/IP/limity → ContextVar
+ │ │ │
+ └── start ──→ aplikacja MCP│
+ │ │
+ register_tools │
+ │ │
+ BppClient (per żądanie, D9)
+ │ │
+ ASGITransport │
+ │ │
+ KlientScope ─────────┤ ← nadpisuje scope["client"] (§5.2)
+ │ │
+ └──→ django_asgi_app ←┘
+```
+
+### 5.1 Lifespan — dopisany klucz plus leniwy start
+
+**Klucz.** `ProtocolTypeRouter` nie odrzuca lifespanu; odrzuca to, czego nie ma
+w słowniku (§2.4). Dopisujemy:
+
+```python
+application = ProtocolTypeRouter(
+ {
+ "http": RouterHttp(mcp_app, django_asgi_app),
+ "websocket": AllowedHostsOriginValidator( # BEZ ZMIAN
+ AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
+ ),
+ "lifespan": LifespanMcp(mcp_app), # ← nowy klucz
+ }
+)
+```
+
+**Świadomie NIE zastępujemy routera własnym dyspozytorem** — przepisanie
+brałoby na nas odtworzenie gałęzi websocketowej wraz z
+`AllowedHostsOriginValidator` i `AuthMiddlewareStack`, czyli ruszanie działającej
+warstwy bezpieczeństwa po to, żeby naprawić rzecz, która jej nie dotyczy.
+Patchowanie `channels` odrzucone: jego zachowanie nie jest błędne, a monkeypatch
+byłby niewidoczny w `asgi.py` i pękłby przy aktualizacji.
+
+**Leniwy start — w osobnym, długowiecznym zadaniu.** Sam klucz naprawia
+uvicorna i **nie naprawia Daphne**, pod którym chodzi `run-site run`
+i `channels_live_server` (§2.4).
+
+Wersja 3 kazała wchodzić w `session_manager.run()` **z zadania żądania**. To
+było błędne i jest przyczyną blokera BL-3 trzeciej recenzji:
+`run()` to `async with lifespan, anyio.create_task_group()`
+(`streamable_http_manager.py:145`), więc zadanie, które w to wchodzi, staje się
+**host taskiem** grupy. Konsekwencje:
+
+- jeśli w tym zadaniu jest aktywny **jakikolwiek inny cancel scope** (a §5.2
+ każe owijać obsługę w `fail_after`), wyjście z niego rzuci
+ `RuntimeError: Attempted to exit a cancel scope that isn't the current
+ task's current cancel scope` (`anyio/_backends/_asyncio.py:470-474`).
+ Pierwsze żądanie pada, grupa jest już oznaczona jako wystartowana
+ (`_has_started`, `manager:143`), więc **każde kolejne też pada**;
+- anulowanie host taska (timeout, `application_close_timeout` Daphne,
+ `daphne/server.py:281-289`) **zatruwa grupę** — późniejsze `start()` kończy
+ się `RuntimeError("Child exited without calling task_status.started()")`.
+ Bez restartu procesu nie ma wyjścia, bo `run()` wchodzi się raz na instancję.
+
+Dlatego host taskiem musi być **zadanie tła**, nigdy żądanie:
+
+```python
+class StartMcp:
+ """Wejście w session_manager.run() dokładnie raz, w OSOBNYM zadaniu."""
+
+ def __init__(self, mcp_app):
+ self._app = mcp_app
+ self._gotowe = asyncio.Event()
+ self._zadanie: asyncio.Task | None = None
+ self._blad: BaseException | None = None
+
+ async def zapewnij(self) -> None:
+ if self._zadanie is None:
+ self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj())
+ await self._gotowe.wait()
+ if self._blad is not None:
+ raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad
+
+ async def _trzymaj(self) -> None:
+ """Host task grupy zadań — żyje tyle, co proces."""
+ try:
+ async with self._app.router.lifespan_context(self._app):
+ self._gotowe.set()
+ await asyncio.Event().wait() # nigdy
+ except BaseException as exc: # noqa: BLE001 — re-raise przez zapewnij()
+ self._blad = exc
+ self._gotowe.set()
+ raise
+```
+
+`zapewnij()` musi być **pierwszą instrukcją** w `RouterHttp`, przed założeniem
+jakiegokolwiek scope'u (§5.2). `LifespanMcp` woła je na `lifespan.startup`;
+pod Daphne pierwsze wywołanie przychodzi z pierwszego żądania. Tworzenie
+zadania jest jednorazowe (`self._zadanie is None`), a `Event` szereguje
+równoległych czekających — blokada nie jest potrzebna, bo `create_task`
+w jednej pętli nie ma punktu przerwania między testem a przypisaniem.
+
+**Błąd startu** raportujemy jako `lifespan.startup.failed` z komunikatem. Pod
+gunicornem oznacza to `should_exit` w uvicornie (`lifespan/on.py:58-60`) →
+worker pada → gunicorn respawnuje. Czyli **boot-loop, nie cisza** — i tak ma
+być. Pod Daphne błąd wraca z `zapewnij()` jako 503 na `/mcp`.
+
+**Zatrucie grupy mimo wszystko.** Wyjątek dziecka grupy poza `serve_connection`
+anuluje scope grupy; pod uvicornem po starcie skutkuje to procesem, który żyje,
+ale każde `/mcp` zwraca 500 aż do recyklingu `max_requests`. `StartMcp`
+wystawia więc **stan gotowości** do healthchecku (§10.3), a `RouterHttp` przy
+martwym zadaniu zwraca 503 zamiast 500.
+
+**`RouterHttp`** — dopasowanie **dokładne**, nie prefiksowe:
+
+| Ścieżka | Trafia do |
+|---|---|
+| `/mcp` | `BramkaBearera(tryb=publiczny)` → aplikacja MCP |
+| `/mcp/auth` | `BramkaBearera(tryb=wymagany)` → **przepisanie ścieżki na `/mcp`** → aplikacja MCP |
+| `/mcp/` | Django (strona HTML, §8) |
+| reszta | Django |
+
+**Przepisanie ścieżki jest obowiązkowe** (BL-1): SDK montuje trasę jako
+`Route`, nie `Mount` (`lowlevel/server.py:808-813`), czyli regex `^/mcp$`.
+Bez przepisania `/mcp/auth` dostałoby **404**, a `redirect_slashes` Starlette
+próbowałoby jeszcze `/mcp/auth/`. Przepisujemy `scope["path"]` i `raw_path`
+po przejściu przez bramkę.
+
+Prefiksowe `/mcp*` z wersji 3 było też sprzeczne z §8: `/mcp/` trafiłoby do
+Starlette i dostało 307 na `/mcp`.
+
+`GET /mcp` z `Accept: text/html` (człowiek wkleił adres do przeglądarki)
+przekierowujemy na `/mcp/` — inaczej SDK odda 406
+(`streamable_http.py:698-703`).
+
+### 5.2 `KontekstMcp` i `KlientScope` — dwie warstwy, nie jedna
+
+Wersja 2 zakładała, że jedna warstwa przed aplikacją MCP ustawi wszystko.
+**To było błędne dla IP klienta**: `ASGITransport` buduje scope wewnętrznego
+żądania sam i wpisuje tam własne `client` (`httpx/_transports/asgi.py:92,117`).
+Warstwa stojąca przed aplikacją MCP nie ma jak tego dosięgnąć.
+
+Potrzebne są więc dwie:
+
+**`KontekstMcp`** (przed aplikacją MCP) zdejmuje z zewnętrznego żądania i kładzie
+w `ContextVar`:
+
+| Co | Po co |
+|---|---|
+| `Host` | rozstrzygnięcie `Uczelnia` → bramka API i `ukryte_statusy` (§7.2) |
+| scheme z `X-Forwarded-Proto` | inaczej `SECURE_SSL_REDIRECT` zapętla (§7.6) |
+| IP z `get_client_ip` | throttling (§7.3) |
+| bearer | przekazanie do wewnętrznego żądania |
+| budżet czasu i współbieżności | D11 |
+
+**`KlientScope`** (wokół `django_asgi_app`, czyli **za** transportem) nadpisuje
+`scope["client"]` wartością z ContextVar. To jedyne miejsce, w którym da się to
+zrobić.
+
+Reset ContextVarów na wyjściu obowiązkowy.
+
+**Sufit czasu (D11) — egzekwowany W KLIENCIE, nie wokół aplikacji.**
+
+Timeouty `httpx` są przy `ASGITransport` martwe, a gunicornowy `timeout` to
+heartbeat. Jedno `tools/call` rozwija się na N żądań do API bez żadnego limitu.
+
+Wersja 3 kazała owinąć obsługę w `anyio.fail_after(...)` w `KontekstMcp`.
+**To nie działa** (bloker BL-2 trzeciej recenzji). W trybie bezstanowym serwer
+JSON-RPC startuje jako zadanie **grupy menedżera**
+(`streamable_http_manager.py:243`), a handler narzędzia jest spawnowany do
+grupy dispatchera (`jsonrpc_dispatcher.py:493,684`). `fail_after` wokół
+`mcp_app(...)` anuluje wyłącznie zadanie żądania, które czeka na odpowiedź —
+czyli:
+
+- **narzędzie i jego N żądań do Django biegną dalej**, tylko nikt już na nie
+ nie czeka;
+- `http_transport.terminate()` **nie jest w `finally`** (`manager:246-249`),
+ więc `read_stream` nigdy się nie zamyka → `run_stateless_server` żyje
+ wiecznie → **wyciek zadania na każdy timeout**.
+
+Ten sam wyciek zachodzi przy **każdym rozłączeniu klienta** w trakcie
+`tools/call`, nie tylko przy timeoucie. Przy `proxy_read_timeout 300s` to
+realny wektor wyczerpania zasobów: N rozłączeń = N wiecznych zadań.
+
+Dlatego budżet trafia tam, gdzie faktycznie wykonuje się praca:
+
+- `KontekstMcp` kładzie **deadline** (monotoniczny znacznik) i **semafor**
+ w `ContextVar`;
+- `BppClientInProcess` czyta deadline w `_request` i **sam podnosi `BppError`**,
+ gdy budżet wyczerpany, a każde pojedyncze wywołanie owija w `fail_after`
+ na resztkę budżetu;
+- semafor ogranicza liczbę równoległych żądań wewnętrznych tego samego
+ wywołania narzędzia.
+
+Dzięki temu przekroczenie limitu **zatrzymuje pracę**, a nie tylko przestaje na
+nią czekać — i wraca do klienta jako czytelny błąd narzędzia.
+
+Wartości do ustalenia w planie; **istnienie limitu jest decyzją specu**.
+
+**Wyciek przy rozłączeniu** jest niezależny od naszego budżetu i leży w SDK.
+Do planu: zgłoszenie upstream oraz — do czasu naprawy — licznik żywych zadań
+menedżera wystawiony w healthchecku (§10.3), żeby wyciek był widoczny, zanim
+położy proces.
+
+### 5.3 `BppClient` per żądanie (D9)
+
+`BppClient` zapamiętuje `api_root` w `__init__`, `Config` jest `frozen`, a klient
+z lifespanu jest jeden na proces (§2.2). Zamiast obchodzić to nadpisywaniem
+prywatnego `_full_url`, korzystamy z furtki w kontrakcie `register_tools`:
+lifespan oddaje **obiekt o tych samych atrybutach**, którego `client` jest
+property:
+
+```python
+class KontekstZadania:
+ """Spełnia kontrakt KontekstApp, ale klienta oddaje per żądanie."""
+
+ bearer_provider = None # host wielo-użytkownikowy — nigdy cache tokenu
+
+ @property
+ def client(self) -> BppClient:
+ return klient_zadania.get() # ContextVar, ustawiony przez KontekstMcp
+```
+
+`KontekstMcp` zakłada klienta na czas żądania:
+
+```python
+BppClientInProcess(
+ Config(base_url=f"{scheme}://{host}", transport="stdio"),
+ transport=httpx.ASGITransport(
+ app=KlientScope(django_asgi_app),
+ raise_app_exceptions=False, # 500 → BppNetworkError, nie traceback
+ ),
+ tryb_auth=TrybAuth.W_PROCESIE,
+ max_retries=0,
+)
+```
+
+Rozwiązuje naraz: **`_api_root` z właściwego hosta**, **cache izolowany**
+(świeży klient = świeży słownik, szew `_slownik_cache` niepotrzebny),
+**semafor per żądanie**. Koszt to `AsyncClient` na żądanie — bez puli połączeń
+i bez TLS to praktycznie sam obiekt Pythona.
+
+`follow_redirects=False` (D12) — patrz §7.6.
+
+Odziedziczona mechanika: retry martwy (`max_retries=0`), timeouty martwe (sufit
+z §5.2), auto-follow paginacji i mapowanie błędów bez zmian.
+
+### 5.4 `BramkaBearera` — własna warstwa 401 (D10)
+
+SDK nie zna trybu „anonim dozwolony, zły token odrzucony" (§2.3): albo
+`token_verifier` i 401 dla wszystkich bez tożsamości, albo brak weryfikacji
+w ogóle. Piszemy więc własną warstwę ASGI **przed** aplikacją MCP, w dwóch
+wariantach montażu:
+
+| Adres | Brak nagłówka | Zły / wygasły / bez scope | Ważny |
+|---|---|---|---|
+| `/mcp` | przepuść jako anonim | **401** + `WWW-Authenticate` | przepuść z tożsamością |
+| `/mcp/auth` | **401** + `WWW-Authenticate` | **401** + `WWW-Authenticate` | przepuść z tożsamością |
+
+**Weryfikacja musi sprawdzać to samo, co DRF — nie mniej.**
+`ApiReadOnlyForBearerMiddleware._ma_wazny_bearer` (`middleware.py:31-40`)
+patrzy wyłącznie na `AccessToken.is_valid()`. `StrictOAuth2Authentication`
+sprawdza dodatkowo `user.is_active` i scope `read`
+(`authentication.py:33-35`). Gdyby `BramkaBearera` skopiowała tylko ten
+pierwszy wzorzec, **token bez scope'u `read` przeszedłby bramkę i padł dopiero
+w DRF** — czyli wróciłby jako błąd w treści JSON-RPC z HTTP 200, a klient nigdy
+nie zrobiłby ponownej autoryzacji. Bramka musi więc sprawdzać: `is_valid()`
+**oraz** `user.is_active` **oraz** scope `read`.
+
+To jest **trzecia kopia** tych reguł w repo (obok `authentication.py`
+i `middleware.py`). Wersja 2 specu proponowała konsolidację w
+`oauth_mcp/tokens.py` i słusznie odsunąłem ją do projektu 3 jako zmianę
+w testowanej warstwie auth — ale trzecia kopia jest ceną, którą trzeba nazwać.
+**Do planu: minimalny wspólny helper czytany przez wszystkie trzy miejsca**,
+bez ruszania struktury klas DRF. Pełna konsolidacja zostaje projektem 3.
+
+Lookup idzie przez `sync_to_async` (kontekst async). Ponieważ dzieje się **poza
+cyklem żądania Django** (brak sygnałów `request_started`/`request_finished`),
+obowiązkowe jest `close_old_connections()` wokół zapytania — inaczej połączenia
+do bazy nie są sprzątane.
+
+**Dlaczego dwa adresy.** `/mcp` nigdy nie zmusi klienta do logowania, bo anonim
+dostaje 200 — a czy klienci MCP odpalają OAuth przy 401 na **późniejszym**
+żądaniu, jest nieznane i zależne od implementacji. `/mcp/auth` odrzuca 401-ką
+już `initialize`, czyli w jedynym momencie, co do którego mamy pewność, że
+obsługuje go każdy klient. Użytkownik wybiera adres świadomie, a projekt
+przestaje zależeć od cudzego zachowania.
+
+`WWW-Authenticate`:
+`Bearer resource_metadata="https:///.well-known/oauth-protected-resource"`
+
+**`token_verifier` SDK nie jest używany** — jego montaż wyłączyłby anonima.
+
+---
+
+## 6. Discovery
+
+Nowy widok w `oauth_mcp/views_metadata.py` —
+`/.well-known/oauth-protected-resource` (RFC 9728), bliźniak istniejącego
+RFC 8414, ten sam wzorzec `build_absolute_uri`:
+
+```json
+{ "resource": "https:///mcp",
+ "authorization_servers": ["https://"],
+ "scopes_supported": ["read"],
+ "bearer_methods_supported": ["header"] }
+```
+
+Poprzedni spec (§6) przypisywał ten plik pakietowi `bpp-mcp`. **Przenosi się do
+BPP** — Resource Serverem jest instancja.
+
+### 6.1 Allowlista hostów aplikacji MCP
+
+**[probe]** `TransportSecuritySettings` to druga, niezależna lista dozwolonych
+hostów. Trzy rzeczy, których wersja 2 nie widziała:
+
+1. **Inna semantyka niż `ALLOWED_HOSTS`** — dopasowanie dokładne albo `host:*`
+ (`transport_security.py:49-60`). Django-owe `.domena` i `*` **nie działają**;
+ trzeba je przetłumaczyć, a testy repo używają `ALLOWED_HOSTS = ["*"]`.
+2. **Sprawdzany jest też `Origin`** (`:65-70`) — klienci przeglądarkowe
+ i MCP Inspector go wysyłają. Niezgodny `Origin` daje **403**
+ (`transport_security.py:113-114`), nie 421. Decyzja: `allowed_origins`
+ wypełniamy tymi samymi hostami co `allowed_hosts`, ze schematem `https://`
+ (plus `http://` dla `localhost` w dev).
+3. **`host=` bez `transport_security` po cichu wyłącza ochronę**
+ (`lowlevel/server.py:738-744`). Dlatego **zawsze** przekazujemy jawne
+ `TransportSecuritySettings`.
+
+Lista budowana **fabryką aplikacji** (`build_application()`), nie przy imporcie
+modułu — inaczej `settings.ALLOWED_HOSTS` nadpisane w teście nigdy do niej nie
+dotrze (§11).
+
+---
+
+## 7. Bezpieczeństwo
+
+### 7.1 Przekazujemy WYŁĄCZNIE `Bearer`
+
+Allowlista nagłówków wewnętrznego żądania: **`Authorization` tylko gdy schemat
+to `Bearer`**, plus `Accept` i `Content-Type`. Odcinamy `Cookie`,
+`X-Forwarded-*`, `Referer` i resztę.
+
+- **`Cookie`** → `SessionAuthentication` (`base.py:1058`) uwierzytelnia sesją:
+ pełne uprawnienia, bez zgody, bez scope, bez revoke. CSRF nie chroni, bo GET.
+- **`Authorization: Basic`** → `BasicAuthentication` (`base.py:1059`)
+ uwierzytelnia hasłem, a `ApiReadOnlyForBearerMiddleware` sprawdza **wyłącznie
+ prefiks `bearer `** (`middleware.py:33`) — więc dla Basica warstwa read-only
+ **nie istnieje**.
+
+**Gdzie ta allowlista musi stać.** SDK przekazuje narzędziu **cały obiekt
+`Request` zewnętrznego żądania** przez `SessionMessage.metadata.request`
+(`streamable_http.py:608-611`) — z `Cookie` włącznie. Dziś czyta z niego tylko
+`bearer_from_request` (`bpp_mcp/auth.py:46-53`), więc D6 jest w praktyce
+zachowane, ale **przez dyscyplinę kodu w cudzym pakiecie, nie przez izolację**.
+Nasza allowlista działa przy budowaniu żądania wewnętrznego
+(`BppClientInProcess`), i to ona jest właściwą zaporą. Test „`Cookie` nie
+uwierzytelnia" **musi być po stronie BPP**, nie tylko w `bpp-mcp` — bo to my
+ponosimy skutki, gdyby tamta dyscyplina się zmieniła.
+
+### 7.2 Propagacja `Host` — to jest kontrola dostępu
+
+Wewnętrzne żądanie **musi** nieść `Host` zewnętrznego. Inaczej `Uczelnia=None`
+(§2.5), a wtedy `BramkaApiV1` **przepuszcza bezwarunkowo** — API wyłączone przez
+administratora staje się dostępne przez `/mcp` — a `ukryte_statusy=None`
+przestaje filtrować rekordy ukrytych statusów. W instalacji wielouczelnianej to
+wyciek między uczelniami.
+
+**Propagacja to za mało — potrzebna jest bramka fail-closed.** `ALLOWED_HOSTS`
+na produkcji zawiera hosty infrastrukturalne (`127.0.0.1`, `appserver`,
+`appserver:8000` — `production.py:154-159`), które przechodzą **obie** bramki
+(allowlista transportowa SDK i `get_host()` Django), a nie mają swojego `Site`.
+Propagacja `Host` przenosi je wtedy wiernie — prosto w `Uczelnia=None`, czyli
+w otwartą bramkę API. `curl -H 'Host: appserver' https:///mcp`
+obchodziłby wyłącznik API i filtr ukrytych statusów.
+
+Dlatego `mcp_server.uczelnia` sprawdza **per żądanie**, czy host jednoznacznie
+wskazuje uczelnię, i odrzuca **421 Misdirected Request**, gdy nie wskazuje:
+
+- host ma swój `Site` → wymagamy niepustego `get_for_request` (to samo
+ rozstrzygnięcie, którego użyje potem `BramkaApiV1`);
+- host nie ma `Site` → dopuszczalne **wyłącznie** w instalacji
+ jednouczelnianej, gdzie fallback na `SITE_ID` jest legalny; przy zerze
+ i przy dwóch-i-więcej uczelniach → odmowa.
+
+Sprawdzenie **musi** być per żądanie: allowlista transportowa (§6.1) liczy się
+przy imporcie `django_bpp.asgi`, a import modułu ASGI nie może zależeć od bazy;
+powiązanie host→`Site`→`Uczelnia` to zaś dane, nie konfiguracja.
+
+**Poprawność samego nagłówka sprawdzamy PRZED bramką uczelni.** `Host` walidujemy
+`django.http.request.split_domain_port` (ten sam `host_validation_re`, którego
+używa `HttpRequest.get_host()`), bo Django-owe `get_host()` na tej ścieżce nie
+biegnie. Bez tego host z niepoprawnym portem (`:abc`) przechodził
+allowlistę SDK (dopasowuje `host:*` przez `startswith`, portu nie waliduje)
+i bramkę uczelni (obcinała port), a wysypywał się dopiero w `httpx.URL` przy
+budowie `base_url` — wyjątkiem, który nie jest ani `httpx.HTTPError`, ani
+`BppError`, więc wrapper narzędzi zgłaszał go do Rollbara. Jedno anonimowe
+żądanie = jedno zgłoszenie; `curl` w pętli wyczerpywał kwotę. Niepoprawny `Host`
+dostaje teraz **421**, zanim cokolwiek dotknie bazy.
+
+#### Czego ta propagacja NIE daje — dryf w `/api/v1/`
+
+**Propagacja `Host` steruje bramką API (`BramkaApiV1`) i filtrem ukrytych
+statusów korekty — ale NIE zawęża do uczelni typowanych endpointów publikacji.**
+`WydawnictwoCiagleViewSet`, `WydawnictwoZwarteViewSet`, `PatentViewSet`,
+`Praca_DoktorskaViewSet` i `Praca_HabilitacyjnaViewSet` mają wyłącznie
+`UkryjStatusyKorektyMixin` oraz `exclude(nie_eksportuj_przez_api=True)`; nie
+wołają `scope_rekord_do_uczelni` ani `scope_rekord_api`. Pełną politykę
+(`api_v1.scoping.scope_rekord_api` = uczelnia + ukryte statusy +
+`nie_eksportuj_przez_api`) stosują tylko `szukaj`, `zapytanie` i `recent`.
+
+W instalacji wielouczelnianej oznacza to, że pod hostem uczelni A typowany
+endpoint publikacji zwróci także rekordy uczelni B. **To jest stan
+PREEGZYSTUJĄCY w `/api/v1/`, nie wprowadzony przez `/mcp`** — `/mcp` woła te
+same endpointy, więc dziedziczy ich zachowanie i nic tu nie pogarsza ani nie
+naprawia. Naprawa idzie osobnym PR-em do `/api/v1/` (decyzja właściciela);
+tutaj spec ma tylko nie deklarować izolacji, której nie ma. Patrz §15.9.
+
+### 7.3 Throttling
+
+`ASGITransport` ustawia `client=("127.0.0.1", 123)`, więc bez interwencji
+wszyscy anonimowi dzielą jeden kubełek. `KlientScope` (§5.2) wstawia IP
+z `get_client_ip`. **Nie dokładamy `X-Forwarded-For`** — DRF bez `NUM_PROXIES`
+kluczowałby po całym nagłówku (§2.5), a my chcemy `REMOTE_ADDR`.
+
+Osobno: strefa nginx `bpp_api` (60 r/s) **jest omijana**, bo wewnętrzne żądania
+nie przechodzą przez nginx, a jedno `tools/call` rozwija się na N żądań. Sufit
+z D11 jest jedyną obroną na tej ścieżce — dlatego jest decyzją, nie pomiarem.
+
+### 7.4 Rekurencja i zakres URL-i
+
+`ASGITransport` dostaje `django_asgi_app`, nie `application`. `BppClientInProcess`
+odrzuca ścieżki spoza `/api/v1/` **oraz sprawdza host** — `_full_url` przyjmuje
+URL-e bezwzględne wprost (`client.py:183-184`), a paginacyjne `next` je niosą.
+
+### 7.5 Widoczność i Rollbar
+
+Wewnętrzne żądania nie trafiają do access-logu nginx ani uvicorna. Logujemy je
+w `KontekstMcp` (ścieżka, kod, czas, obecność bearera — **nigdy token**).
+
+`CustomRollbarNotifierMiddleware` (`base.py:346`) **nie obejmuje** wyjątków
+z `LifespanMcp`, `KontekstMcp` ani aplikacji MCP — te nie trafią do Rollbara
+same. Warstwa MCP woła `rollbar.report_exc_info()` jawnie i **nie wkłada
+nagłówków do `extra_data`**. (`"authorization"` jest w `ROLLBAR_SCRUB_FIELDS`,
+`base.py:1777`, ale to dotyczy żądań Django.)
+
+**Wyjątki narzędzi wymagają osobnego przechwycenia.** SDK zamienia każdy
+wyjątek handlera na `CallToolResult(is_error=True)` (`mcpserver/server.py:423-424`),
+więc **nic nie dociera** do warstwy ASGI — jawne `report_exc_info()` tam by ich
+nie zobaczyło. Potrzebny jest cienki wrapper wokół `register_tools`, który
+owija każde zarejestrowane narzędzie i raportuje wyjątek, zanim SDK go zamieni
+na wynik. Bez tego awarie narzędzi są **całkowicie niewidoczne** w monitoringu.
+
+### 7.6 Przekierowania — `follow_redirects=False` (D12)
+
+`BppClient` domyślnie podąża za przekierowaniami (`client.py:153`). W procesie
+daje to dwa ciche problemy:
+
+1. **`SECURE_SSL_REDIRECT=True`** (`production.py:168`) + scheme `http` → 301 na
+ `https` → httpx podąża → **każde żądanie wewnętrzne wykonuje się dwa razy**,
+ niewidocznie. Propagacja scheme (§5.2) tego unika, ale `follow_redirects`
+ maskowałoby błąd, gdyby propagacja przestała działać.
+2. **`FirstRunWizardMiddleware`** — `DEFAULT_SKIP_PREFIXES` to `/static/`,
+ `/media/`, `/__debug__/`, `/admin/` (`first_run_wizard/middleware.py:22-27`),
+ a `FIRST_RUN_WIZARD_SKIP_PREFIXES` nie jest ustawione. Na świeżej instalacji
+ wewnętrzne `/api/v1/` **dostaje przekierowanie do kreatora**, httpx za nim
+ idzie, `resp.json()` na HTML-u rzuca `ValueError`, którego `_request` nie
+ mapuje (`client.py:271`) → surowy traceback w narzędziu. **To zamyka §15.6:
+ tak, przekierowuje.**
+
+Przy `follow_redirects=False` oba przypadki kończą się czytelnym `BppError` ze
+statusem 301/302.
+
+### 7.7 Co jeszcze wypada przez ominięcie middleware
+
+Przegląd `MIDDLEWARE` (`base.py:328-351`) dla **zewnętrznego** `/mcp`:
+
+| Middleware | Skutek |
+|---|---|
+| `SecurityMiddleware` | `/mcp` bez HSTS i nagłówków bezpieczeństwa — dokłada je nginx |
+| `MaliciousRequestBlockingMiddleware` | `/mcp` poza filtrem botów — nowa powierzchnia bez tej siatki |
+| `CountdownBlockingMiddleware` | **nie zwalnia `/api/`** (`django_countdown/middleware.py:22`): w trybie odliczania wewnętrzne żądania dostaną HTML blokady, a `/mcp` nie będzie blokowany. Decyzja: `/mcp` odbijamy **razem** z resztą serwisu |
+| `FirstRunWizardMiddleware` | §7.6 |
+| `CustomRollbarNotifierMiddleware` | §7.5 |
+| `Session`/`CSRF`/`Auth`/`SessionSecurity`/`Axes` | nieistotne **pod warunkiem D6** |
+| `LocaleMiddleware` | komunikaty narzędzi w domyślnym języku — kosmetyka |
+
+### 7.8 Audience (RFC 8707) — deklarujemy i egzekwujemy
+
+PRM (§6) deklaruje zasób `https:///mcp`. Klient MCP zgodny ze
+specyfikacją autoryzacji 2025-06-18 **MUSI** wysłać `resource=` z kanonicznym
+adresem serwera MCP — **wartość bierze z adresu, pod który się łączy, nie
+z naszego PRM**, więc usunięcie pola z PRM niczego by nie zmieniło. DOT zapisuje
+ją w `Grant.resource`, a potem w `AccessToken.resource` (oauthlib wciąga każdy
+parametr query/body do `Request._params`).
+
+Wynikały z tego dwie rzeczy naraz:
+
+- **nie egzekwowaliśmy** audience — `BramkaBearera` sprawdzała ważność, konto
+ i scope, ale nie `allows_audience()`; token zawężony do hosta uczelni A
+ przechodził bramkę pod hostem uczelni B;
+- **domyślny walidator DOT** (`validate_resource_as_url_prefix`, prefiks
+ ścieżki) odrzucał 401-ką żądanie wewnętrzne serwera MCP do własnego
+ `/api/v1/` — bo `/api/v1/...` nie zaczyna się od `/mcp`. Zmierzone na pełnym
+ stosie: token zgodnego klienta przechodził bramkę, a KAŻDE wywołanie
+ narzędzia kończyło się `BppError: Błąd HTTP 401`. Cała ścieżka `/mcp/auth`
+ była dla zgodnego klienta martwa.
+
+Rozwiązanie: audience egzekwujemy **po originie**, nie po prefiksie ścieżki.
+`BramkaBearera` woła `token.allows_audience(f"{schemat}://{host}/mcp")`,
+a `OAUTH2_PROVIDER["RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR"] =
+"oauth_mcp.audience.waliduj_audience_po_originie"`. Uzasadnienie: `/mcp`
+i `/api/v1/` to **jeden** Resource Server — ta sama instancja, te same dane, ten
+sam scope `read`, a narzędzie MCP z definicji czyta `/api/v1/`. RFC 8707 chroni
+przed użyciem tokenu u **innego** Resource Servera, a w BPP „inny" znaczy „inny
+host" — i to nadal egzekwujemy. Ograniczenie, którego to nie usuwa (token bez
+`resource`), jest w §15.10.
+
+---
+
+## 8. Strona `/mcp/` dla człowieka
+
+`GET /mcp/` → widok Django z instrukcją. `GET /mcp` z `Accept: text/html` →
+redirect na `/mcp/` (§5.1). Treść wzorowana na `mcp.sentry.dev`: **oba adresy**
+(publiczny i z logowaniem) z wyjaśnieniem różnicy, gotowe wklejki dla klientów,
+link do `bpp-mcp` dla stdio. Adresy przez `build_absolute_uri`. Teksty przez
+`gettext` (`LocaleMiddleware` działa dla tej ścieżki — idzie przez Django).
+
+---
+
+## 9. Zależności i obraz
+
+Dochodzi `bpp-mcp>=0.4,<0.5`. **Zmierzone po faktycznym `uv lock`
+(Task 1 planu): 21 nowych pakietów, nie dziesięć.** Wcześniejsze oszacowanie
+liczyło tylko zależności bezpośrednie i było zaniżone ponad dwukrotnie —
+poniżej stan rzeczywisty:
+
+| Nowe | Uwaga |
+|---|---|
+| `httpx` | klient `BppClient` |
+| **`httpx2`** | `mcp` 2.0 przeszło na drugą bibliotekę HTTP |
+| `pydantic` | kompilowana, spora |
+| `opentelemetry-api`, `jsonschema`, `mcp-types`, `python-multipart`, `sse-starlette`, `starlette`, `typing-inspection` | |
+
+Zależności przechodnie, których pierwsze oszacowanie nie objęło:
+`annotated-types`, `httpcore`, `httpcore2`, `httpx2-jsfetch`, `jsonschema`,
+`jsonschema-specifications`, **`pydantic-core`**, `referencing`, **`rpds-py`**,
+`truststore`, `typing-inspection`.
+
+Już obecne: `anyio` (4.11.0), `pyjwt`, `uvicorn`.
+
+**Dwa ustalenia, które podnoszą wagę ryzyka z §15:**
+
+1. **`pydantic-core` i `rpds-py` to kompilowane rozszerzenia Rust** — osobne
+ koła per platforma. To realny wzrost rozmiaru obrazu i osobna powierzchnia
+ CVE dla bramki Trivy, której pierwsze oszacowanie w ogóle nie przewidywało.
+2. **Dwa równoległe stosy HTTP w drzewie**: `bpp-mcp` zależy od `httpx`, a
+ `mcp` 2.x od `httpx2` — każdy z własnym `httpcore`. Dochodzi też
+ `httpx2-jsfetch` z markerem `sys_platform == 'emscripten'`, czyli kod WASM
+ nieużywany w kontenerze Linux, ale obecny w locku. To wzmacnia argument za
+ przejściem `bpp-mcp` na `httpx2` (§13, §15.5) — dziś wozimy oba.
+
+**Kolizji `starlette` nie ma** — `channels[daphne]` ciągnie `asgiref` i `daphne`,
+nie `starlette`. To zamyka §15.2.
+
+Do sprawdzenia przed scaleniem:
+- czy `mcp 2.0` nie wymaga `anyio` nowszego niż 4.11.0 (venv `bpp-mcp` ma 4.14.1),
+- rozmiar obrazu produkcyjnego,
+- **bramka Trivy** — dziesięć nowych pakietów to dziesięć nowych źródeł CVE
+ w łańcuchu; `python-multipart` ma historię.
+
+**`httpx` i `httpx2` naraz** to dublet, nie konflikt. Najczystsze wyjście wraca
+do projektu 2: `bpp-mcp` przechodzi na `httpx2` (0.5.0) i przestaje wozić drugi
+klient — pod warunkiem, że `httpx2` ma `ASGITransport` o tej samej semantyce,
+bo na niej stoi D4. Poza zakresem tego specu (§13).
+
+---
+
+## 10. Wdrożenie
+
+`bpp-deploy` **wymaga zmian**:
+
+1. **ModSecurity/CRS** — wyłączenie obejmuje dziś tylko
+ `^/(bpp|api/v1)/zapytanie/`. Ciało JSON-RPC na `/mcp`, zwłaszcza
+ z DjangoQL w argumentach, będzie skanowane i 403 z WAF-a jest bardzo
+ prawdopodobny. Potrzebne wyłączenie dla `/mcp`, zawężone jak istniejące.
+2. **Strefa `limit_req`** — `/mcp` wpada dziś pod `location /` (100 r/s). Sufit
+ z D11 działa **wewnątrz** aplikacji; strefa nginx chroni przed zalewem samych
+ żądań MCP. Do zmierzenia, ale nie jest to już „poza zakresem" (§7.3).
+
+3. **Healthcheck `/mcp` — osobny adres `GET /mcp/status`.** Grupa zadań
+ menedżera może zostać „zatruta" (wyjątek dziecka anuluje scope grupy) —
+ proces wtedy żyje, ale **każde `/mcp` zwraca błąd aż do recyklingu
+ `max_requests`**. `RouterHttp` przy martwym menedżerze oddaje kontrolowane
+ 503 (kolejność sprawdzeń ma znaczenie: `zapewnij()` rzuca, więc jego wyjątek
+ musi być złapany, inaczej gałąź 503 jest martwym kodem), a monitoring pyta
+ `/mcp/status`: `200 {"status":"ok"}` albo `503 {"status":"error"}` plus
+ `wystartowany`/`zywy` w treści (§5.1, §5.2).
+
+ **Nie wpinamy tego w `/health/`**: tamten adres jest sondą Dockera, więc 503
+ z powodu awarii izolowanej do MCP restartowałby cały appserver. `/mcp/status`
+ obsługuje warstwa ASGI — bez uwierzytelnienia, bez bazy i bez middleware —
+ i sam wyzwala leniwy start, żeby pod Daphne nie raportować „padnięte" na
+ świeżym, zdrowym workerze. Do monitoringu zewnętrznego wystarczy zwykły
+ `GET`; nginx nie wymaga zmian (adres wpada pod `location /`).
+
+Bez zmian: `stateless_http` czyni recykling `--max-requests` nieszkodliwym,
+`json_response` znosi problem `proxy_read_timeout`.
+
+---
+
+## 11. Testy
+
+Konwencja repo: pytest, bez `unittest.TestCase`, `model_bakery.baker`.
+**Repo nie ma `pytest-asyncio`** — korutyny odpala się wzorcem z
+`django_bpp/tests/test_asgi_notifications.py:27` (`asyncio.run` w osobnym
+wątku), więc dostęp do bazy wymaga `transaction=True` albo tego samego wzorca.
+
+**Aplikacja musi być budowana fabryką** `build_application()`, nie tworzona przy
+imporcie: (i) allowlista hostów SDK liczona z `settings` przy imporcie nie
+zobaczy override'u z fixture'a `settings` (§6.1); (ii)
+`session_manager.run()` można wejść **raz na instancję**, więc modułowe
+`application` nie nadaje się do wielokrotnego lifespanu w testach.
+
+**Routing i lifespan**
+- klucz `"lifespan"` → `startup.complete`; błąd startu → `startup.failed`
+- **leniwy start**: żądanie do `/mcp` **bez** lifespanu (symulacja Daphne)
+ startuje menedżera i zwraca 200 — plus test, że drugie żądanie **nie**
+ próbuje startować ponownie
+- ruch spoza `/mcp` do Django; `websocket` do channels
+- `GET /mcp` z `Accept: text/html` → redirect na `/mcp/`
+- **`/mcp/auth` dociera do aplikacji MCP** — dowód, że przepisanie ścieżki
+ działa (bez niego SDK dałoby 404, BL-1)
+- `/mcp/` (ze slashem) trafia do **Django**, nie do Starlette
+- start menedżera odbywa się w **osobnym zadaniu**: `zapewnij()` wywołane
+ z zadania, w którym jest aktywny `fail_after`, **nie** psuje ani tego
+ żądania, ani kolejnych (regresja na BL-3)
+- po przekroczeniu budżetu czasu **narzędzie przestaje wykonywać żądania**
+ do Django (licznik wywołań się nie zwiększa) — a nie tylko przestajemy
+ czekać (regresja na BL-2)
+
+**Bezpieczeństwo (każdy osobno)**
+- `Cookie` wysłany do `/mcp` **nie** uwierzytelnia — także po wewnętrznym
+ przekierowaniu (httpx ma własny cookie-jar, `_client.py:566`)
+- `Authorization: Basic` **nie** jest przekazywany
+- **test wielo-hostowy**: fixture'y są (`src/fixtures/conftest_multisite.py:16-48`,
+ rejestracja `conftest.py:61`); domeny wymagają nadpisania `ALLOWED_HOSTS`
+ per test (wzorzec: `admin_dashboard/tests/test_cache_vary_host.py:46`) — dwie
+ `Uczelnia`, dwa hosty, każdy widzi swoje
+- `api_v1_wlaczone=False` → `/mcp` też odmawia
+- rekord o ukrytym statusie nie wycieka
+- dwa różne IP → dwa kubełki throttlingu
+- ścieżka **i host** spoza `/api/v1/` odrzucone (§7.4)
+- `/mcp` bez tokenu → 200; `/mcp/auth` bez tokenu → **401** z `WWW-Authenticate`;
+ zły token na obu → **401**
+
+**Klient**
+- `BppClientInProcess` zwraca to samo, co żądanie sieciowe
+- cache izolowany między żądaniami
+- przekierowanie kreatora → czytelny `BppError`, nie `ValueError` (§7.6)
+- przekroczenie sufitu czasu → kontrolowany błąd (D11)
+
+**Protokół**
+- `initialize` → `tools/list` → `tools/call`, bez sieci; 11 narzędzi + 1 prompt
+- klient testowy musi wysyłać `Content-Type: application/json`
+ (`transport_security.py:96-100`)
+
+**Ręcznie**
+- podłączenie prawdziwego klienta Claude do obu adresów i pełny taniec OAuth
+
+---
+
+## 12. Kryteria akceptacji
+
+- `POST /mcp` odpowiada na `initialize`, `tools/list`, `tools/call` bez tokenu,
+ zwracając dane publiczne identyczne z `/api/v1/`.
+- `POST /mcp/auth` bez tokenu → **401** z `WWW-Authenticate` wskazującym PRM.
+- Zły token na obu adresach → **401**.
+- `Cookie` i `Basic` **nie** uwierzytelniają.
+- Dwie uczelnie na dwóch hostach widzą **swoje** dane; `api_v1_wlaczone=False`
+ blokuje również `/mcp`.
+- **`/mcp` działa pod Daphne** (`run-site run`) — leniwy start.
+- `/.well-known/oauth-protected-resource` poprawne przy wielu hostach.
+- `GET /mcp/` renderuje stronę z oboma adresami właściwymi dla hosta.
+- Narzędzia z `bpp_mcp` — **żadnej kopii** w `src/`.
+- Jawny sufit czasu **zatrzymuje pracę narzędzia**, nie tylko odpowiedź (D11).
+- `/mcp/auth` dociera do aplikacji MCP (przepisanie ścieżki, BL-1).
+- Menedżer sesji startuje w osobnym zadaniu i przeżywa żądanie, w którym go
+ wywołano — także gdy w tym żądaniu był aktywny `fail_after` (BL-3).
+- Healthcheck rozróżnia stan procesu od stanu endpointu MCP (§10.3):
+ `GET /mcp/status` → 200/503, a `/mcp` przy martwym menedżerze → **503**,
+ nie wyciekający wyjątek.
+- Host, dla którego nie da się jednoznacznie rozstrzygnąć uczelni (np.
+ `Host: appserver`), dostaje **421** — nie obsługę z otwartą bramką API (§7.2).
+- `BppError` (normalny komunikat do użytkownika) **nie** trafia do Rollbara;
+ nieoczekiwany wyjątek narzędzia i awaria startu menedżera — **tak** (§7.5).
+- Każde żądanie `/mcp` zostawia wpis w logu: ścieżka, kod, czas, host,
+ **obecność** bearera — nigdy jego wartość (§7.5).
+- Testy zielone; `ruff` czysty; `pre-commit` bez uwag.
+- Newsfragment w `src/bpp/newsfragments/`.
+- Istniejące testy `oauth_mcp` i `api_v1` przechodzą **bez modyfikacji**.
+
+---
+
+## 13. Poza zakresem
+
+- Narzędzia mutujące.
+- **Rozszerzenie allowlisty DCR** o ChatGPT/Cursor — osobna decyzja (§1.1).
+- Konsolidacja reguł tokenu — projekt 3 (§3).
+- **Przejście `bpp-mcp` na `httpx2`** (0.5.0) — osobne wydanie (§9).
+- WebMCP jako API przeglądarki.
+- Wygaszenie `bpp-mcp`.
+- Zasoby (`resources`) MCP. Prompt `zloz_zapytanie_djangoql` **wchodzi**, bo
+ `register_tools` rejestruje go razem z narzędziami.
+- RFC 8707 / audience binding.
+
+Strefa `limit_req` **przestaje** być poza zakresem — patrz §10.2.
+
+---
+
+## 14. Rozliczenie z recenzjami
+
+**Wersja 1 → 2:** bloker lifespanu potwierdzony, ale rozwiązywalny; bloker
+hosta potwierdzony i groźniejszy (kontrola dostępu, nie błąd 400); dodane
+`Basic`, cache, throttling, ModSecurity, allowlista DCR; 7 → 11 narzędzi;
+zakres rozbity na trzy projekty.
+
+**Wersja 2 → 3:** B1 (`base_url` per żądanie) → D9; B2 (Daphne) → leniwy start;
+B3 (401 + PRM) → D10 dwa adresy; plus dwanaście poprawek.
+
+**Wersja 3 → 4.** Trzecia recenzja **potwierdziła D9 jako zamknięte** i wskazała
+trzy nowe blokery — wszystkie w rozwiązaniach B2/B3, nie w architekturze:
+
+| Ustalenie | Rozstrzygnięcie w tej wersji |
+|---|---|
+| **BL-1** `/mcp/auth` → 404; SDK montuje `Route` (`^/mcp$`), nie `Mount` | `RouterHttp` **przepisuje ścieżkę** na `/mcp` po bramce; dopasowanie dokładne, nie prefiksowe (§5.1) |
+| **BL-2** `fail_after` wokół aplikacji nie zatrzymuje narzędzia i wycieka zadanie | budżet i semafor **w `BppClientInProcess`**, egzekwowane w `_request` (§5.2) |
+| **BL-3** leniwy start pęka pod własnym `fail_after`; anulowanie hosta zatruwa grupę | start w **osobnym, długowiecznym zadaniu**; `zapewnij()` przed jakimkolwiek scope'em (§5.1) |
+| bramka sprawdzała mniej niż DRF (brak scope, `is_active`) | pełny zestaw reguł + `close_old_connections` (§5.4) |
+| `KlientScope` bez decyzji o pustym ContextVar | fail-closed (§5.2) |
+| `Origin` → 403, nie 421; brak reguły dla `allowed_origins` | §6.1 |
+| Rollbar nie zobaczy wyjątków narzędzi | wrapper wokół `register_tools` (§7.5) |
+| `Cookie` dociera do narzędzia przez `SessionMessage` | nazwane; test po stronie BPP (§7.1) |
+| wyciek zadania przy **każdym** rozłączeniu klienta | nazwane, healthcheck + zgłoszenie upstream (§5.2, §10.3) |
+| zatrucie grupy także pod uvicornem | healthcheck, 503 zamiast 500 (§10.3) |
+| `Uczelnia=None` przesadzone dla single-site | sprostowane — dotyczy wielouczelnianości (§2.5) |
+| `stateless + GET → 405` niepotwierdzone | wycofane z §2.3, test warunkowy (§11) |
+| dwie ścieżki protokołu, różny czas życia | §2.3, uwzględnione w §5.2 |
+
+**Wycofane twierdzenia wersji 3:** „B2 zamknięty przez D2" i „B3 zamknięty przez
+D10" — obie były przedwczesne; zamykają je dopiero poprawki tej wersji.
+„Rollbar zamyka §15.7" — nie obejmowało wyjątków narzędzi.
+
+---
+
+## 15. Otwarte kwestie
+
+Zamknięte: **§15.1 wersji 2** (kształt endpointu → D10); **`starlette`** (brak
+kolizji, §9); **kreator przekierowuje** (tak, §7.6); **Rollbar** (§7.5);
+**`anyio`** — `mcp 2.0` wymaga `>=4.9`, BPP ma 4.11.0 (§9).
+
+Zostają:
+
+1. Wpływ dziesięciu nowych pakietów na bramkę Trivy i rozmiar obrazu (§9).
+2. Wartości budżetu czasu i semafora (D11) — pomiar; **istnienie limitu jest
+ zdecydowane** (§5.2).
+3. Kształt reguły wyłączającej ModSecurity dla `/mcp` (§10.1).
+4. Czy strefa `limit_req` dla `/mcp` jest potrzebna (§10.2).
+5. Czy `httpx2` ma `ASGITransport` o semantyce wymaganej przez D4 — warunek
+ przejścia `bpp-mcp` na jeden klient HTTP (§9, §13).
+6. Czy `stateless_http` + `GET` daje 405 — do sprawdzenia przed napisaniem
+ testu (§2.3, §11).
+7. Czy klienci Claude walidują pole `resource` w PRM względem adresu serwera —
+ przy dwóch adresach (`/mcp`, `/mcp/auth`) może wymagać metadanych pod
+ `/.well-known/oauth-protected-resource/<ścieżka>` (RFC 9728 §3.1). **Do
+ sprawdzenia ręcznie na obu adresach.**
+8. Zgłoszenie upstream braku `terminate()` w `finally`
+ (`streamable_http_manager.py:246-249`) — wyciek zadania przy rozłączeniu
+ klienta (§5.2).
+9. **Typowane endpointy publikacji w `/api/v1/` nie są zawężane do uczelni**
+ (`wydawnictwo_ciagle`, `wydawnictwo_zwarte`, `patent`, `praca_doktorska`,
+ `praca_habilitacyjna` — same `UkryjStatusyKorektyMixin` +
+ `nie_eksportuj_przez_api`, bez `scope_rekord_do_uczelni`). Dryf
+ PREEGZYSTUJĄCY, nie wprowadzony przez ten PR; `/mcp` woła te same endpointy,
+ więc dziedziczy zachowanie. Do zamknięcia osobnym PR-em w `/api/v1/`
+ (decyzja właściciela). Szczegóły: §7.2.
+10. **Token OAuth wydany BEZ parametru `resource` pozostaje nieograniczony.**
+ `AccessToken.allows_audience` zwraca dla pustej listy `True`, zanim
+ jakikolwiek walidator zostanie zawołany — to kontrakt DOT (kompatybilność
+ wstecz z tokenami sprzed RFC 8707) i nie da się go zmienić bez przedefinio-
+ wania semantyki dla całego projektu. W instalacji wielouczelnianej taki
+ token, wydany pod hostem uczelni A, zadziała pod hostem uczelni B. Tokeny
+ klientów zgodnych ze specyfikacją MCP 2025-06-18 **niosą** `resource`, więc
+ są związane z originem (§7.2, `oauth_mcp.audience`); dotyczy to wyłącznie
+ tokenów zakładanych ręcznie i klientów nieimplementujących RFC 8707.
diff --git a/pyproject.toml b/pyproject.toml
index 1c5b15820..739f72b80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,9 @@ dependencies = [
"Pillow>=12.2,<14",
"django-grappelli>=5.0.0,<6",
"django-multiseek==0.10.3",
+ # Narzędzia MCP + szwy do hostowania ich w naszym procesie (spec §2.2).
+ # Ciągnie mcp>=2,<3, starlette i httpx — patrz spec §9.
+ "bpp-mcp>=0.4,<0.5",
"django-braces>=1.15.0",
"django-loginas==0.3.14",
"django-webmaster-verification==0.4.3",
diff --git a/src/bpp/newsfragments/mcp-hostowany.feature.rst b/src/bpp/newsfragments/mcp-hostowany.feature.rst
new file mode 100644
index 000000000..403fede18
--- /dev/null
+++ b/src/bpp/newsfragments/mcp-hostowany.feature.rst
@@ -0,0 +1,5 @@
+Serwis wystawia własny serwer MCP pod adresem ``/mcp`` (publiczny) oraz
+``/mcp/auth`` (z logowaniem OAuth). Asystenci AI obsługujący zdalne serwery
+MCP łączą się jednym adresem — bez instalowania czegokolwiek i bez podawania
+adresu uczelni. Instrukcja podłączenia jest na stronie ``/mcp/``. Stan samego
+endpointu (niezależny od sondy ``/health/`` Dockera) pokazuje ``/mcp/status``.
diff --git a/src/django_bpp/asgi.py b/src/django_bpp/asgi.py
index 01787f16b..2d368efa2 100644
--- a/src/django_bpp/asgi.py
+++ b/src/django_bpp/asgi.py
@@ -22,11 +22,23 @@
# channels_broadcast działają dalej bez zmian.
websocket_urlpatterns = liveops.routing.websocket_urlpatterns
+# Aplikacja MCP i jej routing. Import PO get_asgi_application() — mcp_server
+# sięga po modele przez klienta w procesie.
+from mcp_server.aplikacja import build_application # noqa: E402
+from mcp_server.routing import LifespanMcp, RouterHttp # noqa: E402
+
+_mcp_app, _mcp_start = build_application()
+
application = ProtocolTypeRouter(
{
- "http": django_asgi_app,
+ "http": RouterHttp(_mcp_app, django_asgi_app, _mcp_start),
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
+ # ProtocolTypeRouter nie „nie obsługuje" lifespanu — po prostu nie ma
+ # dla niego klucza i rzuca ValueError, który uvicorn loguje jako
+ # „appears unsupported" i ignoruje. Menedżer sesji MCP nigdy wtedy nie
+ # wstaje (spec §2.4, §5.1).
+ "lifespan": LifespanMcp(_mcp_start),
}
)
diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py
index 2ca0c3958..52effdb3e 100644
--- a/src/django_bpp/settings/base.py
+++ b/src/django_bpp/settings/base.py
@@ -478,6 +478,7 @@ def _elem_in_sys_argv(possible):
"rest_framework",
"oauth_mcp",
"oauth2_provider",
+ "mcp_server",
"django_filters",
"api_v1",
"adminsortable2",
@@ -1995,4 +1996,15 @@ def iter_namespace(ns_pkg):
"ROTATE_REFRESH_TOKEN": True,
"ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 30, # 30 min
"REFRESH_TOKEN_EXPIRE_SECONDS": 60 * 60 * 24 * 7, # 7 dni (NIE None!)
+ # RFC 8707: klient MCP zgodny ze spec 2025-06-18 MUSI wysyłać
+ # `resource=https:///mcp`, więc tokeny są zawężone do adresu
+ # serwera MCP. Domyślny walidator DOT dopasowuje PREFIKSEM ŚCIEŻKI, przez
+ # co żądanie wewnętrzne serwera MCP do własnego `/api/v1/` dostawało 401 —
+ # zalogowana ścieżka `/mcp/auth` była martwa dla każdego zgodnego klienta.
+ # Nasz walidator egzekwuje ORIGIN (schemat+host+port), bo `/mcp`
+ # i `/api/v1/` to jeden Resource Server; pełne uzasadnienie w docstringu
+ # `oauth_mcp.audience`.
+ "RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR": (
+ "oauth_mcp.audience.waliduj_audience_po_originie"
+ ),
}
diff --git a/src/django_bpp/urls.py b/src/django_bpp/urls.py
index 20768cd39..946a0b4c3 100644
--- a/src/django_bpp/urls.py
+++ b/src/django_bpp/urls.py
@@ -68,6 +68,7 @@ def protected_media_serve(request, path, document_root=None):
),
path("formdefaults/", include("formdefaults.urls")),
path("", include("oauth_mcp.urls")),
+ path("mcp/", include("mcp_server.urls", namespace="mcp_server")),
url(r"^favicon\.ico$", cache_page(60 * 60)(favicon)),
path("test_403/", login_required(test_403_view)),
path("test_500/", login_required(test_500_view)),
diff --git a/src/mcp_server/__init__.py b/src/mcp_server/__init__.py
new file mode 100644
index 000000000..ae5873c15
--- /dev/null
+++ b/src/mcp_server/__init__.py
@@ -0,0 +1,6 @@
+"""Hostowany endpoint MCP serwisu BPP (/mcp, /mcp/auth).
+
+Narzędzia pochodzą z pakietu ``bpp-mcp``; ta aplikacja dostarcza wyłącznie
+warstwę montażu: routing ASGI, kontekst żądania, uwierzytelnianie i klienta
+wołającego własną aplikację Django w procesie.
+"""
diff --git a/src/mcp_server/aplikacja.py b/src/mcp_server/aplikacja.py
new file mode 100644
index 000000000..085a27200
--- /dev/null
+++ b/src/mcp_server/aplikacja.py
@@ -0,0 +1,194 @@
+"""Budowa aplikacji MCP: serwer SDK + narzędzia z bpp-mcp + polityka hostów."""
+
+from __future__ import annotations
+
+import functools
+from contextlib import asynccontextmanager
+
+import rollbar
+from bpp_mcp import register_tools
+from bpp_mcp.client import BppError
+from django.conf import settings
+from mcp.server.mcpserver import MCPServer
+from mcp.server.transport_security import TransportSecuritySettings
+
+from mcp_server.klient import zbuduj_klienta
+from mcp_server.start import StartMcp
+
+
+class KontekstZadania:
+ """Kontrakt lifespanu ``register_tools``, ale klient jest PER ŻĄDANIE.
+
+ ``register_tools`` dokumentuje, że lifespan ma oddać ``KontekstApp`` *albo
+ obiekt o tych samych atrybutach*. Korzystamy z tej furtki: ``client`` jest
+ property, więc wrappery narzędzi — które czytają go przy każdym wywołaniu —
+ dostają klienta zbudowanego dla bieżącego żądania (spec D9).
+ """
+
+ #: Host wielo-użytkownikowy: token jednej osoby nie może trafić do żądania
+ #: innej, więc NIGDY nie sięgamy do lokalnego cache tokenów.
+ bearer_provider = None
+
+ @property
+ def client(self):
+ return zbuduj_klienta()
+
+
+def _dozwolone_hosty() -> list[str]:
+ """Przetłumacz ``ALLOWED_HOSTS`` na semantykę SDK.
+
+ SDK dopasowuje host dokładnie albo wzorcem ``host:*`` — Django-owe
+ ``.domena`` i ``*`` nie działają (spec §6.1). ``*`` w ALLOWED_HOSTS
+ (używane w testach) mapujemy na wyłączenie sprawdzania, bo nie ma dla
+ niego odpowiednika.
+
+ **Ta lista NIE jest kontrolą wielotenantową** i nie próbuje nią być.
+ ``TransportSecuritySettings`` chroni przed DNS rebindingiem — przed
+ przeglądarką namówioną na wysłanie żądania z cudzą nazwą. Tożsamość
+ uczelni rozstrzyga ``mcp_server.uczelnia`` PER ŻĄDANIE, i tam jest
+ fail-closed (spec §7.2).
+
+ Dlaczego nie zawężamy tej listy do hostów mapujących się na ``Site``:
+ funkcja biegnie przy budowie aplikacji, czyli przy imporcie
+ ``django_bpp.asgi``, a import modułu ASGI nie może zależeć od dostępnej
+ bazy. Zawężenie do samego ``DJANGO_BPP_HOSTNAMES`` (bez bazy) też nie
+ rozwiązywałoby problemu: hostname w konfiguracji nie dowodzi istnienia
+ ``Site`` ani powiązanej ``Uczelnia`` — dałoby więc poczucie
+ bezpieczeństwa bez samego bezpieczeństwa, a przy okazji rozjechałoby
+ ``ALLOWED_HOSTS`` z allowlistą MCP w dev i w testach.
+ """
+ hosty: list[str] = []
+ for wpis in settings.ALLOWED_HOSTS:
+ if wpis == "*":
+ return []
+ hosty.append(wpis.lstrip("."))
+ hosty.append(f"{wpis.lstrip('.')}:*")
+ return hosty
+
+
+def _z_raportowaniem(serwer: MCPServer) -> MCPServer:
+ """Owiń ``serwer.tool()`` raportowaniem wyjątków narzędzi do Rollbara.
+
+ ``MCPServer._handle_call_tool`` łapie KAŻDY wyjątek handlera i zamienia
+ go w ``CallToolResult(is_error=True)`` (patrz
+ ``mcp.server.mcpserver.server``) — na tym poziomie zostaje po nim tylko
+ ``logger.exception(...)``. Nic z tego nie dociera do warstwy ASGI, więc
+ ``CustomRollbarNotifierMiddleware`` (który łapie wyjątki Django) nigdy nie
+ zobaczy awarii narzędzia — musimy zgłosić ją sami, ZANIM SDK ją pochłonie
+ (spec §7.5).
+
+ Zweryfikowane w zainstalowanym ``mcp`` (2.x), nie zgadywane:
+
+ * ``MCPServer.tool()`` to DEKORATOR-FABRYKA — ``tool(**kw)`` zwraca
+ ``decorator``, a ``decorator(fn)`` REJESTRUJE narzędzie natychmiast
+ (``self.add_tool(fn, ...)``) i oddaje ``fn`` bez zmian. Rejestracja
+ dzieje się więc w momencie dekorowania, nie leniwie — stąd podmiana
+ ``serwer.tool`` musi zdążyć PRZED wywołaniem ``register_tools``.
+ * Schemat argumentów i wykrycie parametru ``Context`` liczy
+ ``Tool.from_function`` na PODANEJ (owinięta) funkcji przez
+ ``inspect.signature(fn, eval_str=True)`` i ``typing.get_type_hints(fn)``
+ — obie te funkcje stdlib jawnie idą po łańcuchu ``__wrapped__``, więc
+ ``functools.wraps`` na wewnętrznym ``wrapper`` wystarcza, by dostały
+ oryginalny sygnaturę/adnotacje (i ``__globals__`` oryginału — istotne,
+ bo ``bpp_mcp.tools``/``server`` mają ``from __future__ import
+ annotations``, więc adnotacja ``Context`` jest stringiem do
+ wyliczenia). Potwierdzone w praktyce testem
+ ``test_context_i_schemat_przetrwaly_wrapper`` — bez tego wrapper
+ zarejestrowałby ``ctx`` jako zwykły, wymagany parametr wejściowy
+ zamiast wstrzykiwanego kontekstu.
+
+ **``BppError`` NIGDY nie jest raportowany — także przy statusie 5xx.**
+ W ``bpp-mcp`` to normalny kanał komunikatów do użytkownika, nie awaria —
+ 401 anonima przy DjangoQL, 404 nieistniejącej encji, odrzucony argument,
+ przekroczony budżet czasu. Na nieuwierzytelnionym, publicznym endpoincie
+ raportowanie ich znaczyłoby, że dowolna osoba z internetu wyczerpuje
+ kwotę Rollbara jednym ``curl``-em w pętli i topi realne alerty (zmierzone
+ przez recenzenta: 4 zgłoszenia na 4 wywołania). Okno maintenance albo
+ niedokończony kreator dawałyby zgłoszenie z KAŻDEGO wywołania.
+
+ Wcześniejsza wersja tej funkcji robiła wyjątek dla statusu >= 500, z
+ uzasadnieniem, że taki status pochodzi z odpowiedzi naszej aplikacji
+ Django na żądanie wewnętrzne, więc mówi o awarii po naszej stronie, a
+ „użytkownik nie ma jak wymusić 5xx z /api/v1/”. Recenzent zmierzył
+ empirycznie, że ta gałąź działała ODWROTNIE do zamierzenia:
+
+ * Timeout DjangoQL (``statement_timeout`` w
+ ``api_v1/viewsets/zapytanie.py``) daje 503 DETERMINISTYCZNIE przy zbyt
+ szerokim zapytaniu — dowolny anonim wymusza go samym zapytaniem.
+ ``retry_5xx=False`` na tej ścieżce (``bpp_mcp/client.py``) przenosi
+ ``status_code=503`` aż do ``BppError``, więc trafiał do Rollbara przy
+ KAŻDYM wywołaniu (zmierzone: 3 wywołania = 3 zgłoszenia) — czyli
+ dokładnie ten hałas od anonima, który reszta tej funkcji ma wygaszać.
+ * Prawdziwe 500 z Django, przeciwnie, NIE trafiało do Rollbara: narzędzia
+ z ``retry_5xx=True`` (wszystkie poza ``zapytanie_*``) po wyczerpaniu
+ prób gubią ``status_code`` w końcowym
+ ``raise BppNetworkError("Nie udało się pobrać ... po N próbach")``
+ (``bpp_mcp/client.py``) — warunek ``status >= 500`` nigdy nie był
+ prawdziwy tam, gdzie miał być.
+
+ Gałąź usunięto całkowicie — ``BppError`` idzie tą samą, jedną ścieżką
+ niezależnie od statusu. Realne 500 i tak nie jest ślepą plamą: żądanie
+ do ``/api/v1/`` idzie przez własną aplikację Django W PROCESIE
+ (``mcp_server.klient``), a na TEJ ścieżce Django biegnie normalnie —
+ ``CustomRollbarNotifierMiddleware`` (``bpp.middleware``) zgłasza wyjątek
+ z żądania wewnętrznego do Rollbara, zanim ``BppClient`` zdąży zamienić
+ odpowiedź na czytelny ``BppError``.
+ """
+ oryginalny = serwer.tool
+
+ def tool(*args, **kwargs):
+ dekorator = oryginalny(*args, **kwargs)
+
+ def opakuj(fn):
+ @functools.wraps(fn)
+ async def wrapper(*a, **kw):
+ try:
+ return await fn(*a, **kw)
+ except BppError:
+ # Nigdy nie raportuj — patrz docstring funkcji.
+ raise
+ except Exception:
+ rollbar.report_exc_info()
+ raise
+
+ return dekorator(wrapper)
+
+ return opakuj
+
+ serwer.tool = tool
+ return serwer
+
+
+def build_application():
+ """Zbuduj aplikację ASGI serwera MCP i jej ``StartMcp``.
+
+ FABRYKA, nie moduł: allowlista hostów jest liczona z ``settings``, więc
+ zbudowanie aplikacji przy imporcie uniemożliwiłoby testom nadpisanie
+ ``ALLOWED_HOSTS`` (spec §11).
+ """
+
+ @asynccontextmanager
+ async def lifespan(_serwer):
+ yield KontekstZadania()
+
+ serwer = MCPServer("bpp", version="1", lifespan=lifespan)
+ register_tools(_z_raportowaniem(serwer))
+
+ hosty = _dozwolone_hosty()
+ bezpieczenstwo = TransportSecuritySettings(
+ enable_dns_rebinding_protection=bool(hosty),
+ allowed_hosts=hosty,
+ allowed_origins=[f"https://{h}" for h in hosty if not h.endswith(":*")],
+ )
+
+ aplikacja = serwer.streamable_http_app(
+ streamable_http_path="/mcp",
+ # Bezstanowo: recykling workera (--max-requests) zabiłby sesję
+ # stateful w środku pracy użytkownika. json_response znosi SSE, więc
+ # proxy_read_timeout nginksa przestaje być tematem (spec D3).
+ stateless_http=True,
+ json_response=True,
+ transport_security=bezpieczenstwo,
+ )
+ aplikacja.state.serwer_mcp = serwer
+ return aplikacja, StartMcp(aplikacja)
diff --git a/src/mcp_server/apps.py b/src/mcp_server/apps.py
new file mode 100644
index 000000000..d59ebad3c
--- /dev/null
+++ b/src/mcp_server/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class McpServerConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "mcp_server"
+ verbose_name = "Serwer MCP"
diff --git a/src/mcp_server/auth.py b/src/mcp_server/auth.py
new file mode 100644
index 000000000..ba7f9f818
--- /dev/null
+++ b/src/mcp_server/auth.py
@@ -0,0 +1,161 @@
+"""Weryfikacja bearera przed aplikacją MCP.
+
+SDK MCP montuje ``RequireAuthMiddleware`` wyłącznie przy podanym
+``token_verifier``, a ten odrzuca 401-ką KAŻDE żądanie bez tożsamości — czyli
+wyklucza dostęp anonimowy, na którym nam zależy. Stąd własna warstwa
+(spec §5.4).
+"""
+
+from __future__ import annotations
+
+import json
+
+from asgiref.sync import sync_to_async
+from django.db import close_old_connections
+
+from mcp_server.kontekst import schemat_zadania
+
+WYMAGANY_SCOPE = "read"
+
+
+def _sprawdz(raw: str, zasob: str) -> bool:
+ """Czy token jest ważny wg TYCH SAMYCH reguł, co DRF.
+
+ ``StrictOAuth2Authentication`` wymaga ważnego tokenu, aktywnego konta ORAZ
+ scope'u ``read``. Sprawdzanie tu mniej oznaczałoby, że token bez scope'u
+ przechodzi bramkę i pada dopiero w DRF — czyli wraca jako błąd w treści
+ JSON-RPC z HTTP 200, a klient nigdy nie ponawia autoryzacji.
+
+ ``zasob`` to kanoniczny adres serwera MCP dla BIEŻĄCEGO żądania
+ (``:///mcp``) — audience w rozumieniu RFC 8707. Sprawdzamy
+ je, bo deklarujemy je w dokumencie PRM (``oauth_mcp.views_metadata``):
+ deklaracja bez egzekwowania jest gorsza niż jej brak. Bez tego token
+ zawężony do hosta jednej uczelni przechodziłby bramkę pod hostem drugiej.
+ Semantykę dopasowania (origin, nie prefiks ścieżki) i powód tego wyboru
+ opisuje ``oauth_mcp.audience``.
+
+ Wyszukanie tokenu oddajemy walidatorowi DOT (``_load_access_token``)
+ zamiast pisać własny ``filter(token=raw)``. Powody, oba istotne:
+
+ * ``token`` to NIEZINDEKSOWANY ``TextField``; indeksowaną (i ``unique``)
+ kolumną jest ``token_checksum`` (``oauth2_provider/models.py``). Filtr po
+ ``token`` skanował całą tabelę tokenów, więc tani flood błędnymi
+ bearerami obciążał bazę — na publicznym, nieuwierzytelnionym endpoincie.
+ * Sposób liczenia checksumy (sha256 hex z UTF-8) oraz obsługa trybu
+ „hashed at rest" (``COMPLIANT_BCP_RFC9700_TOKEN_STORAGE``, przy którym
+ kolumna ``token`` jest PUSTA i wyszukanie po niej nie znalazłoby nic)
+ są szczegółem DOT. Zduplikowane u nas rozjechałyby się przy pierwszej
+ zmianie w pakiecie — a rozjazd oznaczałby cichą odmowę dostępu wszystkim.
+
+ Klasę walidatora bierzemy z ``oauth2_settings``, nie zaszywamy
+ ``OAuth2Validator`` — projekt może ją podmienić i wtedy bramka ma iść tą
+ samą drogą, co DRF.
+ """
+ from oauth2_provider.settings import oauth2_settings
+
+ close_old_connections() # poza cyklem żądania Django — brak sygnałów
+ try:
+ token = oauth2_settings.OAUTH2_VALIDATOR_CLASS()._load_access_token(raw)
+ if token is None or not token.is_valid():
+ return False
+ if token.user is None or not token.user.is_active:
+ return False
+ if not token.allows_audience(zasob):
+ return False
+ return bool(token.allow_scopes([WYMAGANY_SCOPE]))
+ finally:
+ close_old_connections()
+
+
+zweryfikuj_token = sync_to_async(_sprawdz, thread_sensitive=False)
+
+
+class BramkaBearera:
+ """Przepuszcza anonima (gdy wolno) i odrzuca nieważny token 401-ką."""
+
+ def __init__(self, aplikacja, *, wymagany: bool) -> None:
+ self._aplikacja = aplikacja
+ self._wymagany = wymagany
+
+ async def __call__(self, scope, receive, send):
+ naglowki = {k.lower(): v for k, v in scope.get("headers", [])}
+ surowy = naglowki.get(b"authorization", b"").decode(errors="replace")
+ schemat, _, wartosc = surowy.partition(" ")
+
+ if not surowy:
+ if self._wymagany:
+ await self._odmow(scope, send)
+ return
+ await self._aplikacja(scope, receive, send)
+ return
+
+ if schemat.lower() != "bearer" or not await zweryfikuj_token(
+ wartosc.strip(), self._zasob(scope)
+ ):
+ await self._odmow(scope, send)
+ return
+
+ await self._aplikacja(scope, receive, send)
+
+ @staticmethod
+ def _naglowki(scope) -> dict[str, str]:
+ """Nagłówki ASGI jako słownik ``str``→``str``.
+
+ Klucze ASGI są BAJTAMI — bez ``.decode()`` na kluczu każde
+ ``get("host", …)`` z napisowym kluczem chybia i zawsze trafia w wartość
+ domyślną. ``errors="replace"`` na wartości: nagłówek to dane od klienta,
+ nie coś, czemu ufamy jako poprawnemu UTF-8 (ta sama decyzja co w
+ ``routing.RouterHttp._dane``).
+ """
+ return {
+ k.lower().decode(): v.decode(errors="replace")
+ for k, v in scope.get("headers", [])
+ }
+
+ @classmethod
+ def _zasob(cls, scope) -> str:
+ """Kanoniczny adres serwera MCP dla tego żądania (audience RFC 8707).
+
+ Ten sam adres, który ``oauth_mcp.views_metadata`` publikuje w PRM jako
+ ``resource`` i który klient MCP wysyła w ``resource=`` przy autoryzacji.
+ Host i schemat czytamy z nagłówków tak samo jak ``_odmow`` (jedno
+ źródło prawdy: ``schemat_zadania``), a nie z ``dane_zadania`` —
+ ``BramkaBearera`` bywa używana samodzielnie, bez ustawionego kontekstu.
+ """
+ naglowki = cls._naglowki(scope)
+ host = naglowki.get("host", "localhost")
+ schemat = schemat_zadania(naglowki, scope.get("scheme", "http"))
+ return f"{schemat}://{host}/mcp"
+
+ async def _odmow(self, scope, send) -> None:
+ naglowki = self._naglowki(scope)
+ host = naglowki.get("host", "localhost")
+ # NIE ``scope["scheme"]``: nginx stoi w osobnym kontenerze, a
+ # ``docker/appserver/gunicorn_conf.py`` nie ustawia
+ # ``forwarded_allow_ips`` (default gunicorna to 127.0.0.1), więc
+ # uvicorn NIE ufa nagłówkom proxy i zostawia scheme="http". PRM
+ # wskazywałby wtedy na ``http://`` i psuł discovery OAuth — czyli
+ # dokładnie to, po co ten adres istnieje. Nagłówek czytamy tak samo
+ # jak ``RouterHttp._dane`` (jedno źródło prawdy: ``schemat_zadania``).
+ schemat = schemat_zadania(naglowki, scope.get("scheme", "http"))
+ prm = f"{schemat}://{host}/.well-known/oauth-protected-resource"
+ cialo = json.dumps(
+ {
+ "error": "invalid_token",
+ "error_description": "Wymagany ważny token OAuth.",
+ }
+ ).encode()
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 401,
+ "headers": [
+ (b"content-type", b"application/json"),
+ (
+ b"www-authenticate",
+ f'Bearer resource_metadata="{prm}"'.encode(),
+ ),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": cialo})
diff --git a/src/mcp_server/klient.py b/src/mcp_server/klient.py
new file mode 100644
index 000000000..be9d749c2
--- /dev/null
+++ b/src/mcp_server/klient.py
@@ -0,0 +1,109 @@
+"""Klient wołający własną aplikację Django w procesie."""
+
+from __future__ import annotations
+
+import time
+
+import httpx
+from bpp_mcp.client import BppClient, BppError, TrybAuth
+from bpp_mcp.config import Config
+
+from mcp_server.kontekst import biezace, domena_hosta
+
+
+class KlientScope:
+ """Nadpisuje ``scope["client"]`` prawdziwym IP pytającego.
+
+ ``httpx.ASGITransport`` buduje scope wewnętrznego żądania sam i wpisuje
+ tam ``("127.0.0.1", 123)``. Warstwa stojąca PRZED aplikacją MCP nie ma jak
+ tego dosięgnąć — to jedyne miejsce, w którym da się to poprawić. Bez tego
+ ``SearchAnonThrottle`` (liczy po IP) zlewa wszystkich anonimów do jednego
+ kubełka (spec §7.3).
+ """
+
+ def __init__(self, aplikacja) -> None:
+ self._aplikacja = aplikacja
+
+ async def __call__(self, scope, receive, send):
+ dane = biezace() # fail-closed
+ scope = dict(scope)
+ scope["client"] = (dane.ip, 0)
+ await self._aplikacja(scope, receive, send)
+
+
+class BppClientInProcess(BppClient):
+ """``BppClient`` wołający własną aplikację Django, bez gniazda TCP.
+
+ Tworzony PER ŻĄDANIE (spec D9): ``BppClient`` zapamiętuje ``api_root``
+ w ``__init__``, a adres zależy od nagłówka ``Host`` konkretnego żądania.
+ Przy okazji rozwiązuje to izolację cache (świeży klient = świeży słownik)
+ i sprowadza semafor do zasięgu jednego wywołania narzędzia.
+ """
+
+ def __init__(self, *, transport=None) -> None:
+ dane = biezace()
+ if transport is None:
+ from django_bpp.asgi import django_asgi_app
+
+ transport = httpx.ASGITransport(
+ app=KlientScope(django_asgi_app),
+ # 500 z aplikacji ma wrócić jako BppNetworkError z czytelnym
+ # komunikatem, a nie jako surowy traceback w wyniku narzędzia.
+ raise_app_exceptions=False,
+ )
+ super().__init__(
+ Config(base_url=f"{dane.scheme}://{dane.host}", transport="stdio"),
+ transport=transport,
+ tryb_auth=TrybAuth.W_PROCESIE,
+ max_retries=0, # nie ma sieci, która mogłaby zamigotać
+ )
+ # Przekierowania WYŁĄCZONE (spec D12): SECURE_SSL_REDIRECT po cichu
+ # podwajałby każde żądanie, a FirstRunWizardMiddleware przekierowuje
+ # /api/v1/ na HTML kreatora, co kończyłoby się ValueError w resp.json().
+ self._client.follow_redirects = False
+
+ async def _request(self, full, *, retry_5xx=True):
+ """Egzekwuj budżet czasu PRZED wykonaniem żądania.
+
+ Limit wokół aplikacji MCP nie działa (spec §5.2, BL-2): handler biegnie
+ w zadaniu grupy menedżera, więc anulowanie zadania żądania odcina tylko
+ odpowiedź. Sprawdzenie musi być tutaj, gdzie faktycznie wykonuje się
+ praca.
+ """
+ dane = biezace()
+ if dane.deadline is not None and time.monotonic() >= dane.deadline:
+ raise BppError(
+ "Przekroczono budżet czasu wywołania narzędzia — zawęź "
+ "zapytanie albo poproś o mniej danych."
+ )
+ if not str(full.path).startswith("/api/v1/"):
+ raise BppError(f"Ścieżka spoza /api/v1/ jest niedozwolona: {full.path}")
+ # Porównujemy DOMENĘ z domeną, nie z surowym nagłówkiem ``Host``.
+ # ``httpx.URL.host`` NIGDY nie zawiera portu (port jest w ``.port``),
+ # a ``dane.host`` to nagłówek tak, jak przyszedł — z portem włącznie.
+ # Poprzednia wersja porównywała te dwie różne rzeczy wprost, więc pod
+ # Daphne (``run-site run`` słucha na losowym porcie: ``Host:
+ # localhost:54321``) KAŻDE wywołanie narzędzia kończyło się tym
+ # błędem — czyli kryterium spec §12 „``/mcp`` działa pod Daphne" było
+ # niespełnione, a testy tego nie widziały, bo używały wyłącznie hostów
+ # bez portu.
+ #
+ # Domena to WŁAŚCIWA granulacja, a nie osłabienie kontroli: transportem
+ # jest ``httpx.ASGITransport``, więc adres nie trafia do żadnego
+ # gniazda — host służy WYŁĄCZNIE do zbudowania nagłówka ``Host``
+ # żądania wewnętrznego, a to on decyduje o ``Site`` → ``Uczelnia``
+ # (spec §7.2). Rozstrzyganie ``Site`` i tak port pomija, więc różnica
+ # portów nie może wynieść żądania poza uczelnię; obcy host — może,
+ # i dalej jest odrzucany. ``domena_hosta`` to ta sama funkcja, którą
+ # ``RouterHttp`` waliduje nagłówek na wejściu, więc do tego miejsca
+ # ``dane.host`` jest już poprawny i normalizacja się nie rozjedzie.
+ if full.host and full.host != domena_hosta(dane.host):
+ raise BppError(
+ f"Host spoza bieżącego żądania jest niedozwolony: {full.host}"
+ )
+ return await super()._request(full, retry_5xx=retry_5xx)
+
+
+def zbuduj_klienta() -> BppClientInProcess:
+ """Zbuduj klienta dla bieżącego żądania."""
+ return BppClientInProcess()
diff --git a/src/mcp_server/kontekst.py b/src/mcp_server/kontekst.py
new file mode 100644
index 000000000..f47466b4d
--- /dev/null
+++ b/src/mcp_server/kontekst.py
@@ -0,0 +1,122 @@
+"""Dane zewnętrznego żądania, przenoszone do warstwy wykonującej narzędzia.
+
+Klient MCP żyje w lifespanie (jeden na proces), a host, protokół, IP i token
+zależą od KONKRETNEGO żądania. Przenosimy je ContextVarem — MCP SDK celowo
+kopiuje kontekst przez granicę zadań (transport zapisuje ``copy_context()``
+nadawcy, dispatcher odtwarza go przy uruchamianiu handlera), więc wartość
+ustawiona w warstwie ASGI jest widoczna w kodzie narzędzia.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from contextvars import ContextVar
+from dataclasses import dataclass
+
+from django.conf import settings
+from django.http.request import split_domain_port
+
+
+@dataclass(frozen=True)
+class DaneZadania:
+ """Wszystko, czego wewnętrzne żądanie nie ma skąd wziąć samo."""
+
+ host: str
+ scheme: str
+ ip: str
+ bearer: str | None
+ deadline: float | None # monotoniczny znacznik końca budżetu
+
+
+dane_zadania: ContextVar[DaneZadania | None] = ContextVar(
+ "mcp_dane_zadania", default=None
+)
+
+
+def biezace() -> DaneZadania:
+ """Zwróć dane bieżącego żądania albo rzuć — NIGDY nie zgaduj.
+
+ Fallback na wartości domyślne oznaczałby ciche żądanie z nieznanego hosta,
+ czyli obejście bramki API i wyciek między uczelniami (spec §7.2).
+ """
+ dane = dane_zadania.get()
+ if dane is None:
+ raise RuntimeError(
+ "Brak kontekstu żądania MCP — warstwa KontekstMcp nie została "
+ "uruchomiona albo ContextVar został zresetowany za wcześnie."
+ )
+ return dane
+
+
+def domena_hosta(host: str) -> str:
+ """Zwróć część domenową nagłówka ``Host`` — albo ``""``, gdy jest niepoprawny.
+
+ JEDNO źródło prawdy o tym, czym jest poprawny ``Host`` na ścieżce ``/mcp``,
+ używane w DWÓCH miejscach: przez bramkę w ``routing.RouterHttp`` (odrzuca
+ niepoprawny nagłówek 421-ką) i przez ``klient.BppClientInProcess`` (porównuje
+ host URL-a wychodzącego z hostem żądania). Rozjazd między tymi dwoma
+ miejscami już raz kosztował — patrz komentarze w obu.
+
+ Używamy ``django.http.request.split_domain_port``, czyli DOKŁADNIE tej samej
+ funkcji (i tego samego ``host_validation_re`` z numerycznym portem), którą
+ Django stosuje w ``HttpRequest.get_host()``. Dlaczego nie własne
+ ``host.split(":")[0]``:
+
+ * ``TransportSecuritySettings`` SDK dopasowuje wzorzec ``host:*`` przez
+ ``host.startswith(base_host + ":")`` (``mcp/server/transport_security.py``)
+ — portu NIE waliduje, więc ``uczelnia.example:abc``, ``:1:2`` czy ``:0x1F``
+ przechodzą jego kontrolę;
+ * ``uczelnia._rozstrzygalny`` obcinał port przez ``split(":")[0]``, więc
+ dostawał prawdziwy ``Site`` i przepuszczał żądanie dalej;
+ * ``klient.BppClientInProcess`` budował ``Config(base_url=f"{scheme}://
+ {host}")``, a ``httpx.URL`` na niepoprawnym porcie rzucał ``ValueError``
+ — czyli wyjątek, który NIE jest ani ``httpx.HTTPError`` (więc
+ ``BppClient._request`` go nie łapie), ani ``BppError`` (więc wrapper
+ ``aplikacja._z_raportowaniem`` robił ``rollbar.report_exc_info()``).
+
+ Razem dawało to anonimowy, nielimitowany kanał do Rollbara: jedno żądanie
+ z ``Host: :abc`` = jedno zgłoszenie, a ``curl`` w pętli wyczerpywał
+ kwotę i topił realne alerty. Django-owe ``get_host()`` (z tym samym regexem)
+ NIE jest na tej ścieżce, bo żądanie nie przechodzi przez ``HttpRequest``.
+ """
+ domena, _port = split_domain_port(host)
+ return domena
+
+
+def _naglowek_schematu() -> tuple[str, str]:
+ """Nazwa nagłówka niosącego prawdziwy schemat i wartość znacząca „https”.
+
+ Czytamy z ``SECURE_PROXY_SSL_HEADER``, a NIE z zaszytego na sztywno
+ ``X-Forwarded-Proto``, bo w tym repozytorium te dwie rzeczy to nie to samo:
+ ``settings/base.py`` deklaruje ``HTTP_X_FORWARDED_PROTOCOL``, a
+ ``settings/production.py`` — ``HTTP_X_FORWARDED_PROTO``. Zaszycie jednej
+ z tych nazw sprawiłoby, że warstwa MCP rozjeżdża się z Django dokładnie
+ w jednym z dwóch środowisk — po cichu, bo objawem jest tylko zły schemat
+ w składanych adresach (PRM w ``WWW-Authenticate``, ``base_url`` klienta).
+
+ Klucz ASGI jest małymi literami i z myślnikami, więc tłumaczymy nazwę
+ z konwencji ``request.META`` (``HTTP_X_FORWARDED_PROTO``) na nagłówkową
+ (``x-forwarded-proto``).
+ """
+ ustawienie = getattr(settings, "SECURE_PROXY_SSL_HEADER", None)
+ if not ustawienie:
+ # Django bez tego ustawienia w ogóle nie ufa proxy; my też nie —
+ # zostaje schemat z gniazda, więc nazwa nagłówka nie ma znaczenia.
+ return "", "https"
+ meta, wartosc_https = ustawienie
+ nazwa = meta[len("HTTP_") :] if meta.startswith("HTTP_") else meta
+ return nazwa.replace("_", "-").lower(), wartosc_https
+
+
+def schemat_zadania(naglowki: Mapping[str, str], domyslny: str = "http") -> str:
+ """Rozstrzygnij ``http``/``https`` dla żądania z podanymi nagłówkami.
+
+ nginx→uvicorn jest plaintext, więc ``scope["scheme"]`` mówi ``http`` nawet
+ dla ruchu, który do nginksa przyszedł po HTTPS (spec §7.6). Gunicorn nie
+ ustawia ``forwarded_allow_ips``, więc uvicorn nagłówkom proxy NIE ufa i sam
+ schematu nie poprawi — musimy przeczytać nagłówek ręcznie.
+ """
+ nazwa, wartosc_https = _naglowek_schematu()
+ if nazwa and naglowki.get(nazwa) == wartosc_https:
+ return "https"
+ return "https" if domyslny in ("https", "wss") else "http"
diff --git a/src/mcp_server/routing.py b/src/mcp_server/routing.py
new file mode 100644
index 000000000..0f1944c6f
--- /dev/null
+++ b/src/mcp_server/routing.py
@@ -0,0 +1,477 @@
+"""Routing ASGI: rozdział /mcp od reszty serwisu oraz obsługa lifespanu."""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+
+import rollbar
+from bpp_mcp.auth import set_current_bearer
+from django.conf import settings
+from django.db import Error as BladBazy
+from redis.exceptions import ConnectionError as BladRedisa
+from redis.exceptions import TimeoutError as PrzekroczonyCzasRedisa
+
+from django_bpp.client_ip import get_client_ip
+from mcp_server.auth import BramkaBearera
+from mcp_server.kontekst import (
+ DaneZadania,
+ dane_zadania,
+ domena_hosta,
+ schemat_zadania,
+)
+from mcp_server.start import StartMcp
+from mcp_server.uczelnia import host_rozstrzyga_uczelnie
+from mcp_server.zdrowie import stan_startu
+
+logger = logging.getLogger(__name__)
+
+#: „Infrastruktura pod ``/mcp`` nie odpowiada" — JEDEN epizod, niezależnie od
+#: tego, która warstwa go zauważyła. Trzy typy, nie jeden:
+#:
+#: * ``BladBazy`` (``django.db.Error``) — leżący PostgreSQL;
+#: * ``BladRedisa`` (``redis.exceptions.ConnectionError``) — ``sites.site``
+#: i ``bpp.uczelnia`` są w ``CACHEOPS`` (``production.py``), a
+#: ``CACHEOPS_DEGRADE_ON_FAILURE`` nie jest ustawione, więc leżący Redis
+#: rzuca wyjątkiem Z SAMEGO ORM-u, nie z bazy;
+#: * ``PrzekroczonyCzasRedisa`` (``redis.exceptions.TimeoutError``) — NIE jest
+#: podklasą ``ConnectionError`` (jego MRO to ``RedisError`` → ``Exception``,
+#: zweryfikowane w zainstalowanym pakiecie). WISZĄCY Redis daje więc inny typ
+#: niż Redis ODMAWIAJĄCY połączenia i bez tego wpisu leciałby do ogólnej
+#: siatki bezpieczeństwa w ``__call__``, która zgłasza BEZ rate-limitu —
+#: czyli dokładnie ten hałas raz-na-żądanie, który rate-limit ma wygaszać.
+BLEDY_INFRASTRUKTURY = (BladBazy, BladRedisa, PrzekroczonyCzasRedisa)
+
+#: Budżet czasu jednego wywołania narzędzia (spec D11).
+BUDZET_SEKUND = getattr(settings, "MCP_BUDZET_SEKUND", 25.0)
+
+SCIEZKA_PUBLICZNA = "/mcp"
+SCIEZKA_Z_LOGOWANIEM = "/mcp/auth"
+#: Sonda stanu MCP — patrz ``mcp_server.zdrowie`` (dlaczego nie ``/health/``).
+SCIEZKA_STANU = "/mcp/status"
+
+
+class LifespanMcp:
+ """Obsługuje scope ``lifespan``, którego ProtocolTypeRouter nie zna.
+
+ Klucza ``"lifespan"`` po prostu nie ma dziś w mapie routera, więc uvicorn
+ dostaje ValueError, loguje „lifespan appears unsupported" i jedzie dalej —
+ a menedżer sesji MCP nigdy nie wstaje (spec §2.4).
+ """
+
+ def __init__(self, start: StartMcp) -> None:
+ self._start = start
+
+ async def __call__(self, scope, receive, send):
+ while True:
+ komunikat = await receive()
+ if komunikat["type"] == "lifespan.startup":
+ try:
+ await self._start.zapewnij()
+ except Exception as exc:
+ # Świadomie NIE połykamy: pod gunicornem worker padnie
+ # i zostanie respawnowany, czyli awaria będzie widoczna
+ # jako boot-loop, a nie jako cisza (spec §5.1).
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
+ return
+ await send({"type": "lifespan.startup.complete"})
+ elif komunikat["type"] == "lifespan.shutdown":
+ await send({"type": "lifespan.shutdown.complete"})
+ return
+
+
+class RouterHttp:
+ """Kieruje ``/mcp`` i ``/mcp/auth`` do MCP, resztę do Django."""
+
+ def __init__(self, mcp_app, django_app, start: StartMcp) -> None:
+ self._django = django_app
+ self._start = start
+ self._publiczny = BramkaBearera(mcp_app, wymagany=False)
+ self._z_logowaniem = BramkaBearera(mcp_app, wymagany=True)
+ # Rate-limit zgłoszeń Rollbara przy awarii infrastruktury — DWIE
+ # flagi, JEDNA NA BRAMKĘ, nie jedna wspólna. Powód: bramki mają RÓŻNY
+ # ZASIĘG, więc powodzenie jednej nie jest dowodem odzyskania drugiej.
+ #
+ # * bramka uczelni (``host_rozstrzyga_uczelnie``) jest wołana dla
+ # KAŻDEGO żądania — anonimowego i z bearerem — więc jej powodzenie
+ # dowodzi, że TA konkretna ścieżka do bazy działa, niezależnie od
+ # tego, czy ktokolwiek się uwierzytelnia.
+ # * bramka bearera (``auth.zweryfikuj_token``) jest wołana WYŁĄCZNIE
+ # przy obecnym nagłówku ``Authorization`` — żądanie anonimowe na
+ # publicznym ``/mcp`` jej w ogóle nie dotyka.
+ #
+ # Jedna wspólna flaga psuje się w OBIE strony, zależnie od reguły
+ # resetu:
+ #
+ # * reset przy KAŻDYM udanym żądaniu (wersja sprzed tej poprawki) —
+ # żądanie anonimowe gasi flagę ustawioną przez awarię bramki
+ # bearera, której samo nie sprawdziło. Scenariusz „leżąca baza +
+ # żywy Redis" (bramka uczelni przechodzi z cacheops) daje wtedy
+ # ruch przeplatany anon/bearer i z powrotem „zgłoszenie na każde
+ # żądanie z bearerem" — dokładnie ten hałas, który rate-limit ma
+ # wygaszać (znalezisko z recenzji PR #804).
+ # * reset TYLKO przy żądaniu z bearerem (wersja POŚREDNIA, też
+ # błędna) — w instalacji BEZ ŻADNEGO klienta OAuth (a OAuth w BPP
+ # jest opcjonalny, takich instalacji jest sporo) awaria bramki
+ # uczelni zgłasza się RAZ i NIGDY WIĘCEJ, bo nic nie nosi bearera,
+ # który mógłby zresetować wspólną flagę.
+ #
+ # Rozdzielenie flag usuwa oba fałszywe dowody na raz: każda bramka
+ # zeruje WYŁĄCZNIE swoją flagę, na podstawie WŁASNEGO powodzenia.
+ # Patrz ``_zglos_awarie_bramki_uczelni`` / ``_zglos_awarie_bramki_
+ # bearera`` oraz miejsca resetu w ``_obsluz``.
+ self._awaria_bramki_uczelni_zgloszona = False
+ self._awaria_bramki_bearera_zgloszona = False
+
+ async def __call__(self, scope, receive, send):
+ sciezka = scope.get("path", "")
+ if sciezka == SCIEZKA_STANU:
+ await self._stan(send)
+ return
+ if sciezka not in (SCIEZKA_PUBLICZNA, SCIEZKA_Z_LOGOWANIEM):
+ await self._django(scope, receive, send)
+ return
+
+ if scope.get("method") == "GET" and self._chce_html(scope):
+ await self._przekieruj(send, "/mcp/")
+ return
+
+ poczatek = time.monotonic()
+ # Kod odpowiedzi znamy dopiero z komunikatu wysłanego przez warstwę
+ # niżej — podglądamy go w drodze na zewnątrz, żeby log audytowy
+ # (spec §7.5) miał co zapisać. Żądania wewnętrzne nie trafiają ani do
+ # access-logu nginksa, ani uvicorna, więc bez tego /mcp nie zostawia
+ # ŻADNEGO śladu.
+ widziany = {}
+
+ async def sledzacy(komunikat):
+ if komunikat["type"] == "http.response.start":
+ widziany["status"] = komunikat["status"]
+ await send(komunikat)
+
+ # `dane` wyliczamy DOPIERO wewnątrz siatki bezpieczeństwa (nie przed
+ # `try` jak wcześniej) — usterka 2a z self-review PR #804:
+ # `_dane(scope)` woła bez `errors=` na `.decode()`, więc niepoprawny
+ # UTF-8 w nagłówku (np. `Authorization: Bearer \xff`) rzucał
+ # `UnicodeDecodeError` PRZED jakimkolwiek `try`, co dawało gołe 500
+ # bez wpisu w logu audytowym i bez Rollbara — anonimowo, jednym
+ # żądaniem. Poprawka niżej (`errors="replace"`) usuwa tę KONKRETNĄ
+ # przyczynę, ale `dane` zostaje w siatce jako obrona w głąb: kolejny
+ # nieoczywisty błąd w tej funkcji też dostanie log + zgłoszenie
+ # + kontrolowane 503, nie gołe 500.
+ dane = None
+ try:
+ dane = self._dane(scope)
+ if not domena_hosta(dane.host):
+ # NAJPIERW poprawność samego nagłówka, PRZED bramką uczelni
+ # i przed dotknięciem bazy. Powód w ``kontekst.domena_hosta``:
+ # allowlista SDK przepuszcza ``host:``, bramka
+ # uczelni obcinała port i widziała prawdziwy ``Site``, a
+ # ``httpx.URL`` w kliencie wysypywał się dopiero na budowie
+ # ``base_url`` — wyjątkiem, który wrapper narzędzi zgłaszał do
+ # Rollbara. Kontrolowane 421 zamyka ten kanał, a jednocześnie
+ # nie ujawnia, co serwis obsługuje (patrz ``_nieznany_host``).
+ logger.warning(
+ "mcp: odrzucony niepoprawny nagłówek Host: %r", dane.host
+ )
+ await self._nieznany_host(sledzacy)
+ return
+ await self._obsluz(sciezka, scope, receive, sledzacy, dane, widziany)
+ except Exception:
+ # JEDNA siatka bezpieczeństwa na całą obsługę żądania — patrz
+ # komentarz nad `except (BladBazy, BladRedisa)` w `_obsluz` o
+ # tym, dlaczego TA gałąź nie duplikuje raportowania z węższych
+ # except-ów: te kończą się (return) bez re-raise, więc do tego
+ # miejsca dochodzi wyłącznie to, czego węższy except NIE złapał.
+ logger.exception("mcp: nieobsłużony wyjątek w obsłudze żądania")
+ rollbar.report_exc_info()
+ if "status" not in widziany:
+ # Nie próbuj wysyłać drugiego `http.response.start` — ASGI
+ # nie pozwala na dwa starty odpowiedzi, a warstwa niżej może
+ # już swój wysłać (np. gdzieś w środku strumieniowania).
+ await self._niedostepny(sledzacy)
+ finally:
+ logger.info(
+ "mcp %s %s host=%s bearer=%s status=%s czas=%.3fs",
+ scope.get("method", "?"),
+ sciezka,
+ dane.host if dane is not None else "?",
+ # OBECNOŚĆ, nigdy wartość — token w logu byłby poświadczeniem
+ # leżącym w pliku, który wędruje do agregatora i backupów.
+ "tak" if dane is not None and dane.bearer else "nie",
+ widziany.get("status"),
+ time.monotonic() - poczatek,
+ )
+
+ def _zglos_awarie_bramki_uczelni(self, komunikat: str) -> None:
+ """Zgłoś awarię bramki UCZELNI raz na epizod tej konkretnej bramki.
+
+ Rate-limit jak w ``StartMcp._trzymaj`` (B3) — baza może leżeć dłużej
+ niż jedno żądanie, a bez ograniczenia zgłaszalibyśmy Rollbarowi raz na
+ KAŻDE odrzucone żądanie, czyli dokładnie ten hałas, który B2 usunęło
+ z narzędzi. Flaga wraca do zera przy KAŻDYM żądaniu, które przeszło
+ przez ``host_rozstrzyga_uczelnie`` bez wyjątku (patrz ``_obsluz``,
+ zaraz po ``try/except`` tej bramki) — w tym przy żądaniu anonimowym,
+ bo ta bramka jest wołana dla każdego żądania. Osobna flaga od bramki
+ bearera (patrz ``__init__`` po uzasadnienie rozdziału) — powodzenie
+ TEJ bramki nie mówi nic o bramce bearera, więc nie może zerować jej
+ flagi, i odwrotnie.
+ """
+ if self._awaria_bramki_uczelni_zgloszona:
+ return
+ logger.exception(komunikat)
+ rollbar.report_exc_info()
+ self._awaria_bramki_uczelni_zgloszona = True
+
+ def _zglos_awarie_bramki_bearera(self, komunikat: str) -> None:
+ """Zgłoś awarię bramki BEARERA raz na epizod tej konkretnej bramki.
+
+ Ta bramka (``auth.zweryfikuj_token``) jest wołana WYŁĄCZNIE gdy
+ żądanie niesie nagłówek ``Authorization`` — żądanie anonimowe na
+ publicznym ``/mcp`` jej nie dotyka. Flaga wraca do zera dopiero przy
+ żądaniu, które faktycznie przez nią przeszło, czyli gdy ``dane.bearer``
+ jest ustawiony (patrz ``_obsluz``, gałąź ``else`` bramki bearera).
+ Osobna flaga od bramki uczelni — patrz ``__init__`` po pełne
+ uzasadnienie: reset wspólną flagą przy żądaniu anonimowym gasiłby
+ zgłoszenie o awarii TEJ bramki, mimo że ta bramka wcale się nie
+ wykonała (znalezisko z recenzji PR #804).
+ """
+ if self._awaria_bramki_bearera_zgloszona:
+ return
+ logger.exception(komunikat)
+ rollbar.report_exc_info()
+ self._awaria_bramki_bearera_zgloszona = True
+
+ async def _obsluz(
+ self, sciezka, scope, receive, send, dane: DaneZadania, widziany: dict
+ ):
+ """Właściwa obsługa żądania do ``/mcp`` — bez warstwy logowania.
+
+ ``widziany`` to ten sam słownik, który ``__call__`` wypełnia w
+ ``sledzacy``: potrzebny tu, żeby awaria PO rozpoczęciu odpowiedzi nie
+ próbowała wysłać drugiego ``http.response.start`` (ASGI na to nie
+ pozwala).
+ """
+ # PIERWSZA instrukcja i POZA jakimkolwiek cancel scope'em (spec §5.1).
+ try:
+ await self._start.zapewnij()
+ except Exception:
+ # KOLEJNOŚĆ MA ZNACZENIE: ``zapewnij()`` rzuca, gdy menedżer jest
+ # zatruty, więc sprawdzenie ``self._start.zywy`` poniżej samo
+ # z siebie nigdy by do tego stanu nie doszło — gałąź 503 byłaby
+ # martwym kodem, a wyjątek wyszedłby poza aplikację ASGI jako gołe
+ # 500 bez treści. Do Rollbara ta awaria trafia raz, ze ``StartMcp``
+ # (patrz ``start._trzymaj``), a nie raz na żądanie.
+ logger.exception("mcp: menedżer sesji MCP niedostępny")
+ await self._niedostepny(send)
+ return
+ if not self._start.zywy:
+ await self._niedostepny(send)
+ return
+
+ # Fail-closed na tożsamości uczelni (spec §7.2) — patrz
+ # ``mcp_server.uczelnia``: host, dla którego nie da się rozstrzygnąć
+ # uczelni, otwierałby bramkę API na oścież.
+ try:
+ rozstrzygnieta = await host_rozstrzyga_uczelnie(dane.host)
+ except BLEDY_INFRASTRUKTURY:
+ # Bez tego `except` awaria bazy/cache leciała poza aplikację ASGI
+ # jako gołe 500 bez treści i bez zgłoszenia do Rollbara (middleware
+ # Django nie jest na tej ścieżce) — wprost sprzeczne z B3, które
+ # ten sam diff wprowadził dla `zapewnij()`. Celujemy w konkretne
+ # typy (patrz `BLEDY_INFRASTRUKTURY`), NIE w gołe `Exception` — dla
+ # drugiego mamy siatkę bezpieczeństwa w `__call__`, a tu szerszy
+ # złap przykryłby błędy programistyczne w
+ # `host_rozstrzyga_uczelnie` i pozbawił je jej raportowania
+ # (patrz tamten `except Exception`, bez rate-limitu).
+ self._zglos_awarie_bramki_uczelni("mcp: awaria bazy w bramce uczelni")
+ await self._niedostepny(send)
+ return
+ # Żądanie przeszło przez bramkę uczelni BEZ WYJĄTKU — ta bramka jest
+ # wołana dla KAŻDEGO żądania (anonimowego i z bearerem), więc samo
+ # dojście tutaj już dowodzi, że JEJ ścieżka do bazy działa,
+ # niezależnie od tego, czy host się rozstrzygnął (``rozstrzygnieta``
+ # może być ``False`` — to i tak było udane zapytanie do bazy, tylko
+ # z odpowiedzią „nieznany host”, nie z wyjątkiem). Reset dotyczy
+ # WYŁĄCZNIE flagi tej bramki — patrz uzasadnienie rozdziału flag
+ # w ``__init__``.
+ self._awaria_bramki_uczelni_zgloszona = False
+ if not rozstrzygnieta:
+ logger.warning(
+ "mcp: odrzucony host bez jednoznacznej uczelni: %r", dane.host
+ )
+ await self._nieznany_host(send)
+ return
+
+ zeton = dane_zadania.set(dane)
+ # DWA ContextVary, nie jeden. `BppClient._auth_kwargs` czyta token
+ # z WŁASNEGO ContextVara pakietu bpp_mcp (`bpp_mcp.auth`), nie
+ # z naszego. Bez tej linii `DaneZadania.bearer` nie ma żadnej drogi
+ # do żądania wychodzącego i KAŻDE wywołanie leci anonimowo — czyli
+ # zalogowany użytkownik po cichu traci dostęp do swoich danych,
+ # a cała warstwa OAuth staje się dekoracją.
+ set_current_bearer(dane.bearer)
+ try:
+ if sciezka == SCIEZKA_Z_LOGOWANIEM:
+ # SDK montuje trasę jako Route (^/mcp$), nie Mount — bez
+ # przepisania drugi adres dostałby 404 (spec §5.1, BL-1).
+ scope = dict(scope, path=SCIEZKA_PUBLICZNA, raw_path=b"/mcp")
+ await self._z_logowaniem(scope, receive, send)
+ else:
+ await self._publiczny(scope, receive, send)
+ except BLEDY_INFRASTRUKTURY:
+ # Druga — obok bramki uczelni — warstwa sięgająca do bazy PRZED
+ # aplikacją MCP: `BramkaBearera` → `auth.zweryfikuj_token`. Bez tego
+ # `except` awaria na TEJ ścieżce leciała do ogólnej siatki
+ # bezpieczeństwa w `__call__`, która zgłasza BEZ rate-limitu.
+ #
+ # Scenariusz, w którym to boli: leżąca baza + ŻYWY Redis. Bramka
+ # uczelni przechodzi wtedy z cacheops (`sites.site` i `bpp.uczelnia`
+ # są w `CACHEOPS`), więc jej rate-limitowany `except` w ogóle się
+ # nie uruchamia, a KAŻDE żądanie z bearerem daje osobne zgłoszenie
+ # z siatki. Siatka adresuje *wyjątek*, nie *hałas* — rate-limit
+ # musi być tutaj.
+ self._zglos_awarie_bramki_bearera("mcp: awaria bazy w bramce bearera")
+ if "status" not in widziany:
+ # Aplikacja MCP mogła już zacząć odpowiadać (drugi
+ # `http.response.start` jest w ASGI błędem) — patrz ta sama
+ # ostrożność w siatce w `__call__`.
+ await self._niedostepny(send)
+ else:
+ # WARUNEK ``dane.bearer`` jest tu konieczny, nie kosmetyczny —
+ # to jest reset flagi BRAMKI BEARERA, nie flagi bramki uczelni
+ # (ta resetuje się osobno, wyżej, zaraz po SWOIM ``try/except``).
+ #
+ # Żądanie ANONIMOWE na ``/mcp`` (publiczna bramka, ``wymagany=
+ # False``) w ogóle nie woła ``zweryfikuj_token`` — ``BramkaBearera
+ # .__call__`` przy braku nagłówka ``Authorization`` przechodzi
+ # prosto do aplikacji, nie dotykając bazy. Jego powodzenie NIE
+ # jest więc dowodem, że baza znów odpowiada DLA TEJ bramki. Bez
+ # tego warunku ruch PRZEPLECIONY anon/bearer (dokładnie ten,
+ # który jest publicznym ``/mcp`` w scenariuszu „leżąca baza +
+ # żywy Redis") resetowałby flagę bramki bearera na każdym
+ # anonimowym żądaniu, a zaraz potem ponownie zgłaszał awarię przy
+ # kolejnym żądaniu z bearerem — czyli z powrotem „zgłoszenie na
+ # każde żądanie z bearerem", dokładnie ten hałas, który
+ # rate-limit ma wygaszać (znalezisko z recenzji PR #804).
+ if dane.bearer:
+ self._awaria_bramki_bearera_zgloszona = False
+ finally:
+ dane_zadania.reset(zeton)
+ # set_current_bearer nie zwraca tokenu resetu, więc czyścimy
+ # jawnie — inaczej token wyciekłby do następnego żądania
+ # obsłużonego w tym samym kontekście.
+ set_current_bearer(None)
+
+ async def _stan(self, send) -> None:
+ """Sonda ``/mcp/status``: bez uwierzytelnienia, bez bazy, bez Django.
+
+ Startuje menedżera, jeśli jeszcze nie działa — pod Daphne (brak
+ protokołu lifespan) start jest leniwy, więc sonda, która by go nie
+ wyzwoliła, raportowałaby „padnięte” na zdrowym, świeżym workerze.
+ Po pierwszym razie wywołanie jest idempotentne i nic nie kosztuje.
+ """
+ try:
+ await self._start.zapewnij()
+ except Exception:
+ # To JEST odpowiedź tej sondy, a nie sytuacja wyjątkowa — do
+ # Rollbara awaria poszła już ze ``StartMcp``. Logujemy z pełnym
+ # traceback i odpowiadamy 503 poniżej.
+ logger.exception("mcp/status: menedżer sesji MCP niedostępny")
+ tresc, kod = stan_startu(self._start)
+ await self._json(send, kod, tresc)
+
+ @staticmethod
+ def _chce_html(scope) -> bool:
+ for klucz, wartosc in scope.get("headers", []):
+ if klucz.lower() == b"accept" and b"text/html" in wartosc.lower():
+ return True
+ return False
+
+ @staticmethod
+ def _dane(scope) -> DaneZadania:
+ # UWAGA: klucze ASGI to bajty — bez ``.decode()`` na kluczu każde
+ # `naglowki.get("host", …)` z napisowym kluczem chybia (bajty != str)
+ # i ZAWSZE trafia w wartość domyślną. Skutek po cichu: bearer
+ # zawsze None, host zawsze "localhost" — mostek z pkt 2 briefu
+ # wygląda podłączony, a nic nie przenosi.
+ #
+ # UWAGA 2 (usterka 2a z self-review PR #804): ``errors="replace"`` na
+ # WARTOŚCI jest wymagane — nagłówek to dane od klienta, nie coś, czemu
+ # ufamy jako poprawnemu UTF-8. Bez tego `curl -H 'Authorization:
+ # Bearer \xff'` dawał `UnicodeDecodeError` tu, w miejscu wywoływanym
+ # PRZED jakąkolwiek siatką bezpieczeństwa w `__call__` — anonimowo,
+ # jednym żądaniem, bez wpisu w logu i bez Rollbara. Ten sam wybór
+ # (`errors="replace"`) robi już `mcp_server.auth.BramkaBearera` —
+ # jedno źródło prawdy dla obu miejsc.
+ naglowki = {
+ k.lower().decode(): v.decode(errors="replace")
+ for k, v in scope.get("headers", [])
+ }
+ host = naglowki.get("host", "localhost")
+ # nginx→uvicorn jest plaintext; prawdziwy schemat niesie nagłówek
+ # wskazany przez SECURE_PROXY_SSL_HEADER (spec §7.6). Nagłówka NIE
+ # przekazujemy dalej — służy wyłącznie do zbudowania adresu bazowego.
+ scheme = schemat_zadania(naglowki, scope.get("scheme", "http"))
+ bearer = None
+ surowy = naglowki.get("authorization", "")
+ if surowy.lower().startswith("bearer "):
+ bearer = surowy.split(" ", 1)[1].strip()
+ return DaneZadania(
+ host=host,
+ scheme=scheme,
+ ip=get_client_ip(_UdajeRequest(scope, naglowki)),
+ bearer=bearer,
+ deadline=time.monotonic() + BUDZET_SEKUND,
+ )
+
+ @staticmethod
+ async def _przekieruj(send, gdzie: str) -> None:
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 307,
+ "headers": [(b"location", gdzie.encode())],
+ }
+ )
+ await send({"type": "http.response.body", "body": b""})
+
+ @classmethod
+ async def _niedostepny(cls, send) -> None:
+ await cls._json(send, 503, {"error": "mcp_unavailable"})
+
+ @classmethod
+ async def _nieznany_host(cls, send) -> None:
+ """421 Misdirected Request — ten sam kod, którym SDK odrzuca zły Host.
+
+ Treść celowo nie zdradza, których hostów serwis obsługuje: to jest
+ odpowiedź dla nieuwierzytelnionego świata.
+ """
+ await cls._json(send, 421, {"error": "unknown_host"})
+
+ @staticmethod
+ async def _json(send, kod: int, tresc: dict) -> None:
+ cialo = json.dumps(tresc).encode()
+ await send(
+ {
+ "type": "http.response.start",
+ "status": kod,
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"content-length", str(len(cialo)).encode()),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": cialo})
+
+
+class _UdajeRequest:
+ """Minimalny obiekt zgodny z tym, czego oczekuje ``get_client_ip``."""
+
+ def __init__(self, scope, naglowki):
+ klient = scope.get("client") or ("", 0)
+ self.META = {"REMOTE_ADDR": klient[0]}
+ xff = naglowki.get("x-forwarded-for")
+ if xff:
+ self.META["HTTP_X_FORWARDED_FOR"] = xff
diff --git a/src/mcp_server/start.py b/src/mcp_server/start.py
new file mode 100644
index 000000000..1e469a1e0
--- /dev/null
+++ b/src/mcp_server/start.py
@@ -0,0 +1,124 @@
+"""Jednorazowe wejście w lifespan aplikacji MCP, w osobnym zadaniu.
+
+Menedżer sesji MCP zakłada grupę zadań anyio (``session_manager.run()``).
+Zadanie, które w nią wchodzi, staje się jej HOST TASKIEM i nigdy z niej nie
+wraca (grupa żyje, dopóki host task działa) — a to jest fatalne, gdy tym
+zadaniem jest zadanie obsługujące żądanie (spec §5.1). Objaw zależy od tego,
+JAK błędnie to zrobiono — nie ma jednego wyjątku do złapania:
+
+* jeśli w tym zadaniu jest aktywny inny cancel scope (nasz budżet czasu)
+ i porządek LIFO scope'ów zostanie naruszony, wyjście z tamtego scope'u
+ rzuci ``RuntimeError: Attempted to exit a cancel scope that isn't the
+ current task's current cancel scope``;
+* jeśli zagnieżdżenie scope'ów zostaje poprawne (np. wejście w grupę jest
+ w pełni ustrukturyzowane), zadanie żądania po prostu **nigdy nie wraca**
+ — wisi, trzymając grupę, aż zewnętrzny budżet czasu (``fail_after``,
+ timeout serwera) je anuluje.
+
+W obu przypadkach grupa jest już oznaczona jako wystartowana, więc padają
+też WSZYSTKIE kolejne żądania — a anulowanie host taska (timeout,
+``application_close_timeout`` Daphne) zatruwa ją nieodwracalnie, bo
+``run()`` wchodzi się raz na instancję.
+
+Dlatego host taskiem jest zadanie tła, żyjące tyle, co proces.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+import rollbar
+
+logger = logging.getLogger(__name__)
+
+
+def _odbierz_wyjatek(zadanie: asyncio.Task) -> None:
+ """Odczytaj wyjątek zakończonego host taska, żeby pętla nie hałasowała.
+
+ Nikt tego zadania nie awaituje (żyje tyle, co proces), więc asyncio przy
+ zbieraniu śmieci wypisałoby „Task exception was never retrieved”. Wyjątek
+ jest już zapamiętany w ``_blad`` i zgłoszony do Rollbara w ``_trzymaj`` —
+ tu tylko go kwitujemy, niczego nie połykając.
+ """
+ if not zadanie.cancelled():
+ zadanie.exception()
+
+
+class StartMcp:
+ """Trzyma lifespan aplikacji MCP przy życiu przez cały czas życia procesu."""
+
+ def __init__(self, aplikacja) -> None:
+ self._aplikacja = aplikacja
+ self._gotowe = asyncio.Event()
+ self._zadanie: asyncio.Task | None = None
+ self._blad: BaseException | None = None
+
+ @property
+ def wystartowany(self) -> bool:
+ """Czy lifespan został pomyślnie otwarty."""
+ return self._gotowe.is_set() and self._blad is None
+
+ @property
+ def zywy(self) -> bool:
+ """Czy zadanie trzymające grupę nadal działa (healthcheck, spec §10.3)."""
+ if self._blad is not None:
+ return False
+ return self._zadanie is not None and not self._zadanie.done()
+
+ async def zapewnij(self) -> None:
+ """Wystartuj menedżera sesji, jeśli jeszcze nie działa, i poczekaj.
+
+ Idempotentne. Wołane z ``LifespanMcp`` (uvicorn) albo z pierwszego
+ żądania (Daphne — nie implementuje protokołu lifespan).
+
+ **Nie ponawiamy startu po anulowaniu**, mimo że kusi:
+ ``StreamableHTTPSessionManager.run()`` SDK jawnie odmawia drugiego
+ wejścia („can only be called once per instance”), więc próba restartu
+ na tej samej instancji jest z góry skazana. Zamieniałaby ciche,
+ kontrolowane 503 na zgłoszenie do Rollbara i traceback w logu przy
+ każdym żądaniu po zamknięciu pętli — czyli dokładnie ten hałas,
+ którego pozbywamy się w warstwie narzędzi. Odzyskanie sprawności
+ wymaga nowej instancji aplikacji, czyli recyklingu workera
+ (``--max-requests`` sprawia, że dzieje się to samo), a monitoring
+ dowiaduje się o tym z ``/mcp/status``.
+ """
+ if self._zadanie is None:
+ # create_task nie ma punktu przerwania między testem a przypisaniem
+ # w tej samej pętli, więc blokada nie jest potrzebna.
+ self._zadanie = asyncio.get_running_loop().create_task(self._trzymaj())
+ self._zadanie.add_done_callback(_odbierz_wyjatek)
+ await self._gotowe.wait()
+ if self._blad is not None:
+ raise RuntimeError("Menedżer sesji MCP nie wstał") from self._blad
+
+ async def _trzymaj(self) -> None:
+ """Host task grupy zadań — czeka w nieskończoność."""
+ try:
+ async with self._aplikacja.router.lifespan_context(self._aplikacja):
+ self._gotowe.set()
+ await asyncio.Event().wait()
+ except asyncio.CancelledError:
+ # Anulowanie NIE jest awarią i celowo NIE trafia do ``_blad``.
+ # Przychodzi normalnie przy zamykaniu procesu: uvicorn anuluje
+ # pozostałe zadania po ``lifespan.shutdown``, a ``asyncio.run``
+ # robi to samo przy zamykaniu pętli. Zapamiętane jako trwały błąd
+ # sypałoby Rollbara zgłoszeniem z KAŻDEGO zamknięcia workera
+ # i zamieniało kontrolowane 503 na wyjątek z ``zapewnij()``.
+ # Zwalniamy czekających, żeby nikt nie wisiał na zdarzeniu, które
+ # nigdy nie zostanie ustawione; ``zywy`` i tak jest już False, bo
+ # zadanie się zakończyło — żądania dostaną 503.
+ self._gotowe.set()
+ raise
+ except BaseException as exc:
+ # JEDYNE miejsce, w którym ten wyjątek ktokolwiek zobaczy:
+ # CustomRollbarNotifierMiddleware łapie wyjątki Django, a ten
+ # leci w zadaniu tła poza jakimkolwiek żądaniem (spec §7.5).
+ # Raportujemy TUTAJ, a nie w RouterHttp, żeby zgłoszenie poszło
+ # raz na awarię, a nie raz na żądanie — inaczej martwy menedżer
+ # sam wyczerpałby kwotę Rollbara.
+ logger.exception("mcp: lifespan aplikacji MCP zakończył się błędem")
+ rollbar.report_exc_info()
+ self._blad = exc
+ self._gotowe.set()
+ raise
diff --git a/src/mcp_server/templates/mcp_server/index.html b/src/mcp_server/templates/mcp_server/index.html
new file mode 100644
index 000000000..c8333a6d8
--- /dev/null
+++ b/src/mcp_server/templates/mcp_server/index.html
@@ -0,0 +1,36 @@
+{# Strona /mcp/ — instrukcja podłączenia klienta AI (dla człowieka). #}
+{# Same adresy MCP (/mcp, /mcp/auth) obsługuje warstwa ASGI, patrz #}
+{# mcp_server.routing.RouterHttp — ta strona tylko je pokazuje. #}
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block extratitle %}
+ {% trans "Bibliografia w Twoim asystencie AI" %}
+{% endblock %}
+
+{% block content %}
+
+
+
{% trans "Bibliografia w Twoim asystencie AI" %}
+
+
+ {% trans "Wklej adres poniżej do klienta AI obsługującego zdalne serwery MCP." %}
+
+
+
{% trans "Dostęp publiczny" %}
+
{{ adres_publiczny }}
+
+ {% trans "Bez logowania. Widzi to samo, co publiczna część bibliografii." %}
+
+
+
{% trans "Dostęp z logowaniem" %}
+
{{ adres_z_logowaniem }}
+
+ {% trans "Poprosi o zalogowanie w przeglądarce. Odblokowuje dane niepubliczne." %}
+
+
+
{% trans "Claude Code" %}
+
claude mcp add bpp --transport http {{ adres_publiczny }}
+
+
+{% endblock %}
diff --git a/src/mcp_server/tests/__init__.py b/src/mcp_server/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mcp_server/tests/conftest.py b/src/mcp_server/tests/conftest.py
new file mode 100644
index 000000000..caaf8e915
--- /dev/null
+++ b/src/mcp_server/tests/conftest.py
@@ -0,0 +1,37 @@
+"""Import ``django_bpp.asgi`` MUSI wypaść przed nadpisaniem ``ALLOWED_HOSTS``.
+
+``channels.security.websocket.AllowedHostsOriginValidator`` jest FUNKCJĄ
+FABRYKUJĄCĄ, nie klasą::
+
+ def AllowedHostsOriginValidator(application):
+ allowed_hosts = settings.ALLOWED_HOSTS # czytane RAZ
+ ...
+ return OriginValidator(application, allowed_hosts)
+
+Listę czyta więc jeden raz — w momencie importu ``django_bpp/asgi.py``, gdzie
+budowany jest ``ProtocolTypeRouter`` — i zapieka ją w instancji
+``OriginValidator``. Późniejsze zmiany ``settings.ALLOWED_HOSTS`` nie mają na
+nią wpływu.
+
+Nasze testy budują aplikację przez ``_router(settings)``, które NAJPIERW
+zawęża ``settings.ALLOWED_HOSTS`` do jednego hosta, a DOPIERO POTEM woła
+``build_application()``. To z kolei sięga po ``django_asgi_app`` importem
+leniwym (``mcp_server/klient.py:46`` — leniwym celowo, żeby import modułu ASGI
+nie zależał od gotowości aplikacji Django). Jeżeli to jest PIERWSZY import
+``django_bpp.asgi`` w tym procesie workera, gałąź WebSocketów zostaje na stałe
+zamrożona z jednym hostem.
+
+Skutek zmierzony: każdy późniejszy test z ``channels_live_server`` na tym
+samym workerze dostawał ``403`` przy handshake'u (origin ``http://localhost:
+`` spoza zamrożonej listy), a Playwright timeoutował na
+``chatSocket.readyState === 1``. Objaw był oddalony od przyczyny o setki
+testów i widoczny tylko przy pewnym rozkładzie shardów — na CI wyglądał jak
+flake w ``test_bpp_notifications``.
+
+Import na poziomie modułu ``conftest.py`` wypada przy zbieraniu testów, czyli
+zanim jakikolwiek test zdąży cokolwiek nadpisać. Fikstura by nie wystarczyła
+tak niezawodnie — jej kolejność względem funkcyjnej fikstury ``settings``
+zależałaby od zasięgu.
+"""
+
+import django_bpp.asgi # noqa: F401
diff --git a/src/mcp_server/tests/test_aplikacja.py b/src/mcp_server/tests/test_aplikacja.py
new file mode 100644
index 000000000..9ec69827e
--- /dev/null
+++ b/src/mcp_server/tests/test_aplikacja.py
@@ -0,0 +1,37 @@
+"""Aplikacja mcp_server jest zarejestrowana, a pakiet bpp-mcp dostępny."""
+
+from django.apps import apps
+
+from mcp_server.aplikacja import build_application
+from mcp_server.tests.utils import uruchom
+
+
+def test_aplikacja_zarejestrowana():
+ assert apps.is_installed("mcp_server")
+
+
+def test_pakiet_bpp_mcp_dostepny():
+ from bpp_mcp import register_tools
+
+ assert callable(register_tools)
+
+
+def test_zarejestrowano_komplet_narzedzi():
+ """11 narzędzi + 1 prompt — tyle daje register_tools z bpp-mcp 0.4.0."""
+ mcp_app, _ = build_application()
+
+ async def scenariusz():
+ serwer = mcp_app.state.serwer_mcp
+ return len(await serwer.list_tools()), len(await serwer.list_prompts())
+
+ narzedzia, prompty = uruchom(scenariusz)
+ assert narzedzia == 11
+ assert prompty == 1
+
+
+def test_fabryka_daje_nowa_instancje_za_kazdym_razem():
+ """Aplikacja MUSI powstawać fabryką: allowlista hostów liczona przy
+ imporcie nie zobaczyłaby settings nadpisanych w teście (spec §11)."""
+ a, _ = build_application()
+ b, _ = build_application()
+ assert a is not b
diff --git a/src/mcp_server/tests/test_asgi_montaz.py b/src/mcp_server/tests/test_asgi_montaz.py
new file mode 100644
index 000000000..00fec642b
--- /dev/null
+++ b/src/mcp_server/tests/test_asgi_montaz.py
@@ -0,0 +1,98 @@
+"""Test chroniący faktyczny montaż MCP w ``django_bpp.asgi.application``.
+
+Żaden inny test nie dotyka ``django_bpp.asgi`` — ``test_e2e.py`` i reszta
+testów ``mcp_server`` budują ``RouterHttp``/``LifespanMcp`` własnoręcznie
+przez ``build_application()``, więc przechodzą identycznie, nawet gdy ktoś
+(np. przy konflikcie merge'a) przywróci w ``asgi.py`` stary
+``"http": django_asgi_app`` i usunie klucz ``"lifespan"``. Cały pakiet
+świeciłby wtedy zielono, a produkcja nigdy nie odpowiadałaby na ``/mcp``
+(gunicorn loguje tylko „lifespan appears unsupported" i jedzie dalej).
+
+Musi być SUBPROCESEM: ``build_application()`` w ``asgi.py`` wykonuje się
+raz, przy pierwszym imporcie modułu, i ta jedna instancja żyje tyle, co
+proces — ``session_manager.run()`` wchodzi się raz na instancję (patrz
+``mcp_server/start.py``). W procesie pytest wszystkie testy dzielą już
+zaimportowany ``django_bpp.asgi`` z poprzednich testów (import jest
+cache'owany w ``sys.modules``), więc nie da się tu uzyskać świeżej,
+nieużywanej instancji — subproces daje czysty import i pełną izolację.
+"""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+# Skrypt sprawdza DWIE rzeczy niezależnie:
+# 1. strukturę application_mapping (to jest to, co pada, gdy ktoś przywróci
+# stary blok "http": django_asgi_app i usunie klucz "lifespan"),
+# 2. że lifespan realnie się otwiera i zamyka PRZEZ ProtocolTypeRouter
+# (nie przez LifespanMcp zbudowany osobno w teście) — startup.complete
+# musi się pojawić w wysłanych komunikatach, inaczej menedżer sesji MCP
+# nigdy nie wstaje (spec §2.4, §5.1).
+SKRYPT = """
+import asyncio
+
+import django_bpp.asgi as m
+from mcp_server.routing import LifespanMcp, RouterHttp
+
+mapowanie = m.application.application_mapping
+assert isinstance(mapowanie["http"], RouterHttp), (
+ f"'http' to {type(mapowanie['http'])!r}, nie RouterHttp — MCP nie jest "
+ "zamontowane obok Django."
+)
+assert isinstance(mapowanie["lifespan"], LifespanMcp), (
+ f"'lifespan' to {type(mapowanie.get('lifespan'))!r}, nie LifespanMcp — "
+ "menedżer sesji MCP nigdy nie wstanie pod uvicornem/gunicornem."
+)
+
+
+async def przejdz_cykl_lifespan():
+ wyslane = []
+
+ async def send(msg):
+ wyslane.append(msg)
+
+ kolejka = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]
+
+ async def receive():
+ return kolejka.pop(0)
+
+ # Przez application (ProtocolTypeRouter), nie przez LifespanMcp z boku —
+ # to sprawdza też, że router faktycznie dysponuje scope["type"]=="lifespan".
+ await m.application({"type": "lifespan"}, receive, send)
+ return wyslane
+
+
+wyslane = asyncio.run(przejdz_cykl_lifespan())
+typy = [m["type"] for m in wyslane]
+assert typy == ["lifespan.startup.complete", "lifespan.shutdown.complete"], typy
+print("OK")
+"""
+
+
+def test_application_mapping_montuje_mcp_i_lifespan_wstaje():
+ """``django_bpp.asgi.application`` musi realnie obsługiwać /mcp i lifespan.
+
+ Odpalone jako osobny proces (patrz docstring modułu) z tym samym
+ ``os.environ`` co proces pytest — w tym ``DJANGO_SETTINGS_MODULE``,
+ które ustawia pytest-django (``--ds=django_bpp.settings.test``), oraz
+ zmienne testcontainers wstrzyknięte przez fixture. Test nie dotyka ORM
+ (nie ma ``@pytest.mark.django_db``) — sprawdza strukturę routera
+ i cykl lifespanu, obu bez zapytań do bazy.
+ """
+ wynik = subprocess.run(
+ [sys.executable, "-c", SKRYPT],
+ cwd=REPO_ROOT,
+ env=os.environ.copy(),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+
+ assert wynik.returncode == 0, (
+ "MCP nie jest poprawnie zamontowane w django_bpp.asgi.application.\n"
+ f"STDOUT:\n{wynik.stdout}\n\nSTDERR:\n{wynik.stderr}"
+ )
+ assert "OK" in wynik.stdout
diff --git a/src/mcp_server/tests/test_audience.py b/src/mcp_server/tests/test_audience.py
new file mode 100644
index 000000000..c4828fcef
--- /dev/null
+++ b/src/mcp_server/tests/test_audience.py
@@ -0,0 +1,204 @@
+"""Audience (RFC 8707) i wyszukiwanie tokenu — znaleziska #4 i #5 z recenzji.
+
+**#4.** Dokument PRM (``oauth_mcp.views_metadata``) deklaruje zasób
+``https:///mcp``, a klient MCP zgodny ze specyfikacją autoryzacji
+(2025-06-18) MUSI wysłać ``resource=`` z tym adresem — niezależnie od tego, co
+w PRM napiszemy (wartość bierze z adresu, pod który się łączy). DOT zapisuje ją
+w ``AccessToken.resource``. Bramka ``/mcp`` tego pola nie sprawdzała w ogóle,
+a domyślny walidator DOT odrzucał ZA TO żądanie wewnętrzne serwera MCP do
+własnego ``/api/v1/`` (prefiks ścieżki ``/mcp`` nie pokrywa ``/api/v1/``).
+Skutek zmierzony na pełnym stosie: token zgodnego klienta przechodził bramkę,
+po czym KAŻDE wywołanie narzędzia kończyło się ``BppError: Błąd HTTP 401``.
+
+**#5.** Bramka szukała tokenu przez ``.filter(token=raw)``. ``token`` to
+niezindeksowany ``TextField``; indeksowaną i unikalną kolumną jest
+``token_checksum``, po której szuka sam DOT. Flood błędnymi bearerami skanował
+całą tabelę tokenów — na publicznym, nieuwierzytelnionym endpoincie.
+"""
+
+import json
+from datetime import timedelta
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from django.utils import timezone
+from model_bakery import baker
+from oauth2_provider.models import get_access_token_model, get_application_model
+
+from mcp_server.aplikacja import build_application
+from mcp_server.auth import _sprawdz
+from mcp_server.routing import RouterHttp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+NAGLOWKI_JSONRPC = {
+ "content-type": "application/json",
+ "accept": "application/json, text/event-stream",
+}
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "0"},
+ },
+}
+
+WYWOLANIE = {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {"name": "szukaj_autora", "arguments": {"nazwisko": "Nazwiskotestowe"}},
+}
+
+HOST = "bpp.example.test"
+#: ``zbuduj_scope`` daje ``scheme="http"`` i nie ustawia nagłówka proxy, więc
+#: kanoniczny adres zasobu dla żądania testowego jest po ``http``.
+ZASOB = f"http://{HOST}/mcp"
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 404, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+
+def _router(settings):
+ settings.ALLOWED_HOSTS = [HOST]
+ mcp_app, start = build_application()
+ return RouterHttp(mcp_app, _django, start)
+
+
+def _token(*, resource, scope="read", surowy="tok-audience"):
+ user = baker.make(get_user_model(), is_active=True)
+ app = get_application_model().objects.create(
+ name="test-mcp",
+ client_type="public",
+ authorization_grant_type="authorization-code",
+ )
+ return get_access_token_model().objects.create(
+ user=user,
+ application=app,
+ token=surowy,
+ scope=scope,
+ expires=timezone.now() + timedelta(hours=1),
+ resource=resource,
+ )
+
+
+def _dialog(router, bearer, sciezka="/mcp"):
+ naglowki = dict(NAGLOWKI_JSONRPC, authorization=f"Bearer {bearer}")
+
+ async def biegnij():
+ wyniki = []
+ for cialo in (INITIALIZE, WYWOLANIE):
+ scope = zbuduj_scope(sciezka, host=HOST, naglowki=naglowki)
+ wyniki.append(await wywolaj(router, scope, cialo))
+ return wyniki
+
+ return uruchom(biegnij)
+
+
+def _dane_narzedzia(tresc):
+ odpowiedz = json.loads(tresc)
+ assert "error" not in odpowiedz, odpowiedz
+ wynik = odpowiedz["result"]
+ assert not wynik.get("isError"), wynik
+ return json.loads(wynik["content"][0]["text"])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_zawezony_do_mcp_dziala_przez_caly_stos(settings, uczelnia, autor):
+ """Token zgodnego klienta MCP MUSI dać dane, a nie 401 z ``/api/v1/``.
+
+ Asercja idzie na DANE, nie na status: przed poprawką odpowiedź JSON-RPC też
+ miała HTTP 200 — z ``isError`` i komunikatem o błędzie HTTP 401 z żądania
+ wewnętrznego. Test na sam status byłby zielony z niewłaściwego powodu.
+ """
+ autor.nazwisko = "Nazwiskotestowe"
+ autor.save()
+ _token(resource=[ZASOB])
+
+ router = _router(settings)
+ (_, (status, _, tresc)) = _dialog(router, "tok-audience", sciezka="/mcp/auth")
+
+ assert status == 200, tresc
+ dane = _dane_narzedzia(tresc)
+ assert [a["nazwisko"] for a in dane["autorzy"]] == ["Nazwiskotestowe"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_bez_resource_dalej_dziala(settings, uczelnia, autor):
+ """Kompatybilność wstecz: token bez ``resource`` (klient nieimplementujący
+ RFC 8707, token założony ręcznie) jest nieograniczony i ma dalej działać —
+ ``AccessToken.allows_audience`` zwraca dla pustej listy ``True``."""
+ autor.nazwisko = "Nazwiskotestowe"
+ autor.save()
+ _token(resource=[])
+
+ router = _router(settings)
+ (_, (status, _, tresc)) = _dialog(router, "tok-audience", sciezka="/mcp/auth")
+
+ assert status == 200, tresc
+ assert _dane_narzedzia(tresc)["laczna_liczba"] == 1
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_wydany_dla_innego_hosta_odrzucony_w_bramce(settings, uczelnia):
+ """Egzekwowanie audience: token zawężony do INNEGO hosta ma dostać 401
+ z ``WWW-Authenticate`` (żeby klient poszedł po nowy token), a nie przejść
+ bramkę i paść dopiero w środku wywołania narzędzia.
+
+ To jest cały sens sprawdzania audience u nas: w instalacji wielouczelnianej
+ tabela tokenów jest wspólna, więc token wydany pod hostem uczelni A bez tej
+ kontroli działałby pod hostem uczelni B.
+ """
+ _token(resource=["https://inna.example.test/mcp"])
+
+ router = _router(settings)
+ ((status, naglowki, _), _) = _dialog(router, "tok-audience", sciezka="/mcp/auth")
+
+ assert status == 401
+ assert "resource_metadata=" in naglowki["www-authenticate"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_bez_kolumny_token_nadal_znajdowany(uczelnia):
+ """Regresja na SPOSÓB wyszukania, nie na jego wynik.
+
+ Odwzorowuje tryb „hashed at rest" DOT (``COMPLIANT_BCP_RFC9700_TOKEN_
+ STORAGE``): kolumna ``token`` jest PUSTA, a jedynym śladem tokenu jest
+ ``token_checksum``. Wyszukanie przez ``.filter(token=raw)`` nie znajduje tu
+ NICZEGO — czyli odmawia dostępu wszystkim. ``queryset.update`` omija
+ ``pre_save``, więc checksum zostaje nietknięta, dokładnie jak w DOT.
+ """
+ token = _token(resource=[])
+ get_access_token_model().objects.filter(pk=token.pk).update(token="")
+
+ assert _sprawdz("tok-audience", ZASOB) is True
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wyszukanie_tokenu_uzywa_kolumny_indeksowanej(uczelnia):
+ """Bearer z internetu nie może skanować całej tabeli tokenów.
+
+ Asercja na SQL, bo to jedyny sposób odróżnienia „znalazł" od „znalazł
+ tanio" — oba warianty dają ten sam wynik logiczny. ``token_checksum`` jest
+ ``unique=True, db_index=True``; ``token`` nie ma żadnego indeksu.
+ """
+ _token(resource=[])
+
+ with CaptureQueriesContext(connection) as zapytania:
+ assert _sprawdz("tok-audience", ZASOB) is True
+
+ sql_tokenu = [
+ q["sql"]
+ for q in zapytania.captured_queries
+ if "accesstoken" in q["sql"].lower()
+ ]
+ assert sql_tokenu, zapytania.captured_queries
+ assert all("token_checksum" in sql for sql in sql_tokenu), sql_tokenu
+ assert not any('."token" = ' in sql for sql in sql_tokenu), sql_tokenu
diff --git a/src/mcp_server/tests/test_auth.py b/src/mcp_server/tests/test_auth.py
new file mode 100644
index 000000000..4ed6adcdb
--- /dev/null
+++ b/src/mcp_server/tests/test_auth.py
@@ -0,0 +1,154 @@
+"""BramkaBearera: 401 z WWW-Authenticate, przy zachowaniu dostępu anonimowego.
+
+Bramka MUSI sprawdzać dokładnie to, co StrictOAuth2Authentication: ważność
+tokenu, aktywność konta ORAZ scope ``read``. Gdyby sprawdzała mniej, token bez
+scope'u przeszedłby tutaj i padł dopiero w DRF — czyli wróciłby jako błąd
+w treści JSON-RPC z HTTP 200 i klient nigdy nie ponowiłby autoryzacji
+(spec §5.4).
+"""
+
+import pytest
+from django.contrib.auth import get_user_model
+from model_bakery import baker
+from oauth2_provider.models import get_access_token_model, get_application_model
+
+from mcp_server.auth import BramkaBearera
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+async def _ok(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"PRZESZLO"})
+
+
+def _token(scope="read", aktywny=True, wygasly=False):
+ from datetime import timedelta
+
+ from django.utils import timezone
+
+ user = baker.make(get_user_model(), is_active=aktywny)
+ app = get_application_model().objects.create(
+ name="test",
+ client_type="public",
+ authorization_grant_type="authorization-code",
+ )
+ return get_access_token_model().objects.create(
+ user=user,
+ application=app,
+ token="TOKEN123",
+ scope=scope,
+ expires=timezone.now() + timedelta(seconds=-1 if wygasly else 3600),
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_publiczny_bez_naglowka_przepuszcza():
+ app = BramkaBearera(_ok, wymagany=False)
+ status, _, tresc = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp")))
+ assert status == 200 and tresc == "PRZESZLO"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wymagany_bez_naglowka_daje_401_z_naglowkiem():
+ """Asercja na PEŁNY adres, nie na obecność podciągu ``resource_metadata=``.
+
+ Słaba wersja tego testu przepuściła bloker: ``WWW-Authenticate`` wskazywał
+ PRM pod ``http://`` na produkcji (schemat brany ze ``scope["scheme"]``,
+ któremu uvicorn nie poprawia, bo gunicorn nie ustawia
+ ``forwarded_allow_ips``), a asercja na sam podciąg tego nie widziała —
+ mimo że zepsute discovery OAuth to jedyny powód, dla którego ten adres
+ istnieje.
+ """
+ app = BramkaBearera(_ok, wymagany=True)
+ status, naglowki, _ = uruchom(lambda: wywolaj(app, zbuduj_scope("/mcp/auth")))
+ assert status == 401
+ assert naglowki["www-authenticate"] == (
+ 'Bearer resource_metadata="http://bpp.example.test'
+ '/.well-known/oauth-protected-resource"'
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_prm_ma_https_gdy_proxy_tak_mowi(settings):
+ """Produkcja: nginx w osobnym kontenerze, ruch do uvicorna plaintextem.
+
+ Schemat czytamy z nagłówka wskazanego przez ``SECURE_PROXY_SSL_HEADER``,
+ a nie z zaszytego ``X-Forwarded-Proto`` — ``settings/base.py`` deklaruje
+ ``HTTP_X_FORWARDED_PROTOCOL``, a ``settings/production.py``
+ ``HTTP_X_FORWARDED_PROTO``, więc zaszycie jednej nazwy rozjeżdżałoby
+ warstwę MCP z Django w jednym z dwóch środowisk.
+ """
+ settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+ app = BramkaBearera(_ok, wymagany=True)
+ scope = zbuduj_scope("/mcp/auth", naglowki={"x-forwarded-proto": "https"})
+ status, naglowki, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+ assert naglowki["www-authenticate"] == (
+ 'Bearer resource_metadata="https://bpp.example.test'
+ '/.well-known/oauth-protected-resource"'
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_prm_ignoruje_naglowek_spoza_ustawienia(settings):
+ """Nagłówek INNY niż zadeklarowany w ``SECURE_PROXY_SSL_HEADER`` nie ma
+ prawa podnieść schematu — inaczej dowolny klient podnosiłby go sam,
+ a Django (które ufa tylko zadeklarowanemu) widziałoby co innego."""
+ settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https")
+ app = BramkaBearera(_ok, wymagany=True)
+ scope = zbuduj_scope("/mcp/auth", naglowki={"x-forwarded-proto": "https"})
+ _, naglowki, _ = uruchom(lambda: wywolaj(app, scope))
+ assert 'resource_metadata="http://' in naglowki["www-authenticate"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_zly_token_daje_401_takze_na_publicznym():
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer NIEISTNIEJE"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_token_bez_scope_read_odrzucony_juz_w_bramce():
+ _token(scope="write")
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_konto_nieaktywne_odrzucone():
+ _token(aktywny=False)
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wazny_token_przechodzi():
+ _token()
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, tresc = uruchom(lambda: wywolaj(app, scope))
+ assert status == 200 and tresc == "PRZESZLO"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_wygasly_token_odrzucony():
+ """Luka z docstringu ``_sprawdz``: token WAŻNY strukturalnie (istnieje,
+ konto aktywne, scope ``read``), ale z ``expires`` w przeszłości, musi
+ paść już w bramce — nie dopiero w DRF. Inaczej klient dostaje ``isError``
+ w treści JSON-RPC z HTTP 200 i nigdy nie ponawia autoryzacji.
+
+ Pomocnik ``_token(wygasly=...)`` istniał od początku tego pliku, ale
+ żaden test go nie używał — dokładnie ta luka w pokryciu, którą recenzja
+ PR #804 znalazła mutacyjnie (usunięcie ``is_valid()`` z ``_sprawdz``
+ zostawiało wszystkie testy zielone)."""
+ _token(wygasly=True)
+ app = BramkaBearera(_ok, wymagany=False)
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN123"})
+ status, _, _ = uruchom(lambda: wywolaj(app, scope))
+ assert status == 401
diff --git a/src/mcp_server/tests/test_bezpieczenstwo.py b/src/mcp_server/tests/test_bezpieczenstwo.py
new file mode 100644
index 000000000..e4874306f
--- /dev/null
+++ b/src/mcp_server/tests/test_bezpieczenstwo.py
@@ -0,0 +1,133 @@
+"""Regresje bezpieczeństwa warstwy /mcp (spec §7)."""
+
+import httpx
+import pytest
+from bpp_mcp.client import BppError
+
+from mcp_server.klient import BppClientInProcess
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.tests.utils import uruchom
+
+
+def _dane(host="bpp.example.test", bearer=None):
+ return DaneZadania(
+ host=host, scheme="https", ip="198.51.100.9", bearer=bearer, deadline=None
+ )
+
+
+def _przechwytujacy():
+ zebrane = []
+
+ def handler(request):
+ zebrane.append(request)
+ return httpx.Response(200, json={"ok": 1})
+
+ return httpx.MockTransport(handler), zebrane
+
+
+def test_cookie_nie_trafia_do_zadania_wewnetrznego():
+ """Regresja na NIEOBECNOŚĆ forwardingu, nie na filtrowanie nagłówków.
+
+ Żądanie wewnętrzne jest KONSTRUOWANE OD ZERA (nowy ``httpx.AsyncClient``
+ w ``BppClientInProcess.__init__``, bez jara ciasteczek, bez kopiowania
+ nagłówków zewnętrznego żądania) — nie ma w kodzie ŻADNEJ ścieżki
+ (``cookies=`` czy inny mechanizm), którą ciasteczko mogłoby dotrzeć do
+ tego żądania. To silniejsza gwarancja niż „filtr wycina Cookie" — nie ma
+ czego filtrować. Test złapie regresję dokładnie wtedy, gdyby ktoś kiedyś
+ dopisał przekazywanie nagłówków/ciasteczek zewnętrznego żądania.
+
+ Dlaczego to ma znaczenie, GDYBY taki forwarding się pojawił: Cookie
+ uwierzytelniłoby sesją przez ``SessionAuthentication`` (druga w
+ ``DEFAULT_AUTHENTICATION_CLASSES``) — z pełnymi uprawnieniami, bez
+ zgody, bez scope i bez możliwości revoke (spec §7.1).
+ """
+ transport, zebrane = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane())
+ try:
+ klient = BppClientInProcess(transport=transport)
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ assert "cookie" not in {k.lower() for k in zebrane[0].headers}
+
+
+def test_basic_nie_trafia_do_zadania_wewnetrznego():
+ """Regresja na NIEOBECNOŚĆ forwardingu, nie na strażnika TrybAuth.
+
+ Dwa NIEZALEŻNE powody, dla których Basic tu nie trafi:
+
+ 1. ``BppClient._auth_kwargs`` ma jawną gałąź
+ ``if self._tryb_auth is TrybAuth.W_PROCESIE: return {}`` — Basic jest
+ tam świadomie pomijany.
+ 2. ALE nawet gdyby TĘ gałąź wycięto, Basic i tak by nie trafił: kod
+ trafiłby do gałęzi ``if self._auth_tuple: return {"auth": ...}``,
+ a ``BppClientInProcess.__init__`` buduje
+ ``Config(base_url=..., transport="stdio")`` i NIGDY nie ustawia
+ ``basic_auth`` — ``config.auth_tuple`` jest w tej konstrukcji zawsze
+ ``None``. Basic nie ma więc czym trafić do żądania, niezależnie od
+ strażnika (zweryfikowane mutacyjnie: wycięcie gałęzi 1. NIE psuje
+ tego testu).
+
+ Ten test pilnuje więc powodu 2. — konstrukcji ``Config`` bez
+ ``basic_auth`` — a NIE gałęzi ``TrybAuth.W_PROCESIE``. UWAGA na
+ przyszłość: gdyby ktoś kiedyś dopisał ``basic_auth=`` do ``Config`` w
+ ``mcp_server/klient.py`` (np. z env), TEN test przestanie wystarczać —
+ trzeba będzie dodać osobny test, który faktycznie ćwiczy gałąź 1.
+ (np. skonfigurowany ``basic_auth`` + wycięta gałąź 1. → Basic w
+ nagłówku).
+
+ Dlaczego to ma znaczenie, GDYBY Basic trafił do żądania:
+ ``BasicAuthentication`` jest trzecia w ``DEFAULT_AUTHENTICATION_CLASSES``,
+ a ``ApiReadOnlyForBearerMiddleware`` sprawdza WYŁĄCZNIE prefiks
+ ``bearer ``, więc dla Basica warstwa read-only w ogóle nie istnieje
+ (spec §7.1).
+ """
+ transport, zebrane = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(bearer=None))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ naglowek = zebrane[0].headers.get("authorization", "")
+ assert not naglowek.lower().startswith("basic")
+
+
+def test_sciezka_spoza_api_v1_odrzucona():
+ transport, _ = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane())
+ try:
+ klient = BppClientInProcess(transport=transport)
+ with pytest.raises(BppError):
+ await klient.get_json("https://bpp.example.test/admin/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+
+
+def test_obcy_host_w_url_odrzucony():
+ """_full_url przyjmuje URL-e bezwzględne wprost, a paginacyjne `next` je
+ niosą — kontrola musi obejmować host, nie tylko prefiks (spec §7.4)."""
+ transport, _ = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(host="bpp.a.test"))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ with pytest.raises(BppError):
+ await klient.get_json("https://bpp.zly.test/api/v1/autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
diff --git a/src/mcp_server/tests/test_bramka_uczelni.py b/src/mcp_server/tests/test_bramka_uczelni.py
new file mode 100644
index 000000000..0c1b154b3
--- /dev/null
+++ b/src/mcp_server/tests/test_bramka_uczelni.py
@@ -0,0 +1,434 @@
+"""Fail-closed na tożsamości uczelni: host bez uczelni NIE dochodzi do MCP.
+
+Bloker, który to wymusił: ``ALLOWED_HOSTS`` na produkcji zawiera hosty
+infrastrukturalne (``127.0.0.1``, ``appserver``, ``appserver:8000`` —
+``settings/production.py``). Przechodziły OBIE bramki (allowlista SDK i
+``get_host()`` Django), a potem ``Uczelnia.objects.get_for_request`` nie
+znajdowało dla nich ``Site``. Skutek w instalacji wielouczelnianej:
+
+* ``BramkaApiV1.has_permission`` przy ``Uczelnia=None`` zwraca ``True``
+ BEZWARUNKOWO — także przy ``api_v1_wlaczone=False``;
+* ``scope_rekord_do_uczelni`` staje się no-op;
+* ``UkryjStatusyKorektyMixin`` przestaje wykluczać ukryte statusy korekty.
+
+Czyli ``curl -H 'Host: appserver' https:///mcp`` obchodził wyłącznik
+API i filtr ukrytych statusów. Testy poniżej pilnują obu stron kontraktu:
+odmowy w instalacji wielouczelnianej i ZACHOWANIA działania w jednouczelnianej,
+gdzie fallback na ``SITE_ID`` jest legalny.
+"""
+
+from unittest.mock import Mock
+
+import pytest
+from django.db import OperationalError
+from redis.exceptions import ConnectionError as BladRedisa
+from redis.exceptions import TimeoutError as PrzekroczonyCzasRedisa
+
+from mcp_server import auth, routing
+from mcp_server.routing import RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+class _AtrapaLifespanu:
+ def __init__(self):
+ self.router = self
+
+ def lifespan_context(self, _app):
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _ctx():
+ yield
+
+ return _ctx()
+
+
+async def _mcp(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"MCP"})
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"DJANGO"})
+
+
+def _odpytaj(host, sciezka="/mcp"):
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ return uruchom(lambda: wywolaj(router, zbuduj_scope(sciezka, host=host)))
+
+
+def test_host_infrastrukturalny_odrzucony_przy_wielu_uczelniach(uczelnia1, uczelnia2):
+ """DOKŁADNIE scenariusz blokera: ``Host: appserver`` jest w produkcyjnym
+ ``ALLOWED_HOSTS``, nie ma swojego ``Site`` i przy dwóch uczelniach niczego
+ nie rozstrzyga. Ma dostać 421, a nie obsługę z otwartą bramką."""
+ status, _, tresc = _odpytaj("appserver")
+ assert status == 421
+ assert "MCP" not in tresc
+
+
+def test_localhost_z_portem_tez_odrzucony_przy_wielu_uczelniach(uczelnia1, uczelnia2):
+ """Port w nagłówku ``Host`` nie może być furtką — rozstrzyganie ``Site``
+ porównuje samą nazwę, więc i bramka musi ją odcinać tak samo."""
+ status, _, _ = _odpytaj("appserver:8000")
+ assert status == 421
+
+
+def test_host_wlasnej_uczelni_przechodzi(uczelnia1, uczelnia2):
+ """Kontrola pozytywna: host, który MA swój ``Site``, przechodzi normalnie —
+ bramka nie może być „odrzuca wszystko”."""
+ status, _, tresc = _odpytaj("uczelnia1.localhost")
+ assert status == 200 and tresc == "MCP"
+
+
+def test_instalacja_jednouczelniana_dziala_z_dowolnym_dozwolonym_hostem(uczelnia):
+ """NIE WOLNO zepsuć instalacji jednouczelnianej. Tam fallback na ``SITE_ID``
+ (a dalej na „jedyną uczelnię”) jest legalny i tak działa cały serwis — host
+ bez własnego ``Site`` musi więc dalej przechodzić."""
+ status, _, tresc = _odpytaj("bpp.example.test")
+ assert status == 200 and tresc == "MCP"
+
+
+def test_brak_uczelni_w_bazie_odrzucony():
+ """Świeża instalacja / niedokończony kreator: nie ma czego serwować, a
+ ``Uczelnia=None`` to właśnie stan, w którym bramka API przepuszcza
+ wszystko. Fail-closed."""
+ status, _, _ = _odpytaj("bpp.example.test")
+ assert status == 421
+
+
+def test_bramka_dziala_takze_na_mcp_auth(uczelnia1, uczelnia2):
+ """Adres z logowaniem musi mieć tę samą bramkę — inaczej zostałaby dziura
+ o rozmiarze jednego znaku w ścieżce."""
+ status, _, _ = _odpytaj("appserver", "/mcp/auth")
+ assert status == 421
+
+
+def test_strona_dla_czlowieka_nie_jest_bramkowana(uczelnia1, uczelnia2):
+ """``/mcp/`` (ze slashem) idzie normalnym trybem przez Django, gdzie host
+ obsługuje ``ALLOWED_HOSTS`` i ``SiteResolutionMiddleware`` — nasza bramka
+ nie ma prawa go dotykać."""
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ status, _, tresc = uruchom(
+ lambda: wywolaj(router, zbuduj_scope("/mcp/", metoda="GET", host="appserver"))
+ )
+ assert status == 200 and tresc == "DJANGO"
+
+
+async def _wybuchaj_baza(_host):
+ """Zastępuje ``host_rozstrzyga_uczelnie`` — symuluje padniętą bazę."""
+ raise OperationalError("baza nie odpowiada")
+
+
+def test_awaria_bazy_w_bramce_daje_503_nie_wyciekajacy_wyjatek(monkeypatch):
+ """Bloker: ``host_rozstrzyga_uczelnie`` sięga do bazy, a wywołanie w
+ ``_obsluz`` nie miało żadnej obsługi błędu — ``OperationalError`` wychodził
+ poza aplikację ASGI jako gołe 500 bez treści i bez zgłoszenia do Rollbara
+ (middleware Django nie jest na tej ścieżce). To jest NOWY tryb awarii:
+ przed falą naprawczą anonimowe żądanie na ``/mcp`` wcale nie dotykało
+ bazy w warstwie routingu. Ma dać kontrolowane 503, tak jak awaria startu
+ menedżera (B3), z zgłoszeniem do Rollbara."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp")))
+
+ assert status == 503
+ assert "MCP" not in tresc
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_awaria_bazy_w_bramce_zglaszana_raz_na_epizod_nie_na_zadanie(monkeypatch):
+ """Nie odtwarzaj B2: gdy baza leży dłużej niż jedno żądanie, Rollbar ma
+ dostać JEDNO zgłoszenie na epizod awarii, a nie jedno na każde odrzucone
+ żądanie w pętli — inaczej martwa baza sama wyczerpałaby kwotę Rollbara
+ (tak jak `BppError` przed B2, zmierzone: 4 zgłoszenia na 4 wywołania)."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ for _ in range(3):
+ await wywolaj(router, zbuduj_scope("/mcp"))
+
+ uruchom(scenariusz)
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_awaria_bazy_w_bramce_zglaszana_ponownie_po_odzyskaniu(monkeypatch):
+ """Po powrocie bazy do życia flaga rate-limitu bramki uczelni wraca do
+ zera — kolejny, ODRĘBNY epizod awarii musi znowu trafić do Rollbara,
+ inaczej pierwsza awaria na wiele godzin uciszałaby monitoring na resztę
+ życia workera.
+
+ Środkowe, „zdrowe" żądanie jest celowo ANONIMOWE — to jest dokładnie
+ poprawny scenariusz DLA TEJ bramki: ``host_rozstrzyga_uczelnie`` jest
+ wołane dla KAŻDEGO żądania, więc samo jego powodzenie (bez wyjątku)
+ dowodzi odzyskania, niezależnie od tego, czy ktokolwiek niesie bearer.
+ Flaga bramki uczelni jest ODDZIELNA od flagi bramki bearera właśnie po
+ to, żeby instalacja BEZ ŻADNEGO klienta OAuth (OAuth w BPP jest
+ opcjonalny) nadal dostawała powtórne zgłoszenia — flaga wspólna,
+ resetowana tylko przy bearerze, uciszałaby tu monitoring na zawsze po
+ pierwszej awarii (patrz uzasadnienie rozdziału flag w
+ ``RouterHttp.__init__``)."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def zdrowa(_host):
+ return True
+
+ async def scenariusz():
+ await wywolaj(router, zbuduj_scope("/mcp"))
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", zdrowa)
+ await wywolaj(router, zbuduj_scope("/mcp"))
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+ await wywolaj(router, zbuduj_scope("/mcp"))
+
+ uruchom(scenariusz)
+ assert mock_rollbar.report_exc_info.call_count == 2
+
+
+def test_awaria_bramki_uczelni_zglaszana_ponownie_przy_ruchu_wylacznie_anonimowym(
+ monkeypatch,
+):
+ """Strażnik przeciw regresji: wersja rate-limitu z JEDNĄ wspólną flagą,
+ resetowaną TYLKO przy żądaniu z bearerem, sprawiała, że instalacja bez
+ ani jednego klienta OAuth (a OAuth w BPP jest opcjonalny — takich
+ instalacji jest sporo) zgłaszała awarię bramki uczelni RAZ i NIGDY
+ WIĘCEJ, bo nic w ruchu nie niosło bearera, który mógłby zresetować
+ wspólną flagę.
+
+ Scenariusz: TRZY epizody awarii bramki uczelni, przeplecione dwoma
+ zdrowymi oknami, i ANI JEDNEGO żądania z bearerem w całym scenariuszu.
+ Z osobną flagą bramki uczelni (resetowaną przy KAŻDYM jej powodzeniu,
+ także anonimowym) Rollbar musi dostać TRZY zgłoszenia — po jednym na
+ epizod."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def zdrowa(_host):
+ return True
+
+ async def scenariusz():
+ for _ in range(3):
+ await wywolaj(router, zbuduj_scope("/mcp"))
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", zdrowa)
+ await wywolaj(router, zbuduj_scope("/mcp"))
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_baza)
+
+ uruchom(scenariusz)
+ assert mock_rollbar.report_exc_info.call_count == 3
+
+
+async def _wybuchaj_redis(_host):
+ """Zastępuje ``host_rozstrzyga_uczelnie`` — symuluje padłego Redisa pod
+ cacheops (nie bazę: to jest INNY typ wyjątku od ``_wybuchaj_baza``)."""
+ raise BladRedisa("Redis nie odpowiada")
+
+
+def test_awaria_redis_w_bramce_daje_503_tak_jak_awaria_bazy(monkeypatch):
+ """Usterka 2c z self-review PR #804: ``sites.site`` i ``bpp.uczelnia``
+ są w ``CACHEOPS`` (``production.py``), a ``CACHEOPS_DEGRADE_ON_FAILURE``
+ nie jest ustawione — leżący Redis daje ``redis.exceptions.ConnectionError``
+ Z SAMEGO ORM-u, nie ``django.db.Error``. Wąski ``except BladBazy`` (przed
+ poprawką) tego NIE łapał, więc ta awaria wychodziła poza aplikację ASGI
+ jako gołe 500 — mimo że to dokładnie ten sam epizod „infrastruktura pod
+ /mcp nie odpowiada", który ``except BladBazy`` miał obsłużyć. Musi dać
+ to samo kontrolowane 503 + zgłoszenie, co awaria bazy."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_redis)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp")))
+
+ assert status == 503
+ assert "MCP" not in tresc
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_awaria_redis_w_bramce_zglaszana_raz_na_epizod_nie_na_zadanie(monkeypatch):
+ """Rozstrzyga, KTÓRY except faktycznie złapał ``BladRedisa`` — jedno
+ żądanie (test wyżej) dałoby 503 + jedno zgłoszenie NIEZALEŻNIE od tego,
+ czy złapał go rate-limitowany ``except (BladBazy, BladRedisa)`` w
+ ``_obsluz``, czy ogólna siatka bezpieczeństwa w ``__call__`` (ta zgłasza
+ BEZ rate-limitu). Trzy żądania w tym samym epizodzie odróżniają te dwa
+ przypadki: rate-limitowany except da JEDNO zgłoszenie (jak dla
+ ``BladBazy`` — patrz ``test_awaria_bazy_w_bramce_zglaszana_raz_na_epizod_
+ nie_na_zadanie``), a sama ogólna siatka dałaby TRZY."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_redis)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ for _ in range(3):
+ await wywolaj(router, zbuduj_scope("/mcp"))
+
+ uruchom(scenariusz)
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+async def _wybuchaj_niespodziewanie(_host):
+ """Symuluje błąd PROGRAMISTYCZNY (np. literówkę w nowym kodzie), nie
+ znaną awarię infrastruktury — ma sprawdzić SZERSZĄ siatkę bezpieczeństwa
+ w ``RouterHttp.__call__``, różną od wąskiego
+ ``except (BladBazy, BladRedisa)`` w ``_obsluz``."""
+ raise RuntimeError("błąd programistyczny w host_rozstrzyga_uczelnie")
+
+
+def test_niespodziewany_wyjatek_w_obsludze_daje_503_i_trafia_do_rollbara(
+ monkeypatch,
+):
+ """Usterka 2c z self-review PR #804: komentarz przy wąskim
+ ``except BladBazy`` uzasadniał go obawą o przykrycie błędów
+ programistycznych — ale błąd programistyczny wychodzący do uvicorna jako
+ gołe 500 bez Rollbara jest GORSZY niż zaraportowane 503. Siatka
+ bezpieczeństwa w ``__call__`` (``except Exception``) ma złapać WSZYSTKO,
+ co nie trafiło do węższych except-ów — zaraportować i odpowiedzieć
+ kontrolowanym 503, a nie połknąć po cichu jako gołe 500."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_niespodziewanie)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ status, _, tresc = uruchom(lambda: wywolaj(router, zbuduj_scope("/mcp")))
+
+ assert status == 503
+ assert "MCP" not in tresc
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+async def _wybuchaj_timeout_redisa(_host):
+ """Symuluje Redisa, który WISI (nie odmawia) — ``TimeoutError``, nie
+ ``ConnectionError``."""
+ raise PrzekroczonyCzasRedisa("Redis nie odpowiada w czasie")
+
+
+async def _zdrowa_uczelnia(_host):
+ """Bramka uczelni PRZECHODZI — scenariusz „leżąca baza + ŻYWY Redis".
+
+ ``sites.site`` i ``bpp.uczelnia`` są w ``CACHEOPS``, więc przy żywym Redisie
+ rozstrzygnięcie uczelni może iść z cache'u nawet wtedy, gdy baza leży. Wtedy
+ rate-limitowany ``except`` bramki uczelni w ogóle się nie uruchamia,
+ a awaria wychodzi dopiero przy weryfikacji bearera.
+ """
+ return True
+
+
+def _wybuchajacy_token(wyjatek):
+ async def zweryfikuj(_raw, _zasob):
+ raise wyjatek
+
+ return zweryfikuj
+
+
+def _z_bearerem():
+ return zbuduj_scope("/mcp", naglowki={"authorization": "Bearer COKOLWIEK"})
+
+
+def test_awaria_bazy_w_bramce_bearera_daje_503_i_zglasza_raz_na_epizod(monkeypatch):
+ """Znalezisko #3 z recenzji: ścieżka ``BramkaBearera`` → ``zweryfikuj_token``
+ nie miała własnego ``except``, więc awaria bazy na TEJ warstwie lądowała
+ w ogólnej siatce bezpieczeństwa w ``__call__`` — a ta zgłasza BEZ
+ rate-limitu, czyli raz na KAŻDE żądanie z bearerem.
+
+ Trzy żądania w jednym epizodzie rozstrzygają, KTÓRY ``except`` zadziałał:
+ rate-limitowany da JEDNO zgłoszenie, sama siatka dałaby TRZY. Bramka
+ uczelni jest tu celowo zdrowa — inaczej żądanie nie doszłoby do bearera
+ i test niczego by nie dowodził o warstwie, którą deklaruje sprawdzać.
+ """
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _zdrowa_uczelnia)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+ monkeypatch.setattr(
+ auth, "zweryfikuj_token", _wybuchajacy_token(OperationalError("baza leży"))
+ )
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ return [await wywolaj(router, _z_bearerem()) for _ in range(3)]
+
+ wyniki = uruchom(scenariusz)
+ assert [status for status, _, _ in wyniki] == [503, 503, 503]
+ assert all("MCP" not in tresc for _, _, tresc in wyniki)
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_ruch_mieszany_anon_bearer_nie_resetuje_flagi_zadaniem_anonimowym(
+ monkeypatch,
+):
+ """Recenzja PR #804, punkt 1: żądanie ANONIMOWE na publicznym ``/mcp``
+ w ogóle nie woła ``zweryfikuj_token`` (``BramkaBearera.__call__`` przy
+ braku nagłówka ``Authorization`` przechodzi prosto do aplikacji), więc
+ jego powodzenie nie jest dowodem, że baza znów odpowiada. Reset flagi
+ zaraz po TAKIM żądaniu (kod sprzed tej poprawki) odtwarzał B2 przy ruchu
+ PRZEPLECIONYM — a dokładnie taki przeplot jest publicznym ``/mcp``
+ w scenariuszu „leżąca baza + żywy Redis", dla którego rate-limit powstał.
+
+ Zmierzone na kodzie PRZED tą poprawką (sonda z recenzji): sondując
+ przeplotem anon/bearer × 3 dostajemy statusy
+ ``[200, 503, 200, 503, 200, 503]`` i ``report_exc_info`` wywołane TRZY
+ razy — bo każde ``200`` anonimowe resetowało flagę tuż przed kolejnym
+ ``503`` bearera. Po poprawce (reset warunkowany ``dane.bearer``) statusy
+ zostają te same, ale zgłoszenie do Rollbara ma polecieć raz na epizod,
+ nie raz na żądanie z bearerem.
+ """
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _zdrowa_uczelnia)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+ monkeypatch.setattr(
+ auth, "zweryfikuj_token", _wybuchajacy_token(OperationalError("baza leży"))
+ )
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ wyniki = []
+ for _ in range(3):
+ wyniki.append(await wywolaj(router, zbuduj_scope("/mcp")))
+ wyniki.append(await wywolaj(router, _z_bearerem()))
+ return wyniki
+
+ wyniki = uruchom(scenariusz)
+ assert [status for status, _, _ in wyniki] == [200, 503, 200, 503, 200, 503]
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_timeout_redisa_tez_jest_awaria_infrastruktury_a_nie_hałasem(monkeypatch):
+ """``redis.exceptions.TimeoutError`` NIE jest podklasą ``ConnectionError``.
+
+ Jego MRO to ``RedisError`` → ``Exception`` (zweryfikowane w zainstalowanym
+ pakiecie), więc ``except (BladBazy, BladRedisa)`` go NIE łapał — a WISZĄCY
+ Redis (w odróżnieniu od Redisa ODMAWIAJĄCEGO połączenia) daje właśnie ten
+ typ. Bez niego w ``BLEDY_INFRASTRUKTURY`` każdy taki epizod leciał do siatki
+ bezpieczeństwa, czyli jedno zgłoszenie na żądanie. Trzy żądania odróżniają
+ te dwa przypadki: 1 zgłoszenie = rate-limitowany except, 3 = sama siatka.
+ """
+ mock_rollbar = Mock()
+ monkeypatch.setattr(routing, "host_rozstrzyga_uczelnie", _wybuchaj_timeout_redisa)
+ monkeypatch.setattr(routing, "rollbar", mock_rollbar)
+
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ return [await wywolaj(router, zbuduj_scope("/mcp")) for _ in range(3)]
+
+ wyniki = uruchom(scenariusz)
+ assert [status for status, _, _ in wyniki] == [503, 503, 503]
+ mock_rollbar.report_exc_info.assert_called_once()
diff --git a/src/mcp_server/tests/test_dozwolone_hosty.py b/src/mcp_server/tests/test_dozwolone_hosty.py
new file mode 100644
index 000000000..d680a0d0d
--- /dev/null
+++ b/src/mcp_server/tests/test_dozwolone_hosty.py
@@ -0,0 +1,83 @@
+"""Testy jednostkowe ``_dozwolone_hosty()`` (spec §6.1, recenzja Tasku 7).
+
+Funkcja tłumaczy ``ALLOWED_HOSTS`` Django na allowlistę hostów, którą
+rozumie ``TransportSecuritySettings`` SDK MCP — semantyka OBU list jest
+inna (SDK dopasowuje dokładnie albo wzorcem ``host:*``; Django rozumie
+``*`` i wiodącą ``.domenę``), więc błąd tutaj otwiera albo zamyka DNS
+rebinding protection na całej ``/mcp``.
+
+**Zakres odpowiedzialności — świadomie wąski.** Ta lista NIE jest kontrolą
+wielotenantową i nie ma nią być: nie da się jej policzyć z bazy, bo biegnie
+przy imporcie ``django_bpp.asgi``. Tożsamość uczelni rozstrzyga per żądanie
+``mcp_server.uczelnia`` (testy: ``test_bramka_uczelni.py``) i to tam jest
+fail-closed. Ostatni test w tym pliku pilnuje, żeby ten podział nie został
+przez pomyłkę „naprawiony” tutaj — bo naprawa tutaj byłaby pozorna.
+"""
+
+from mcp_server.aplikacja import _dozwolone_hosty
+
+
+def test_gwiazdka_wylacza_sprawdzanie(settings):
+ """SDK nie ma odpowiednika ``*`` — jedyny bezpieczny przekład to pusta
+ lista, która w ``build_application`` wyłącza
+ ``enable_dns_rebinding_protection`` (patrz wywołanie w ``aplikacja.py``)."""
+ settings.ALLOWED_HOSTS = ["*"]
+ assert _dozwolone_hosty() == []
+
+
+def test_gwiazdka_wygrywa_niezaleznie_od_pozycji(settings):
+ """``*`` gdziekolwiek na liście wyłącza sprawdzanie w całości — funkcja
+ zwraca od razu, nawet gdy przed nim zdążyła dodać inne wpisy."""
+ settings.ALLOWED_HOSTS = ["bpp.realny.test", "*"]
+ assert _dozwolone_hosty() == []
+
+
+def test_zwykly_host_dokladny_i_z_gwiazdka_portu(settings):
+ """Zwykły wpis daje DWA warianty: dokładny (bez portu) i ``host:*``
+ (z dowolnym portem) — SDK nie ma osobnej semantyki „dowolny port"."""
+ settings.ALLOWED_HOSTS = ["bpp.przyklad.test"]
+ hosty = _dozwolone_hosty()
+ assert hosty == ["bpp.przyklad.test", "bpp.przyklad.test:*"]
+
+
+def test_wiele_hostow_daje_wpisy_dla_kazdego(settings):
+ settings.ALLOWED_HOSTS = ["bpp.a.test", "bpp.b.test"]
+ hosty = _dozwolone_hosty()
+ assert hosty == [
+ "bpp.a.test",
+ "bpp.a.test:*",
+ "bpp.b.test",
+ "bpp.b.test:*",
+ ]
+
+
+def test_wiodaca_kropka_django_bez_wiodacej_kropki_w_wyniku(settings):
+ """Django-owe ``.domena`` (dopasowuje domenę i WSZYSTKIE subdomeny) SDK
+ nie rozumie wprost — funkcja ucina kropkę, więc subdomeny same nie
+ przejdą (SDK dopasowuje dokładnie), ale przynajmniej goła domena
+ działa, zamiast nie dopasować NICZEGO (kropka na początku nie pasowałaby
+ do żadnego realnego nagłówka ``Host``)."""
+ settings.ALLOWED_HOSTS = [".przyklad.test"]
+ hosty = _dozwolone_hosty()
+ assert hosty == ["przyklad.test", "przyklad.test:*"]
+ assert not any(h.startswith(".") for h in hosty)
+
+
+def test_hosty_infrastrukturalne_przechodza_ta_warstwe(settings):
+ """Dokumentacja podziału odpowiedzialności, nie akceptacja dziury.
+
+ Produkcyjne ``ALLOWED_HOSTS`` zawiera ``127.0.0.1``, ``appserver``
+ i ``appserver:8000`` (``settings/production.py`` — potrzebne m.in. sondzie
+ Dockera). Ta funkcja je przepuszcza i tak ma zostać: ochrona przed DNS
+ rebindingiem to nie to samo, co rozstrzyganie uczelni.
+
+ Żądanie z takim hostem jest odrzucane 421-ką dopiero PER ŻĄDANIE, przez
+ ``mcp_server.uczelnia`` — bo dopiero tam wolno zapytać bazę, czy host ma
+ swój ``Site``. Gdyby ktoś kiedyś zawęził tę listę „dla bezpieczeństwa”,
+ dostałby zawężenie liczone z konfiguracji, a nie z danych — czyli poczucie
+ bezpieczeństwa bez samego bezpieczeństwa.
+ """
+ settings.ALLOWED_HOSTS = ["bpp.przyklad.test", "appserver", "127.0.0.1"]
+ hosty = _dozwolone_hosty()
+ assert "appserver" in hosty
+ assert "127.0.0.1" in hosty
diff --git a/src/mcp_server/tests/test_e2e.py b/src/mcp_server/tests/test_e2e.py
new file mode 100644
index 000000000..a52be6642
--- /dev/null
+++ b/src/mcp_server/tests/test_e2e.py
@@ -0,0 +1,160 @@
+"""Pełna ścieżka: POST /mcp → initialize → tools/list → tools/call, bez sieci."""
+
+import json
+
+import pytest
+
+from mcp_server.aplikacja import build_application
+from mcp_server.routing import RouterHttp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+NAGLOWKI_JSONRPC = {
+ "content-type": "application/json",
+ # Bez tego SDK odrzuca żądanie w transport_security (klient MCP musi
+ # deklarować oba typy, także przy json_response=True).
+ "accept": "application/json, text/event-stream",
+}
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 404, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+
+def _router(settings, django_app=_django):
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+ mcp_app, start = build_application()
+ return RouterHttp(mcp_app, django_app, start)
+
+
+async def _jsonrpc(router, cialo, host="bpp.example.test"):
+ """Jedno żądanie JSON-RPC do ``/mcp``.
+
+ Korutyna, nie funkcja synchroniczna: cały dialog MUSI zmieścić się
+ w JEDNEJ pętli zdarzeń. ``uruchom`` zakłada świeżą pętlę per wywołanie,
+ a zamknięcie pętli anuluje host task ``StartMcp``, więc kolejne żądanie
+ w nowej pętli zastałoby menedżera sesji martwego i dostało 503 — tak jak
+ w produkcji po recyklingu workera.
+ """
+ scope = zbuduj_scope("/mcp", host=host, naglowki=NAGLOWKI_JSONRPC)
+ return await wywolaj(router, scope, cialo)
+
+
+def _wywolaj_jsonrpc(router, cialo, host="bpp.example.test"):
+ return uruchom(lambda: _jsonrpc(router, cialo, host))
+
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "0"},
+ },
+}
+
+
+@pytest.mark.django_db(transaction=True)
+def test_initialize_zwraca_serverinfo(settings, uczelnia):
+ router = _router(settings)
+ status, _, tresc = _wywolaj_jsonrpc(router, INITIALIZE)
+ assert status == 200
+ assert '"serverInfo"' in tresc and '"bpp"' in tresc
+
+
+def _wynik_narzedzia(tresc: str) -> dict:
+ """Wyłuskaj wynik narzędzia z odpowiedzi JSON-RPC.
+
+ SDK pakuje go dwukrotnie: JSON-RPC → ``result.content[0].text`` → JSON
+ zwrócony przez narzędzie. Asercja na sam surowy tekst odpowiedzi
+ przeszłaby też wtedy, gdyby nazwisko trafiło tam jako echo argumentu,
+ więc rozpakowujemy i patrzymy na DANE.
+ """
+ odpowiedz = json.loads(tresc)
+ assert "error" not in odpowiedz, odpowiedz
+ wynik = odpowiedz["result"]
+ assert not wynik.get("isError"), wynik
+ return json.loads(wynik["content"][0]["text"])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_initialize_tools_list_tools_call_przez_pelny_stos(settings, uczelnia, autor):
+ """Kryterium spec §12: ``initialize`` → ``tools/list`` → ``tools/call``
+ zwracające dane publiczne identyczne z ``/api/v1/``.
+
+ To jest ścieżka, która jest SENSEM tej zmiany, a nie miała żadnej
+ regresji — plik sprawdzał wyłącznie ``initialize``. Wywołanie idzie przez
+ ``RouterHttp`` → bramkę bearera → aplikację MCP → narzędzie → klienta
+ w procesie → prawdziwe ``/api/v1/autor/`` w Django. Bez sieci, ale przez
+ KOMPLET warstw: gdyby którakolwiek z nich (kontekst żądania, mostek
+ bearera, ``KlientScope``, allowlista ścieżek) przestała działać, ten test
+ to zobaczy.
+ """
+ autor.nazwisko = "Nazwiskotestowe"
+ autor.save()
+
+ router = _router(settings)
+
+ async def dialog():
+ wyniki = [await _jsonrpc(router, INITIALIZE)]
+ wyniki.append(
+ await _jsonrpc(router, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
+ )
+ wyniki.append(
+ await _jsonrpc(
+ router,
+ {
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "tools/call",
+ "params": {
+ "name": "szukaj_autora",
+ "arguments": {"nazwisko": "Nazwiskotestowe"},
+ },
+ },
+ )
+ )
+ return wyniki
+
+ (init, lista, wywolanie) = uruchom(dialog)
+
+ assert init[0] == 200, init
+ assert lista[0] == 200, lista
+ nazwy = {n["name"] for n in json.loads(lista[2])["result"]["tools"]}
+ assert "szukaj_autora" in nazwy
+
+ status, _, tresc = wywolanie
+ assert status == 200, tresc
+ dane = _wynik_narzedzia(tresc)
+ assert dane["laczna_liczba"] == 1, dane
+ assert [a["nazwisko"] for a in dane["autorzy"]] == ["Nazwiskotestowe"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_zle_host_daje_421(settings, uczelnia):
+ """Ochrona przed DNS-rebinding SDK działa niezależnie od ALLOWED_HOSTS.
+
+ Host MUSI mieć swój ``Site`` — fixture ``uczelnia`` wiąże go z domeną
+ "testserver" (``src/fixtures/conftest_models.py``) — inaczej 421 zapada
+ w NASZEJ bramce uczelni (``mcp_server.uczelnia``, patrz
+ ``test_bramka_uczelni.py``), zanim żądanie w ogóle dojdzie do SDK, i test
+ niczego nie dowodzi o warstwie, którą deklaruje sprawdzać (zweryfikowane:
+ bez tej fixture baza jest pusta, więc 421 zapadał u nas — przy wyłączonej
+ ochronie SDK, ``_dozwolone_hosty() == []``, cała reszta suity
+ ``mcp_server`` była zielona, więc NIC nie pokrywało sprawdzenia hosta
+ przez SDK na żywym żądaniu). "testserver" jest jednak SPOZA
+ ``ALLOWED_HOSTS`` (``_router`` ustawia je na ``["bpp.example.test"]``),
+ więc odrzucenie musi przyjść z ``TransportSecuritySettings`` SDK.
+ """
+ router = _router(settings)
+ scope = zbuduj_scope(
+ "/mcp",
+ host="testserver",
+ naglowki={"content-type": "application/json"},
+ )
+ status, _, _ = uruchom(
+ lambda: wywolaj(router, scope, {"jsonrpc": "2.0", "id": 1, "method": "ping"})
+ )
+ assert status in (403, 421)
diff --git a/src/mcp_server/tests/test_host.py b/src/mcp_server/tests/test_host.py
new file mode 100644
index 000000000..372a5c89f
--- /dev/null
+++ b/src/mcp_server/tests/test_host.py
@@ -0,0 +1,213 @@
+"""Nagłówek ``Host`` na ścieżce ``/mcp``: walidacja jak w Django, port dozwolony.
+
+Dwie usterki z recenzji PR #804, obie o ten sam nagłówek i obie niewidoczne dla
+wcześniejszych testów, bo te używały wyłącznie hostów BEZ portu:
+
+1. Host z NIEPOPRAWNYM portem (``:abc``) przechodził wszystkie cztery
+ warstwy — allowlistę SDK (dopasowuje ``host:*`` przez ``startswith``, portu
+ nie waliduje), bramkę uczelni (obcinała port ``split(":")[0]``) — i wysypywał
+ się dopiero w ``httpx.URL`` przy budowie ``base_url``. Ten wyjątek nie jest
+ ani ``httpx.HTTPError``, ani ``BppError``, więc wrapper narzędzi zgłaszał go
+ do Rollbara: jedno anonimowe żądanie = jedno zgłoszenie, bez rate-limitu.
+2. Host z PRAWIDŁOWYM portem (``localhost:54321``, czyli dokładnie to, co daje
+ ``run-site run`` pod Daphne) psuł KAŻDE wywołanie narzędzia, bo
+ ``mcp_server.klient`` porównywał ``httpx.URL.host`` (nigdy nie zawiera portu)
+ z surowym nagłówkiem ``Host`` (zawiera).
+"""
+
+import json
+from unittest.mock import Mock
+
+import httpx
+import pytest
+from bpp_mcp.client import BppError
+
+from mcp_server import aplikacja as modul_aplikacji
+from mcp_server import routing as modul_routingu
+from mcp_server.aplikacja import build_application
+from mcp_server.klient import BppClientInProcess
+from mcp_server.kontekst import DaneZadania, dane_zadania, domena_hosta
+from mcp_server.routing import RouterHttp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+NAGLOWKI_JSONRPC = {
+ "content-type": "application/json",
+ "accept": "application/json, text/event-stream",
+}
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "0"},
+ },
+}
+
+WYWOLANIE = {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "szukaj_autora",
+ "arguments": {"nazwisko": "Nazwiskotestowe"},
+ },
+}
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 404, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+
+def _router(settings):
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+ mcp_app, start = build_application()
+ return RouterHttp(mcp_app, _django, start)
+
+
+def _dialog(router, host):
+ """``initialize`` + ``tools/call`` w JEDNEJ pętli zdarzeń (patrz test_e2e)."""
+
+ async def biegnij():
+ wyniki = []
+ for cialo in (INITIALIZE, WYWOLANIE):
+ scope = zbuduj_scope("/mcp", host=host, naglowki=NAGLOWKI_JSONRPC)
+ wyniki.append(await wywolaj(router, scope, cialo))
+ return wyniki
+
+ return uruchom(biegnij)
+
+
+def test_domena_hosta_odrzuca_to_samo_co_django():
+ """Jednostkowo: ``domena_hosta`` musi być zgodne z ``get_host()`` Django.
+
+ Przypadki niepoprawne to dokładnie te, które przechodziły przez allowlistę
+ SDK (``host.startswith(base + ":")``) — port nieliczbowy, podwójny port,
+ port szesnastkowy, port pusty.
+ """
+ assert domena_hosta("bpp.example.test") == "bpp.example.test"
+ assert domena_hosta("BPP.Example.Test:8000") == "bpp.example.test"
+ assert domena_hosta("bpp.example.test.:8000") == "bpp.example.test"
+ assert domena_hosta("[::1]:8000") == "[::1]"
+ for zly in (
+ "bpp.example.test:abc",
+ "bpp.example.test:1:2",
+ "bpp.example.test:0x1F",
+ ):
+ assert domena_hosta(zly) == "", zly
+
+
+@pytest.mark.django_db(transaction=True)
+def test_host_z_niepoprawnym_portem_daje_421_i_nie_woła_rollbara(
+ settings, uczelnia, autor, monkeypatch
+):
+ """Regresja na anonimowy, nielimitowany kanał do Rollbara.
+
+ Asercja jest DWUCZĘŚCIOWA i obie części są potrzebne:
+
+ * ``status == 421`` — samo to nie wystarcza, bo przed poprawką odpowiedź
+ miała status 200 (błąd narzędzia jedzie w ``isError``), więc test
+ sprawdzający tylko „nie 500" byłby zielony z niewłaściwego powodu;
+ * ``report_exc_info`` NIE wywołane — to jest właściwa szkoda. Zmierzone na
+ kodzie sprzed poprawki: 200 + DOKŁADNIE JEDNO zgłoszenie na żądanie,
+ czyli ``curl`` w pętli wyczerpuje kwotę Rollbara.
+
+ Mockujemy Rollbara w OBU modułach, które go wołają (``aplikacja``
+ — wrapper narzędzi, ``routing`` — siatka bezpieczeństwa), żeby test nie
+ przeszedł przypadkiem dlatego, że wyjątek przewędrował do innej warstwy.
+ """
+ autor.nazwisko = "Nazwiskotestowe"
+ autor.save()
+ rollbar_narzedzi = Mock()
+ rollbar_routingu = Mock()
+ monkeypatch.setattr(modul_aplikacji, "rollbar", rollbar_narzedzi)
+ monkeypatch.setattr(modul_routingu, "rollbar", rollbar_routingu)
+
+ router = _router(settings)
+ (_, (status, _, tresc)) = _dialog(router, "bpp.example.test:abc")
+
+ assert status == 421, tresc
+ assert json.loads(tresc) == {"error": "unknown_host"}
+ rollbar_narzedzi.report_exc_info.assert_not_called()
+ rollbar_routingu.report_exc_info.assert_not_called()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_host_z_prawidlowym_portem_dziala_end_to_end(settings, uczelnia, autor):
+ """Kryterium spec §12 („``/mcp`` działa pod Daphne, ``run-site run``").
+
+ Pod Daphne serwer słucha na losowym porcie, więc nagłówek to
+ ``localhost:``. Test idzie PRZEZ CAŁY STOS aż do prawdziwego
+ ``/api/v1/autor/`` i sprawdza DANE, nie tylko status: przed poprawką
+ odpowiedź też miała HTTP 200, tylko z ``isError`` i komunikatem „Host spoza
+ bieżącego żądania jest niedozwolony".
+ """
+ autor.nazwisko = "Nazwiskotestowe"
+ autor.save()
+
+ router = _router(settings)
+ (_, (status, _, tresc)) = _dialog(router, "bpp.example.test:8000")
+
+ assert status == 200, tresc
+ odpowiedz = json.loads(tresc)
+ assert "error" not in odpowiedz, odpowiedz
+ wynik = odpowiedz["result"]
+ assert not wynik.get("isError"), wynik
+ dane = json.loads(wynik["content"][0]["text"])
+ assert [a["nazwisko"] for a in dane["autorzy"]] == ["Nazwiskotestowe"]
+
+
+def _dane(host):
+ return DaneZadania(
+ host=host, scheme="https", ip="198.51.100.9", bearer=None, deadline=None
+ )
+
+
+def _przechwytujacy():
+ zebrane = []
+
+ def handler(request):
+ zebrane.append(request)
+ return httpx.Response(200, json={"ok": 1})
+
+ return httpx.MockTransport(handler), zebrane
+
+
+def test_obcy_host_nadal_odrzucony_mimo_portu_w_naglowku():
+ """Poprawka pkt 2 NIE MOŻE osłabić kontroli z ``test_bezpieczenstwo``.
+
+ Porównujemy teraz domenę z domeną — obcy host w URL-u (np. w polu ``next``
+ paginacji) ma dalej lecieć na ``BppError``, także gdy nagłówek ``Host``
+ bieżącego żądania niesie port.
+ """
+ transport, _ = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane("bpp.a.test:8000"))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ with pytest.raises(BppError):
+ await klient.get_json("https://bpp.zly.test/api/v1/autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+
+
+def test_wlasny_host_z_portem_przechodzi_w_kliencie():
+ """Kontrola pozytywna do testu wyżej — bramka nie może być „odrzuca wszystko"."""
+ transport, zebrane = _przechwytujacy()
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane("bpp.a.test:8000"))
+ try:
+ klient = BppClientInProcess(transport=transport)
+ await klient.get_json("https://bpp.a.test/api/v1/autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ assert zebrane and str(zebrane[0].url).startswith("https://bpp.a.test/api/v1/")
diff --git a/src/mcp_server/tests/test_import_asgi.py b/src/mcp_server/tests/test_import_asgi.py
new file mode 100644
index 000000000..23bfdb2bf
--- /dev/null
+++ b/src/mcp_server/tests/test_import_asgi.py
@@ -0,0 +1,47 @@
+"""Zamrożona lista origin-ów WebSocketa — regresja na 403 w live-serwerze.
+
+``AllowedHostsOriginValidator`` z channels to funkcja fabrykująca: czyta
+``settings.ALLOWED_HOSTS`` RAZ, przy imporcie ``django_bpp/asgi.py``, i zapieka
+wynik. Jeśli ten import wypadnie po tym, jak jakiś test zawęzi
+``ALLOWED_HOSTS``, gałąź WebSocketów zostaje zamrożona z jednym hostem na całe
+życie procesu workera.
+
+Objaw był oddalony od przyczyny: ``403`` przy handshake'u WebSocketa w
+``integration_tests/test_bpp_with_notifications.py``, setki testów później, i
+tylko przy pewnym rozkładzie shardów. Na CI wyglądał jak flake Playwrighta.
+Pełne rozpoznanie i lekarstwo: docstring ``mcp_server/tests/conftest.py``.
+"""
+
+import pytest
+
+from mcp_server.aplikacja import build_application
+
+
+@pytest.mark.django_db(transaction=True)
+def test_zawezenie_hostow_w_tescie_nie_zamraza_galezi_websocketow(settings, uczelnia):
+ """Odwzorowuje dokładny przebieg, który to psuł.
+
+ Kolejność jest istotna i jest treścią testu: najpierw zawężenie
+ ``ALLOWED_HOSTS`` (tak robi ``_router`` w kilku plikach tego pakietu),
+ potem ``build_application()``, które sięga po ``django_asgi_app`` importem
+ leniwym. Bez importu wymuszonego w ``conftest.py`` to właśnie tutaj
+ zapadałby pierwszy import ``django_bpp.asgi`` w procesie.
+
+ Asercja idzie na ``localhost``, bo to jest origin, którego używa
+ ``channels_live_server`` — czyli realny warunek przejścia tamtych testów,
+ a nie dowolna własność listy.
+ """
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+ build_application()
+
+ # Import CELOWO tutaj, nie na górze modułu: import modułowy wykonuje się
+ # przy zbieraniu testów, czyli przed nadpisaniem — i test przechodziłby
+ # nawet bez `conftest.py`, czyli byłby zielony z niewłaściwego powodu.
+ # Sprawdzone mutacyjnie.
+ import django_bpp.asgi
+
+ walidator = django_bpp.asgi.application.application_mapping["websocket"]
+ assert "localhost" in walidator.allowed_origins, (
+ "gałąź WebSocketów zamrożona z zawężoną listą hostów — live-serwer "
+ "będzie odrzucał handshake origin-em localhost z 403"
+ )
diff --git a/src/mcp_server/tests/test_klient.py b/src/mcp_server/tests/test_klient.py
new file mode 100644
index 000000000..0385dc667
--- /dev/null
+++ b/src/mcp_server/tests/test_klient.py
@@ -0,0 +1,124 @@
+"""KlientScope: wewnętrzne żądanie musi nieść PRAWDZIWE IP pytającego.
+
+httpx.ASGITransport buduje scope sam i wpisuje tam własne ``client``
+(127.0.0.1). Bez podmiany wszyscy anonimowi użytkownicy MCP dzieliliby jeden
+kubełek throttlingu DRF — jeden klient DoS-owałby wszystkich (spec §7.3).
+"""
+
+import time
+
+import httpx
+import pytest
+from bpp_mcp.client import BppError
+
+from mcp_server.klient import BppClientInProcess, KlientScope, zbuduj_klienta
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.tests.utils import uruchom
+
+
+async def _echo_client(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": str(scope["client"]).encode()})
+
+
+def test_klient_scope_wstawia_ip_z_kontekstu():
+ app = KlientScope(_echo_client)
+
+ async def scenariusz():
+ zeton = dane_zadania.set(
+ DaneZadania(
+ host="bpp.example.test",
+ scheme="https",
+ ip="198.51.100.9",
+ bearer=None,
+ deadline=None,
+ )
+ )
+ try:
+ wyslane = []
+
+ async def send(m):
+ wyslane.append(m)
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ await app({"type": "http", "client": ("127.0.0.1", 123)}, receive, send)
+ return b"".join(m.get("body", b"") for m in wyslane).decode()
+ finally:
+ dane_zadania.reset(zeton)
+
+ assert "198.51.100.9" in uruchom(scenariusz)
+
+
+def test_klient_scope_bez_kontekstu_rzuca():
+ """FAIL-CLOSED: brak kontekstu to błąd programisty, nie powód do cichego
+ fallbacku na 127.0.0.1 (spec §5.2)."""
+ app = KlientScope(_echo_client)
+
+ async def scenariusz():
+ with pytest.raises(RuntimeError):
+ await app({"type": "http", "client": ("127.0.0.1", 1)}, None, None)
+
+ uruchom(scenariusz)
+
+
+def _dane(deadline=None, host="bpp.example.test"):
+ return DaneZadania(
+ host=host, scheme="https", ip="198.51.100.9", bearer=None, deadline=deadline
+ )
+
+
+def test_api_root_bierze_sie_z_hosta_zadania():
+ """Sedno D9: adres API zależy od hosta KONKRETNEGO żądania (spec §5.3)."""
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(host="bpp.umlub.pl"))
+ try:
+ klient = zbuduj_klienta()
+ return str(klient._full_url("autor/"))
+ finally:
+ dane_zadania.reset(zeton)
+
+ assert uruchom(scenariusz) == "https://bpp.umlub.pl/api/v1/autor/"
+
+
+def test_dwa_zadania_dwa_rozne_adresy():
+ async def scenariusz():
+ adresy = []
+ for host in ("bpp.a.test", "bpp.b.test"):
+ zeton = dane_zadania.set(_dane(host=host))
+ try:
+ adresy.append(str(zbuduj_klienta()._full_url("autor/")))
+ finally:
+ dane_zadania.reset(zeton)
+ return adresy
+
+ a, b = uruchom(scenariusz)
+ assert a != b
+ assert "bpp.a.test" in a and "bpp.b.test" in b
+
+
+def test_przekroczony_budzet_zatrzymuje_zadania():
+ """REGRESJA BL-2: po wyczerpaniu budżetu klient NIE wykonuje kolejnych
+ żądań — limit ma zatrzymywać pracę, nie tylko przestać na nią czekać."""
+ wywolania = []
+
+ def handler(request):
+ wywolania.append(request)
+ return httpx.Response(200, json={"ok": 1})
+
+ async def scenariusz():
+ zeton = dane_zadania.set(_dane(deadline=time.monotonic() - 1))
+ try:
+ klient = BppClientInProcess(transport=httpx.MockTransport(handler))
+ # BppError konkretnie, nie goły Exception (ruff B017) — to jest
+ # dokładnie ten wyjątek, który _request ma podnieść przy
+ # przekroczonym budżecie.
+ with pytest.raises(BppError):
+ await klient.get_json("autor/1/")
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ assert wywolania == []
diff --git a/src/mcp_server/tests/test_logowanie.py b/src/mcp_server/tests/test_logowanie.py
new file mode 100644
index 000000000..746926b89
--- /dev/null
+++ b/src/mcp_server/tests/test_logowanie.py
@@ -0,0 +1,123 @@
+"""Ślad audytowy żądań /mcp (spec §7.5) — i to, czego w nim BYĆ NIE MOŻE.
+
+Żądania na ``/mcp`` nie trafiają ani do access-logu nginksa (wewnętrzne
+wywołania go omijają), ani uvicorna w formie użytecznej do audytu. Publiczny,
+nieuwierzytelniony endpoint na produkcyjnej domenie uczelni nie zostawiał
+przed tą poprawką ŻADNEGO śladu — nie było jak zbadać nadużycia ani zmierzyć
+obciążenia.
+
+Spec wymaga wprost: „Logujemy je w KontekstMcp (ścieżka, kod, czas, obecność
+bearera — **nigdy token**)”.
+"""
+
+import logging
+
+import pytest
+
+from mcp_server.routing import RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+TOKEN = "SEKRETNY-TOKEN-KTORY-NIE-MOZE-TRAFIC-DO-LOGU"
+
+
+class _AtrapaLifespanu:
+ def __init__(self):
+ self.router = self
+
+ def lifespan_context(self, _app):
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _ctx():
+ yield
+
+ return _ctx()
+
+
+async def _mcp(scope, receive, send):
+ await send({"type": "http.response.start", "status": 207, "headers": []})
+ await send({"type": "http.response.body", "body": b"MCP"})
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+
+def _token_w_bazie(raw):
+ from datetime import timedelta
+
+ from django.contrib.auth import get_user_model
+ from django.utils import timezone
+ from model_bakery import baker
+ from oauth2_provider.models import get_access_token_model, get_application_model
+
+ user = baker.make(get_user_model(), is_active=True)
+ app = get_application_model().objects.create(
+ name="test-log",
+ client_type="public",
+ authorization_grant_type="authorization-code",
+ )
+ return get_access_token_model().objects.create(
+ user=user,
+ application=app,
+ token=raw,
+ scope="read",
+ expires=timezone.now() + timedelta(seconds=3600),
+ )
+
+
+def _wywolaj(naglowki=None):
+ router = RouterHttp(_mcp, _django, StartMcp(_AtrapaLifespanu()))
+ return uruchom(
+ lambda: wywolaj(router, zbuduj_scope("/mcp", naglowki=naglowki or {}))
+ )
+
+
+def test_log_ma_sciezke_kod_czas_host_i_brak_bearera(caplog, uczelnia):
+ with caplog.at_level(logging.INFO, logger="mcp_server.routing"):
+ status, _, _ = _wywolaj()
+ assert status == 207
+ (rekord,) = [r for r in caplog.records if r.name == "mcp_server.routing"]
+ wiadomosc = rekord.getMessage()
+ assert "/mcp" in wiadomosc
+ assert "status=207" in wiadomosc, "kod odpowiedzi musi być w logu"
+ assert "host=bpp.example.test" in wiadomosc
+ assert "bearer=nie" in wiadomosc
+ assert "czas=" in wiadomosc
+
+
+def test_log_odnotowuje_obecnosc_bearera_ale_nie_jego_wartosc(caplog, uczelnia):
+ """Sedno wymogu: OBECNOŚĆ, nigdy wartość. Token w logu byłby
+ poświadczeniem leżącym w pliku, który wędruje do agregatora i backupów."""
+ _token_w_bazie(TOKEN)
+ with caplog.at_level(logging.INFO, logger="mcp_server.routing"):
+ _wywolaj({"authorization": f"Bearer {TOKEN}"})
+
+ rekordy = [r for r in caplog.records if r.name == "mcp_server.routing"]
+ assert rekordy, "żądanie z bearerem musi zostawić ślad"
+ assert "bearer=tak" in rekordy[0].getMessage()
+
+ # Cały strumień logów, nie tylko nasz rekord — token nie może wyciec
+ # ŻADNĄ drogą (argumenty formatowania, wyjątki, warstwy niżej).
+ caly_log = "\n".join(r.getMessage() for r in caplog.records)
+ assert TOKEN not in caly_log
+ assert TOKEN not in "".join(str(a) for r in caplog.records for a in (r.args or ()))
+
+
+def test_log_powstaje_takze_gdy_zadanie_zostalo_odrzucone(caplog):
+ """Odrzucone żądania są tym, co audyt interesuje NAJBARDZIEJ — log leci
+ z ``finally``, więc powstaje niezależnie od tego, którą gałęzią wyszliśmy.
+ Bez uczelni w bazie bramka odrzuca 421-ką."""
+ with caplog.at_level(logging.INFO, logger="mcp_server.routing"):
+ status, _, _ = _wywolaj()
+ assert status == 421
+ (rekord,) = [
+ r
+ for r in caplog.records
+ if r.name == "mcp_server.routing" and r.levelno == logging.INFO
+ ]
+ assert "status=421" in rekord.getMessage()
diff --git a/src/mcp_server/tests/test_petla_testowa.py b/src/mcp_server/tests/test_petla_testowa.py
new file mode 100644
index 000000000..ed5536b98
--- /dev/null
+++ b/src/mcp_server/tests/test_petla_testowa.py
@@ -0,0 +1,112 @@
+"""``uruchom()`` musi przeżyć globalną łatkę ``nest_asyncio``.
+
+Bloker, który to wymusił: shard 5 na CI wisiał do 25-minutowego limitu joba,
+dwa razy pod rząd, a lokalnie przechodził przy każdej liczbie workerów i nawet
+w kontenerze identycznym z CI. Zrzut SIGABRT/faulthandler pokazał zakleszczenie:
+
+* wątek roboczy: ``SyncToAsync`` -> ``ApiReadOnlyForBearerMiddleware`` ->
+ ``AsyncToSync`` -> ``CurrentThreadExecutor.run_until_future`` (czeka),
+* wątek pętli: ``nest_asyncio._run_once`` -> ``select()`` (nie budzi tamtego).
+
+Źródło łatki to ``channels_broadcast.core.force_sync``, które woła
+``nest_asyncio.apply()``, gdy trafi na działającą pętlę. Łatka jest globalna
+i nieodwracalna dla procesu, więc wystarczyło, że JAKIKOLWIEK wcześniejszy test
+w tym samym workerze xdista ją odpalił. Stąd zależność od rozkładu testów na
+workery — czyli nieodtwarzalność lokalnie.
+
+Test niżej pilnuje własności STRUKTURALNEJ: pętla pod ``uruchom()`` ma
+standardowe ``_run_once``. Sprawdzone mutacyjnie — pada po przywróceniu
+``asyncio.run`` i po samej zamianie fabryki na ``asyncio.new_event_loop``.
+
+Świadomie NIE ma tu testu integracyjnego odtwarzającego samo zakleszczenie.
+Próbowałem: minimalny przeskok ``sync_to_async`` -> ``async_to_sync``, także
+powtórzony, przechodzi RÓWNIEŻ na zepsutej wersji — byłby zielony z
+niewłaściwego powodu. Zakleszczenie wymaga stanu, którego nie da się tanio
+odtworzyć w jednym teście (pełny łańcuch middleware Django plus wcześniejsze
+testy w tym samym workerze). Dowodem integracyjnym jest cała grupa shardu:
+
+ # wtyczka wołająca `nest_asyncio.apply()` na starcie sesji
+ uv run pytest -p nestplug -n 2 --splits 12 --group 6 \
+ --splitting-algorithm least_duration --durations-path .test_durations
+
+Bez poprawki wisi (mierzone: >20 min przy normalnych ~55 s), z poprawką
+przechodzi.
+"""
+
+import asyncio
+
+import pytest
+
+from mcp_server.tests.utils import uruchom
+
+
+def _zamroz_asyncio(monkeypatch):
+ """Zapamiętaj stan, który ``nest_asyncio.apply()`` nadpisuje.
+
+ ``monkeypatch.setattr(obj, nazwa, obecna_wartość)`` zapisuje wartość
+ sprzed podmiany i odtwarza ją w teardownie — ustawienie atrybutu na jego
+ własną bieżącą wartość jest więc no-opem teraz, a gwarancją przywrócenia
+ potem. Bez tego test zatruwałby workera dokładnie tak, jak robi to
+ ``channels_broadcast`` — czyli reprodukowałby chorobę zamiast jej pilnować.
+ """
+ import asyncio.events as events
+ import asyncio.futures as futures
+ import asyncio.tasks as tasks
+
+ for modul, nazwy in (
+ (asyncio, ("run", "Task", "Future", "get_event_loop")),
+ (tasks, ("Task", "_CTask")),
+ (futures, ("Future", "_CFuture")),
+ (events, ("get_event_loop", "_get_event_loop")),
+ ):
+ for nazwa in nazwy:
+ # Atrybut nieistniejący (np. `events._get_event_loop`, usunięte
+ # w 3.13 — `nest_asyncio` dopiero je TWORZY) monkeypatch zapamięta
+ # jako `notset` i w teardownie USUNIE, zamiast przywracać wartość.
+ monkeypatch.setattr(
+ modul, nazwa, getattr(modul, nazwa, None), raising=False
+ )
+
+ polityka = events.get_event_loop_policy()
+ monkeypatch.setattr(
+ type(polityka),
+ "get_event_loop",
+ type(polityka).get_event_loop,
+ raising=False,
+ )
+ monkeypatch.delattr(asyncio, "_nest_patched", raising=False)
+
+
+def _zatruj_jak_channels_broadcast():
+ """Dokładnie to, co robi ``channels_broadcast.core.force_sync:50-54``."""
+ nest_asyncio = pytest.importorskip("nest_asyncio")
+
+ petla = asyncio.new_event_loop()
+ try:
+ nest_asyncio.apply(petla)
+ finally:
+ petla.close()
+ assert getattr(asyncio, "_nest_patched", False), "łatka się nie założyła"
+
+
+def test_uruchom_biegnie_na_petli_ze_standardowym_sterowaniem(monkeypatch):
+ """Warstwa pierwsza: czy pętla ma STANDARDOWE ``_run_once``.
+
+ Asercja celuje w metodę, nie w marker ``_nest_patched`` — ``PetlaBezLatki``
+ deklaruje ten marker CELOWO, żeby powstrzymać ``_patch_loop`` (patrz
+ docstring ``mcp_server.tests.utils``), więc byłby bezużyteczny jako miara.
+
+ Sam wybór fabryki pętli tu nie wystarcza i test to pilnuje: ``_patch_loop``
+ podmienia metody na KLASIE (``cls = loop.__class__``), więc po zatruciu
+ także ``asyncio.new_event_loop()`` oddaje pętlę z ``_run_once`` z
+ ``nest_asyncio``. Sprawdzone: na wariancie z ``loop_factory=
+ asyncio.new_event_loop`` ten test pada.
+ """
+ _zamroz_asyncio(monkeypatch)
+ _zatruj_jak_channels_broadcast()
+
+ async def czyje_run_once():
+ petla = asyncio.get_running_loop()
+ return type(petla)._run_once is asyncio.BaseEventLoop._run_once
+
+ assert uruchom(czyje_run_once) is True
diff --git a/src/mcp_server/tests/test_rollbar.py b/src/mcp_server/tests/test_rollbar.py
new file mode 100644
index 000000000..5217b0c39
--- /dev/null
+++ b/src/mcp_server/tests/test_rollbar.py
@@ -0,0 +1,216 @@
+"""Awarie narzędzi MCP muszą trafiać do Rollbara (spec §7.5).
+
+Kontekst: ``MCPServer._handle_call_tool`` (protokołowy handler, wołany
+faktycznie przez sesję http) łapie KAŻDY wyjątek narzędzia i zamienia go w
+``CallToolResult(is_error=True)`` — dociera do niego jedynie
+``logger.exception(...)``, nic z tego nie trafia do warstwy ASGI, więc
+``CustomRollbarNotifierMiddleware`` (łapie wyjątki Django) nigdy nie zobaczy
+awarii narzędzia. ``_z_raportowaniem`` w ``aplikacja.py`` musi więc zgłosić
+wyjątek do Rollbara PRZED tym, jak SDK go pochłonie.
+
+``serwer.call_tool(...)`` (publiczne API ``MCPServer``, użyte tu) NIE łapie
+wyjątków — podnosi ``UnexpectedToolError``/``ToolError`` (patrz
+``mcp.server.mcpserver.tools.base.Tool.run``). To wystarcza do sprawdzenia
+raportowania: nasz wrapper zgłasza wyjątek do Rollbara WCZEŚNIEJ, w środku
+opakowanej funkcji narzędzia — zanim jakikolwiek poziom SDK zdąży go złapać
+czy przekonwertować.
+"""
+
+from unittest.mock import Mock
+
+import bpp_mcp.tools as bpp_tools
+import httpx
+import pytest
+from bpp_mcp.client import BppError
+from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
+
+from mcp_server import aplikacja
+from mcp_server.aplikacja import build_application
+from mcp_server.klient import BppClientInProcess
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.tests.utils import uruchom
+
+
+async def _wybuchaj(*_a, **_kw):
+ raise RuntimeError("awaria-testowa-narzedzia")
+
+
+def _rzucajacy_bpperror(status=None):
+ async def narzedzie(*_a, **_kw):
+ raise BppError("nie znaleziono encji", status_code=status)
+
+ return narzedzie
+
+
+def _wywolaj_djangoql_schema(monkeypatch, funkcja, oczekiwany_wyjatek):
+ """Zbuduj aplikację z podmienionym ``djangoql_schema`` i zawołaj narzędzie.
+
+ ``djangoql_schema`` nie potrzebuje ani ``ctx``, ani klienta HTTP — to
+ najlżejsze narzędzie do wywołania bez stawiania pełnego żądania.
+ """
+ mock_rollbar = Mock()
+ monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar)
+ monkeypatch.setattr(bpp_tools, "djangoql_schema", funkcja)
+
+ mcp_app, _ = build_application()
+ serwer = mcp_app.state.serwer_mcp
+
+ async def scenariusz():
+ with pytest.raises(oczekiwany_wyjatek):
+ await serwer.call_tool("djangoql_schema", {"model": "rekord"})
+
+ uruchom(scenariusz)
+ return mock_rollbar
+
+
+def test_wyjatek_narzedzia_trafia_do_rollbara(monkeypatch):
+ """Wywołanie narzędzia, które rzuca wyjątek, musi zawołać
+ ``rollbar.report_exc_info`` — bez tego awarie narzędzi są niewidoczne
+ w monitoringu (patrz docstring modułu)."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar)
+ # djangoql_schema nie potrzebuje ani ``ctx``, ani klienta HTTP — najlżejsze
+ # narzędzie do wywołania bez stawiania pełnego żądania/klienta w procesie.
+ monkeypatch.setattr(bpp_tools, "djangoql_schema", _wybuchaj)
+
+ mcp_app, _ = build_application()
+ serwer = mcp_app.state.serwer_mcp
+
+ async def scenariusz():
+ with pytest.raises(UnexpectedToolError):
+ await serwer.call_tool("djangoql_schema", {"model": "rekord"})
+
+ uruchom(scenariusz)
+
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_narzedzie_bez_wyjatku_nie_raportuje(monkeypatch):
+ """Kontrolny brzeg: powodzenie NIE ma trafiać do Rollbara."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar)
+
+ mcp_app, _ = build_application()
+ serwer = mcp_app.state.serwer_mcp
+
+ async def scenariusz():
+ return await serwer.call_tool("djangoql_schema", {"model": "rekord"})
+
+ uruchom(scenariusz)
+
+ mock_rollbar.report_exc_info.assert_not_called()
+
+
+def test_bpperror_nie_trafia_do_rollbara(monkeypatch):
+ """``BppError`` to normalny kanał komunikatów do użytkownika, nie awaria.
+
+ Zmierzone przed poprawką: 4 zgłoszenia na 4 wywołania (401 anonima przy
+ DjangoQL, dwa 404 nieistniejących encji, walidacja argumentu). Na
+ publicznym, nieuwierzytelnionym endpoincie znaczyło to, że dowolna osoba
+ z internetu wyczerpuje kwotę Rollbara jednym ``curl``-em w pętli i topi
+ realne alerty.
+ """
+ mock_rollbar = _wywolaj_djangoql_schema(
+ monkeypatch, _rzucajacy_bpperror(status=404), ToolError
+ )
+ mock_rollbar.report_exc_info.assert_not_called()
+
+
+def test_bpperror_bez_statusu_nie_trafia_do_rollbara(monkeypatch):
+ """Błąd domenowy bez statusu HTTP (walidacja argumentu, przekroczony
+ budżet czasu) też jest komunikatem, nie awarią."""
+ mock_rollbar = _wywolaj_djangoql_schema(
+ monkeypatch, _rzucajacy_bpperror(status=None), ToolError
+ )
+ mock_rollbar.report_exc_info.assert_not_called()
+
+
+def test_bpperror_5xx_z_prawdziwego_klienta_nie_trafia_do_rollbara(monkeypatch):
+ """Regresja na usterkę z self-review PR #804: wcześniejsza wersja
+ ``_z_raportowaniem`` robiła wyjątek dla ``BppError`` ze statusem 5xx, z
+ uzasadnieniem że taki status mówi o awarii po NASZEJ stronie i „użytkownik
+ nie ma jak wymusić 5xx z /api/v1/”. Recenzent zmierzył empirycznie, że jest
+ ODWROTNIE: timeout DjangoQL (``statement_timeout`` w
+ ``api_v1/viewsets/zapytanie.py``) daje 503 DETERMINISTYCZNIE przy zbyt
+ szerokim zapytaniu, a ``retry_5xx=False`` na tej ścieżce
+ (w zainstalowanym ``bpp-mcp``) przenosi ten status aż do ``BppError`` —
+ 3 wywołania dawały 3 zgłoszenia Rollbara, czyli ten sam hałas od anonima,
+ który reszta tej funkcji ma wygaszać. Gałąź usunięto całkowicie.
+
+ Test idzie przez PRAWDZIWY ``BppClientInProcess`` (mockowany transport
+ HTTP zwracający 503, nie wstrzyknięty wyjątek) — poprzednia wersja tego
+ testu wstrzykiwała ``BppError(status_code=503)`` ręcznie prosto do
+ narzędzia, co niczego nie dowodziło: klient produkuje taki obiekt
+ wyłącznie dla tej jednej, konkretnej ścieżki (DjangoQL), a nie w ogóle
+ dla „realnego 500”.
+
+ Wołamy ``narzedzie.fn`` (czyli nasz ``wrapper``) wprost, z ATRAPĄ
+ ``Context`` — ``serwer.call_tool()`` (użyte w innych testach tego modułu)
+ buduje ``Context`` bez ``request_context``, a ``zapytanie_rekord`` (w
+ odróżnieniu od ``djangoql_schema``) go potrzebuje (``_client(ctx)`` w
+ zainstalowanym ``bpp-mcp``), więc poleciałby ``ValueError`` niezwiązany
+ z tezą tego testu."""
+ mock_rollbar = Mock()
+ monkeypatch.setattr(aplikacja, "rollbar", mock_rollbar)
+
+ def odpowiedz_503(request):
+ return httpx.Response(503, json={"error": "statement_timeout"})
+
+ monkeypatch.setattr(
+ aplikacja,
+ "zbuduj_klienta",
+ lambda: BppClientInProcess(transport=httpx.MockTransport(odpowiedz_503)),
+ )
+
+ mcp_app, _ = build_application()
+ serwer = mcp_app.state.serwer_mcp
+ narzedzie = serwer._tool_manager.get_tool("zapytanie_rekord")
+
+ class _AtrapaRequestContext:
+ def __init__(self, lifespan_context):
+ self.request = None
+ self.lifespan_context = lifespan_context
+
+ class _AtrapaContext:
+ def __init__(self, lifespan_context):
+ self.request_context = _AtrapaRequestContext(lifespan_context)
+
+ async def scenariusz():
+ zeton = dane_zadania.set(
+ DaneZadania(
+ host="bpp.example.test",
+ scheme="https",
+ ip="198.51.100.9",
+ bearer=None,
+ deadline=None,
+ )
+ )
+ try:
+ ctx = _AtrapaContext(aplikacja.KontekstZadania())
+ with pytest.raises(BppError) as exc_info:
+ await narzedzie.fn(ctx, "rok=2020")
+ assert exc_info.value.status_code == 503
+ finally:
+ dane_zadania.reset(zeton)
+
+ uruchom(scenariusz)
+ mock_rollbar.report_exc_info.assert_not_called()
+
+
+def test_context_i_schemat_przetrwaly_wrapper():
+ """Regresja na ryzyko opisane w docstringu ``_z_raportowaniem``: gdyby
+ ``functools.wraps`` nie wystarczał do zachowania sygnatury/adnotacji
+ oryginalnej funkcji narzędzia, ``ctx: Context`` przestałby być wykrywany
+ jako parametr wstrzykiwany i stałby się (niewykonalnym) polem schematu
+ wejściowego — a schemat straciłby prawdziwe parametry narzędzia."""
+ mcp_app, _ = build_application()
+ serwer = mcp_app.state.serwer_mcp
+
+ narzedzie = serwer._tool_manager.get_tool("szukaj_publikacji")
+ assert narzedzie is not None
+ assert narzedzie.context_kwarg == "ctx"
+
+ wlasciwosci = narzedzie.parameters["properties"]
+ assert "ctx" not in wlasciwosci
+ assert set(wlasciwosci) == {"q", "rok_od", "rok_do", "limit"}
+ assert narzedzie.parameters["required"] == ["q"]
diff --git a/src/mcp_server/tests/test_routing.py b/src/mcp_server/tests/test_routing.py
new file mode 100644
index 000000000..b3b95f904
--- /dev/null
+++ b/src/mcp_server/tests/test_routing.py
@@ -0,0 +1,255 @@
+"""RouterHttp: dopasowanie DOKŁADNE + przepisanie ścieżki dla /mcp/auth.
+
+SDK montuje trasę jako Starlette ``Route`` (regex ^/mcp$), nie ``Mount``, więc
+bez przepisania ścieżki /mcp/auth dostałoby 404 (spec §5.1, bloker BL-1).
+Dopasowanie prefiksowe byłoby dodatkowo sprzeczne z §8: /mcp/ ma iść do Django.
+"""
+
+import logging
+
+import pytest
+
+from mcp_server.routing import LifespanMcp, RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+def _token(raw: str, *, scope: str = "read", aktywny: bool = True):
+ """Realny access token w bazie — jak w ``test_auth.py`` (Task 5).
+
+ ``BramkaBearera`` sprawdza WAŻNOŚĆ tokenu w bazie niezależnie od
+ ``wymagany`` (spec §5.4, tabela D10: „zły / wygasły / bez scope" → 401
+ na OBU adresach). Testy poniżej, które przepuszczają bearer przez
+ prawdziwą bramkę, muszą więc dać jej token, który faktycznie istnieje —
+ zmyślony ciąg zostałby odrzucony 401-ką, zanim aplikacja MCP go zobaczy.
+ """
+ from datetime import timedelta
+
+ from django.contrib.auth import get_user_model
+ from django.utils import timezone
+ from model_bakery import baker
+ from oauth2_provider.models import get_access_token_model, get_application_model
+
+ user = baker.make(get_user_model(), is_active=aktywny)
+ app = get_application_model().objects.create(
+ name=f"test-{raw}",
+ client_type="public",
+ authorization_grant_type="authorization-code",
+ )
+ return get_access_token_model().objects.create(
+ user=user,
+ application=app,
+ token=raw,
+ scope=scope,
+ expires=timezone.now() + timedelta(seconds=3600),
+ )
+
+
+class _AtrapaLifespanu:
+ def __init__(self):
+ self.router = self
+
+ def lifespan_context(self, _app):
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def _ctx():
+ yield
+
+ return _ctx()
+
+
+async def _echo_sciezki(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"MCP:" + scope["path"].encode()})
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send(
+ {"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()}
+ )
+
+
+def _router():
+ return RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu()))
+
+
+@pytest.mark.django_db(transaction=True)
+def test_mcp_trafia_do_aplikacji_mcp(uczelnia):
+ """``uczelnia`` NIE jest ozdobnikiem: ``RouterHttp`` odrzuca 421-ką host,
+ dla którego nie da się rozstrzygnąć uczelni (spec §7.2, patrz
+ ``mcp_server.uczelnia``). Bez żadnej uczelni w bazie żądanie nigdy nie
+ dotarłoby do aplikacji MCP."""
+ status, _, tresc = uruchom(lambda: wywolaj(_router(), zbuduj_scope("/mcp")))
+ assert status == 200 and tresc == "MCP:/mcp"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_mcp_auth_ma_przepisana_sciezke_na_mcp(uczelnia):
+ """REGRESJA BL-1: bez przepisania SDK oddałoby 404.
+
+ ``/mcp/auth`` bez ważnego tokenu to zawsze 401 (spec §5.4, D10) — bez
+ tokenu bramka odrzuciłaby żądanie PRZED aplikacją MCP niezależnie od
+ przepisania ścieżki, więc dowodem na BL-1 może być tylko przejście
+ z WAŻNYM tokenem.
+ """
+ _token("TOKEN-BL1")
+ scope = zbuduj_scope("/mcp/auth", naglowki={"authorization": "Bearer TOKEN-BL1"})
+ status, _, tresc = uruchom(lambda: wywolaj(_router(), scope))
+ assert status == 200 and tresc == "MCP:/mcp"
+
+
+def test_mcp_ze_slashem_idzie_do_django():
+ status, _, tresc = uruchom(
+ lambda: wywolaj(_router(), zbuduj_scope("/mcp/", metoda="GET"))
+ )
+ assert tresc == "DJANGO:/mcp/"
+
+
+def test_reszta_idzie_do_django():
+ status, _, tresc = uruchom(
+ lambda: wywolaj(_router(), zbuduj_scope("/api/v1/", metoda="GET"))
+ )
+ assert tresc == "DJANGO:/api/v1/"
+
+
+def test_get_mcp_z_przegladarki_przekierowuje_na_slash():
+ """Człowiek wkleja adres z instrukcji; bez tego SDK oddałby 406."""
+ scope = zbuduj_scope("/mcp", metoda="GET", naglowki={"accept": "text/html"})
+ status, naglowki, _ = uruchom(lambda: wywolaj(_router(), scope))
+ assert status == 307 and naglowki["location"] == "/mcp/"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_bearer_trafia_do_contextvara_pakietu(uczelnia):
+ """`BppClient` czyta token z WŁASNEGO ContextVara pakietu bpp_mcp, nie
+ z naszego. Bez tego mostka zalogowany użytkownik po cichu leciałby
+ anonimowo, a warstwa OAuth byłaby dekoracją.
+
+ Token musi być WAŻNY (patrz ``_token``) — bramka na ``/mcp`` przepuszcza
+ anonima, ale zły/nieznany bearer odrzuca 401-ką (spec §5.4) i mock
+ aplikacji MCP (``_podglada``) nigdy by nie został wywołany.
+ """
+ from bpp_mcp.auth import current_bearer
+
+ _token("TOKEN-XYZ")
+ widziany = []
+
+ async def _podglada(scope, receive, send):
+ widziany.append(current_bearer())
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+ router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu()))
+ scope = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer TOKEN-XYZ"})
+ uruchom(lambda: wywolaj(router, scope))
+ assert widziany == ["TOKEN-XYZ"]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_bearer_nie_wycieka_do_nastepnego_zadania(uczelnia):
+ """Po zakończeniu żądania ContextVar pakietu musi być wyczyszczony."""
+ from bpp_mcp.auth import current_bearer
+
+ _token("T1")
+ widziany = []
+
+ async def _podglada(scope, receive, send):
+ widziany.append(current_bearer())
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+ async def scenariusz():
+ router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu()))
+ z_tokenem = zbuduj_scope("/mcp", naglowki={"authorization": "Bearer T1"})
+ await wywolaj(router, z_tokenem)
+ await wywolaj(router, zbuduj_scope("/mcp"))
+
+ uruchom(scenariusz)
+ assert widziany == ["T1", None]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_schemat_zadania_z_naglowka_wskazanego_przez_ustawienie(settings, uczelnia):
+ """``DaneZadania.scheme`` decyduje o ``base_url`` klienta w procesie, więc
+ zły schemat to albo podwojone żądanie (``SECURE_SSL_REDIRECT``), albo
+ adresy z ``http://`` w danych zwracanych użytkownikowi.
+
+ Nazwa nagłówka MUSI pochodzić z ``SECURE_PROXY_SSL_HEADER`` — to samo
+ źródło, z którego czyta ``auth.BramkaBearera``. Zaszycie
+ ``X-Forwarded-Proto`` rozjechałoby warstwę MCP z Django w środowisku,
+ w którym ustawienie mówi ``HTTP_X_FORWARDED_PROTOCOL`` (``base.py``).
+ """
+ settings.SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https")
+ widziane = []
+
+ async def _podglada(scope, receive, send):
+ from mcp_server.kontekst import biezace
+
+ widziane.append(biezace().scheme)
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b""})
+
+ router = RouterHttp(_podglada, _django, StartMcp(_AtrapaLifespanu()))
+
+ async def scenariusz():
+ await wywolaj(
+ router,
+ zbuduj_scope("/mcp", naglowki={"x-forwarded-protocol": "https"}),
+ )
+ # Nagłówek SPOZA ustawienia nie ma prawa podnieść schematu.
+ await wywolaj(
+ router, zbuduj_scope("/mcp", naglowki={"x-forwarded-proto": "https"})
+ )
+
+ uruchom(scenariusz)
+ assert widziane == ["https", "http"]
+
+
+def test_lifespan_startup_complete():
+ start = StartMcp(_AtrapaLifespanu())
+ app = LifespanMcp(start)
+
+ async def scenariusz():
+ zdarzenia = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]
+ odpowiedzi = []
+
+ async def receive():
+ return zdarzenia.pop(0)
+
+ async def send(m):
+ odpowiedzi.append(m)
+
+ await app({"type": "lifespan"}, receive, send)
+ return [m["type"] for m in odpowiedzi]
+
+ assert uruchom(scenariusz) == [
+ "lifespan.startup.complete",
+ "lifespan.shutdown.complete",
+ ]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_naglowek_z_niepoprawnym_utf8_nie_wycieka_wyjatku(caplog, uczelnia):
+ """Usterka 2a z self-review PR #804: ``curl -H $'Authorization: Bearer
+ \\xff' https:///mcp`` dawał ``UnicodeDecodeError`` z
+ ``_dane()`` — wywoływanego w ``__call__`` PRZED jakąkolwiek siatką
+ bezpieczeństwa, więc wyjątek wychodził poza aplikację ASGI jako gołe 500
+ bez treści, bez wpisu w logu audytowym i bez Rollbara. Naprawa:
+ ``errors="replace"``, tak jak już robi ``mcp_server.auth`` (patrz
+ ``BramkaBearera._odmow``).
+
+ Bearer po zamianie na znak zastępczy jest po prostu NIEPRAWIDŁOWYM
+ tokenem — odpowiedź to zwykłe, kontrolowane 401, nie wyjątek."""
+ scope = zbuduj_scope("/mcp")
+ scope["headers"] = [*scope["headers"], (b"authorization", b"Bearer \xff")]
+
+ caplog.set_level(logging.INFO, logger="mcp_server.routing")
+ router = RouterHttp(_echo_sciezki, _django, StartMcp(_AtrapaLifespanu()))
+ status, _, _ = uruchom(lambda: wywolaj(router, scope))
+
+ assert status == 401
+ # Wpis audytowy (spec §7.5) MUSI powstać, mimo błędu w nagłówku —
+ # w buggy wersji finally nigdy nie był osiągany.
+ assert any("mcp POST /mcp" in rekord.message for rekord in caplog.records)
diff --git a/src/mcp_server/tests/test_start.py b/src/mcp_server/tests/test_start.py
new file mode 100644
index 000000000..553c89113
--- /dev/null
+++ b/src/mcp_server/tests/test_start.py
@@ -0,0 +1,188 @@
+"""StartMcp: wejście w lifespan aplikacji MCP dokładnie raz, w osobnym zadaniu.
+
+Kluczowa regresja (spec §5.1, bloker BL-3): wejście w grupę zadań anyio
+z zadania ŻĄDANIA czyni je host taskiem; aktywny w tym zadaniu cancel scope
+rzuca wtedy RuntimeError przy wyjściu, a grupa zostaje trwale zepsuta.
+"""
+
+import asyncio
+from contextlib import asynccontextmanager
+
+import anyio
+import pytest
+
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom
+
+
+class _AplikacjaZLifespanem:
+ """Namiastka aplikacji Starlette: liczy wejścia w lifespan.
+
+ ``lifespan_context`` otwiera PRAWDZIWĄ ``anyio.create_task_group()`` —
+ tak jak realnie robi to ``session_manager.run()`` z SDK MCP. Bez tego
+ atrapa nie odwzorowuje mechanizmu z BL-3: prosty ``@asynccontextmanager``
+ bez grupy zadań nie zostawia na stosie zadania żadnego cancel scope'u,
+ więc naiwna (błędna) implementacja ``StartMcp`` nie psuje się przez
+ ``RuntimeError`` opisany w docstringu ``start.py`` — tylko wisi, aż
+ zewnętrzny ``fail_after`` ubije ją po timeout (inny, dużo wolniejszy
+ i mniej precyzyjny sposób zawodzenia testu).
+ """
+
+ def __init__(self, blad=None):
+ self.wejscia = 0
+ self._blad = blad
+ self.router = self
+
+ @asynccontextmanager
+ async def lifespan_context(self, _app):
+ if self._blad is not None:
+ raise self._blad
+ self.wejscia += 1
+ async with anyio.create_task_group():
+ yield
+
+
+def test_zapewnij_wchodzi_dokladnie_raz():
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ await start.zapewnij()
+ await start.zapewnij()
+ await start.zapewnij()
+ return start.wystartowany
+
+ assert uruchom(scenariusz) is True
+ assert app.wejscia == 1
+
+
+def test_zapewnij_pod_aktywnym_cancel_scope_nie_psuje_zadania():
+ """REGRESJA BL-3: wywołanie z zadania, w którym trwa fail_after, nie może
+ ani wywalić tego zadania, ani zepsuć kolejnych wywołań."""
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ # 1 s wystarczy: przy poprawnej implementacji test i tak kończy się
+ # natychmiast, a przy regresji (zadanie wisi, trzymając grupę) budżet
+ # ogranicza karę CI do 1 s zamiast do domyślnych tu 5 s.
+ with anyio.fail_after(1):
+ await start.zapewnij()
+ # wyjście z fail_after musi się udać — i drugie wywołanie też
+ await start.zapewnij()
+ return app.wejscia
+
+ assert uruchom(scenariusz) == 1
+
+
+def test_blad_startu_jest_podnoszony_i_zapamietany():
+ app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa"))
+ start = StartMcp(app)
+
+ async def scenariusz():
+ with pytest.raises(RuntimeError):
+ await start.zapewnij()
+ return start.zywy
+
+ assert uruchom(scenariusz) is False
+
+
+def test_zadanie_przezywa_zadanie_ktore_je_utworzylo():
+ """Grupa zadań ma żyć dłużej niż żądanie, z którego wystartowała."""
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ async def udaje_zadanie():
+ await start.zapewnij()
+
+ await asyncio.create_task(udaje_zadanie())
+ await asyncio.sleep(0.05)
+ return start.zywy
+
+ assert uruchom(scenariusz) is True
+
+
+def test_anulowanie_nie_jest_zapamietane_jako_trwaly_blad():
+ """``CancelledError`` NIE może latchować stanu.
+
+ ``except BaseException`` łapało też anulowanie, więc JEDNO anulowanie
+ z dowolnego powodu (zamykanie procesu, ubita pętla, timeout serwera)
+ zapisywało się w ``_blad``. Skutki były dwa i oba złe: ``zapewnij()``
+ rzucało wyjątkiem zamiast pozwolić oddać kontrolowane 503, a przy KAŻDYM
+ zamknięciu workera szło zgłoszenie do Rollbara — z rutynowego zdarzenia.
+
+ Po poprawce anulowanie zwalnia czekających, nie zostaje zapamiętane
+ i nie jest raportowane. Endpoint i tak jest wtedy martwy
+ (``zywy is False`` → 503), bo ``session_manager.run()`` SDK wchodzi się
+ raz na instancję — świadomie NIE próbujemy restartu (patrz ``zapewnij``).
+ """
+ from unittest.mock import Mock
+
+ from mcp_server import start as modul_start
+
+ mock_rollbar = Mock()
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ await start.zapewnij()
+ start._zadanie.cancel()
+ # Dwie tury pętli: jedna na dostarczenie anulowania, druga na
+ # domknięcie zadania (``_trzymaj`` ma jeszcze except do wykonania).
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ await start.zapewnij() # NIE może rzucić — anulowanie to nie awaria
+ return start._blad, start.zywy
+
+ with pytest.MonkeyPatch.context() as mp:
+ mp.setattr(modul_start, "rollbar", mock_rollbar)
+ blad, zywy = uruchom(scenariusz)
+
+ assert blad is None, "anulowanie zostało zapamiętane jako trwały błąd"
+ assert zywy is False, "po anulowaniu żądania mają dostawać 503"
+ mock_rollbar.report_exc_info.assert_not_called()
+
+
+def test_blad_startu_jest_raportowany_do_rollbara(monkeypatch):
+ """Wyjątek host taska nie ma żadnej innej drogi do monitoringu:
+ ``CustomRollbarNotifierMiddleware`` łapie wyjątki Django, a ten leci
+ w zadaniu tła poza jakimkolwiek żądaniem (spec §7.5). Zgłoszenie idzie
+ stąd, a nie z ``RouterHttp``, żeby przypadło RAZ na awarię, a nie raz na
+ żądanie — inaczej martwy menedżer sam wyczerpałby kwotę Rollbara."""
+ from unittest.mock import Mock
+
+ from mcp_server import start as modul_start
+
+ mock_rollbar = Mock()
+ monkeypatch.setattr(modul_start, "rollbar", mock_rollbar)
+
+ app = _AplikacjaZLifespanem(blad=RuntimeError("brak Redisa"))
+ start = StartMcp(app)
+
+ async def scenariusz():
+ with pytest.raises(RuntimeError):
+ await start.zapewnij()
+ # drugie i trzecie żądanie NIE mogą dołożyć kolejnych zgłoszeń
+ for _ in range(2):
+ with pytest.raises(RuntimeError):
+ await start.zapewnij()
+
+ uruchom(scenariusz)
+ mock_rollbar.report_exc_info.assert_called_once()
+
+
+def test_wiele_rownoleglych_zapewnij_wchodzi_raz():
+ """Brak punktu przerwania między ``if self._zadanie is None`` a
+ ``create_task`` (komentarz w ``start.py``) ma znaczenie tylko wtedy, gdy
+ wiele wywołań ``zapewnij()`` rusza NAPRAWDĘ współbieżnie w tej samej
+ pętli — sekwencyjne `await` (jak w testach wyżej) tego nie sprawdza.
+ Tu odpalamy 50 wywołań przez ``asyncio.gather`` na raz."""
+ app = _AplikacjaZLifespanem()
+ start = StartMcp(app)
+
+ async def scenariusz():
+ await asyncio.gather(*(start.zapewnij() for _ in range(50)))
+ return app.wejscia
+
+ assert uruchom(scenariusz) == 1
diff --git a/src/mcp_server/tests/test_strona.py b/src/mcp_server/tests/test_strona.py
new file mode 100644
index 000000000..739088f65
--- /dev/null
+++ b/src/mcp_server/tests/test_strona.py
@@ -0,0 +1,26 @@
+"""Strona /mcp/ — instrukcja podłączenia dla człowieka."""
+
+import pytest
+
+
+@pytest.mark.django_db
+def test_strona_pokazuje_oba_adresy(client, settings):
+ # Bez nadpisania ALLOWED_HOSTS Django odda 400 dla obcego Hosta,
+ # zanim widok zdąży zbudować adresy (patrz test_cache_vary_host.py).
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+
+ odp = client.get("/mcp/", HTTP_HOST="bpp.example.test")
+
+ assert odp.status_code == 200
+ tresc = odp.content.decode()
+ assert "http://bpp.example.test/mcp" in tresc
+ assert "http://bpp.example.test/mcp/auth" in tresc
+
+
+@pytest.mark.django_db
+def test_adresy_sa_per_host(client, settings):
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost"]
+
+ a = client.get("/mcp/", HTTP_HOST="uczelnia1.localhost").content.decode()
+
+ assert "uczelnia1.localhost/mcp" in a
diff --git a/src/mcp_server/tests/test_wielohost.py b/src/mcp_server/tests/test_wielohost.py
new file mode 100644
index 000000000..aa3c3585e
--- /dev/null
+++ b/src/mcp_server/tests/test_wielohost.py
@@ -0,0 +1,64 @@
+"""Wewnętrzne żądanie musi nieść Host zewnętrznego — inaczej Uczelnia=None,
+BramkaApiV1 przepuszcza bezwarunkowo i wyciekają ukryte statusy (spec §7.2)."""
+
+import pytest
+from bpp_mcp.client import BppError
+
+from mcp_server.klient import BppClientInProcess
+from mcp_server.kontekst import DaneZadania, dane_zadania
+from mcp_server.tests.utils import uruchom
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def _pobierz(host, sciezka="uczelnia/"):
+ async def scenariusz():
+ zeton = dane_zadania.set(
+ DaneZadania(
+ host=host, scheme="http", ip="198.51.100.9", bearer=None, deadline=None
+ )
+ )
+ try:
+ klient = BppClientInProcess()
+ return await klient.get_json(sciezka)
+ finally:
+ dane_zadania.reset(zeton)
+
+ return uruchom(scenariusz)
+
+
+def test_kazdy_host_widzi_swoja_uczelnie(
+ settings, uczelnia1, uczelnia2, wydawnictwo_ciagle, przed_korekta
+):
+ """``/api/v1/uczelnia/`` NIE jest tu użyty — ``UczelniaViewSet.queryset``
+ to gołe ``Uczelnia.objects.all()``, bez scoping po Site/Host (jednostki
+ i uczelnie „obce" muszą być widoczne globalnie, bo autorzy afiliują się
+ między instytucjami we wspólnej instalacji — patrz komentarz w
+ ``JednostkaViewSet``). Sprawdzone empirycznie: oba hosty dostają
+ IDENTYCZNĄ listę obu uczelni.
+
+ Właściwością, która faktycznie różni się per Host, jest filtr
+ ``ukryte_statusy("api")`` z ``UkryjStatusyKorektyMixin``
+ (``api_v1/viewsets/common.py``) — on właśnie rozstrzyga uczelnię przez
+ ``Uczelnia.objects.get_for_request(self.request)``, czyli przez Host
+ żądania. Ukrywamy status korekty rekordu WYŁĄCZNIE dla ``uczelnia1``:
+ host ``uczelnia1.localhost`` musi rekord wykluczyć, a
+ ``uczelnia2.localhost`` (inna uczelnia, inna konfiguracja) — nie.
+ """
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"]
+ wydawnictwo_ciagle.status_korekty = przed_korekta
+ wydawnictwo_ciagle.save()
+ uczelnia1.ukryj_status_korekty_set.create(status_korekty=przed_korekta)
+
+ a = _pobierz("uczelnia1.localhost", "wydawnictwo_ciagle/")
+ b = _pobierz("uczelnia2.localhost", "wydawnictwo_ciagle/")
+ assert a["count"] != b["count"]
+
+
+def test_wylaczone_api_odmawia_takze_przez_mcp(settings, uczelnia1):
+ """Dowód, że BramkaApiV1 działa na tej ścieżce."""
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost"]
+ uczelnia1.api_v1_wlaczone = False
+ uczelnia1.save(update_fields=["api_v1_wlaczone"])
+ with pytest.raises(BppError):
+ _pobierz("uczelnia1.localhost")
diff --git a/src/mcp_server/tests/test_zdrowie.py b/src/mcp_server/tests/test_zdrowie.py
new file mode 100644
index 000000000..358592ebc
--- /dev/null
+++ b/src/mcp_server/tests/test_zdrowie.py
@@ -0,0 +1,153 @@
+"""Stan endpointu MCP: sonda ``/mcp/status`` i gałąź 503 na ``/mcp``.
+
+Kryterium spec §12 („Healthcheck rozróżnia stan procesu od stanu endpointu
+MCP”) było wcześniej niespełnione po OBU stronach: ``stan_mcp()`` nie było
+wołane z żadnego miejsca produkcyjnego, a gałąź 503 w ``RouterHttp`` była
+nieosiągalna, bo ``zapewnij()`` rzucało, ZANIM wykonanie doszło do sprawdzenia
+``self._start.zywy``.
+
+Te testy chodzą po PRAWDZIWYM ``StartMcp`` nad aplikacją z PRAWDZIWĄ
+``anyio.create_task_group()`` i zatruwają ją tak, jak robi to awaria produkcyjna
+(wyjątek w zadaniu-dziecku anuluje scope grupy). Poprzednia wersja tego pliku
+podstawiała atrapę z dwoma atrybutami — przeszłaby przy dowolnej implementacji,
+w tym przy takiej, w której sonda nie istnieje.
+"""
+
+import asyncio
+import json
+from contextlib import asynccontextmanager
+
+import anyio
+
+from mcp_server.routing import RouterHttp
+from mcp_server.start import StartMcp
+from mcp_server.tests.utils import uruchom, wywolaj, zbuduj_scope
+
+
+class _AplikacjaZGrupaZadan:
+ """Namiastka aplikacji Starlette z prawdziwą grupą zadań anyio.
+
+ ``session_manager.run()`` z SDK MCP zakłada dokładnie taką grupę; awaria
+ produkcyjna, o którą chodzi (§10.3), to wyjątek jej zadania-dziecka.
+ Atrapa musi mieć grupę, bo bez niej nie da się odtworzyć zatrucia.
+ """
+
+ def __init__(self) -> None:
+ self.router = self
+ self._grupa = None
+
+ @asynccontextmanager
+ async def lifespan_context(self, _app):
+ async with anyio.create_task_group() as grupa:
+ self._grupa = grupa
+ yield
+
+ def zatruj(self) -> None:
+ """Wpuść do grupy dziecko, które rzuca — jak awaria w menedżerze sesji."""
+
+ async def dziecko():
+ raise RuntimeError("zatrucie grupy zadań menedżera sesji")
+
+ self._grupa.start_soon(dziecko)
+
+
+async def _django(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send(
+ {"type": "http.response.body", "body": b"DJANGO:" + scope["path"].encode()}
+ )
+
+
+async def _mcp(scope, receive, send):
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"MCP"})
+
+
+def _router(app):
+ return RouterHttp(_mcp, _django, StartMcp(app))
+
+
+def test_status_startuje_menedzera_i_oddaje_200():
+ """Świeży worker pod Daphne nie przeszedł protokołu lifespan, więc sonda
+ MUSI wyzwolić leniwy start — inaczej raportowałaby „padnięte” na w pełni
+ zdrowym procesie.
+
+ Brak ``@pytest.mark.django_db`` jest tu ASERCJĄ, nie zaniedbaniem: sonda
+ ma nie dotykać bazy (wymóg z briefu), a pytest-django wywali każdy dostęp
+ do ORM w teście bez tego markera.
+ """
+ app = _AplikacjaZGrupaZadan()
+ router = _router(app)
+ status, naglowki, tresc = uruchom(
+ lambda: wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET"))
+ )
+ assert status == 200
+ assert naglowki["content-type"] == "application/json"
+ assert json.loads(tresc) == {"status": "ok", "wystartowany": True, "zywy": True}
+
+
+def test_status_po_zatruciu_oddaje_503_i_rozroznia_stan():
+ """Proces żyje (odpowiada!), a endpoint MCP nie — i sonda to rozróżnia.
+
+ ``wystartowany`` zostaje w treści właśnie po to, żeby odróżnić „jeszcze nie
+ wstał” od „wstał i padł”: to drugie znaczy, że czekanie nic nie da i trzeba
+ zrecyklingować workera.
+ """
+ app = _AplikacjaZGrupaZadan()
+ router = _router(app)
+
+ async def scenariusz():
+ przed = await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET"))
+ app.zatruj()
+ await asyncio.sleep(0.05) # niech dziecko zdąży rzucić i anulować grupę
+ po = await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET"))
+ return przed, po
+
+ (status_przed, _, _), (status_po, _, tresc_po) = uruchom(scenariusz)
+ assert status_przed == 200
+ assert status_po == 503
+ assert json.loads(tresc_po) == {
+ "status": "error",
+ "wystartowany": False,
+ "zywy": False,
+ }
+
+
+def test_mcp_po_zatruciu_oddaje_kontrolowane_503():
+ """REGRESJA: gałąź 503 ``RouterHttp`` była martwym kodem.
+
+ ``_trzymaj`` zapisuje wyjątek dziecka w ``_blad``, więc ``zapewnij()``
+ rzuca RuntimeError ZANIM wykonanie dojdzie do ``if not self._start.zywy``.
+ Wyjątek wychodził wtedy poza aplikację ASGI — serwer oddawał gołe 500 bez
+ treści i bez śladu w monitoringu. Test mutacyjny: usunięcie ``try/except``
+ wokół ``zapewnij()`` w ``_obsluz`` wywala ten test wyjątkiem, nie asercją.
+ """
+ app = _AplikacjaZGrupaZadan()
+ router = _router(app)
+
+ async def scenariusz():
+ # Zdrowy start przez sondę, nie przez /mcp: żądanie do /mcp przeszłoby
+ # przez bramkę uczelni (baza), a ten test ma sprawdzać wyłącznie
+ # kolejność sprawdzeń przy martwym menedżerze — 503 zapada ZANIM
+ # bramka uczelni w ogóle zostanie zawołana.
+ await wywolaj(router, zbuduj_scope("/mcp/status", metoda="GET"))
+ app.zatruj()
+ await asyncio.sleep(0.05)
+ return await wywolaj(router, zbuduj_scope("/mcp"))
+
+ status, naglowki, tresc = uruchom(scenariusz)
+ assert status == 503
+ assert json.loads(tresc) == {"error": "mcp_unavailable"}
+ assert naglowki["content-type"] == "application/json"
+
+
+def test_status_nie_idzie_do_django():
+ """``/mcp/status`` obsługuje warstwa ASGI — nie Django, więc odpowiada
+ także wtedy, gdy stos middleware (kreator, blokada odliczania) po drodze
+ przekierowuje albo blokuje."""
+ app = _AplikacjaZGrupaZadan()
+ status, _, tresc = uruchom(
+ lambda: wywolaj(_router(app), zbuduj_scope("/mcp/status", metoda="GET"))
+ )
+ assert status == 200
+ assert "DJANGO" not in tresc
diff --git a/src/mcp_server/tests/utils.py b/src/mcp_server/tests/utils.py
new file mode 100644
index 000000000..f2500153d
--- /dev/null
+++ b/src/mcp_server/tests/utils.py
@@ -0,0 +1,119 @@
+"""Pomocniki testowe warstwy ASGI.
+
+Repo nie ma ``pytest-asyncio``, a ``async_to_sync`` rzuca, gdy w bieżącym
+wątku biegnie już pętla (flaky zależny od kolejności testów — patrz
+``django_bpp/tests/test_asgi_notifications.py``). Odpalamy więc każdą korutynę
+w dedykowanym wątku, na świeżej, WŁASNORĘCZNIE zbudowanej pętli.
+
+Dlaczego nie ``asyncio.run``
+---------------------------
+
+``channels_broadcast.core.force_sync`` woła ``nest_asyncio.apply()``, gdy trafi
+na działającą pętlę zdarzeń. To łatka GLOBALNA I NIEODWRACALNA dla całego
+procesu — wystarczy, że wykona się raz, w dowolnym teście, i obowiązuje do
+końca życia workera pytest-xdist. Robi trzy rzeczy, z których każda nas boli:
+
+* podmienia ``asyncio.run`` na wersję biorącą pętlę z polityki zamiast tworzyć
+ własną — i nigdy jej nie zamykającą (stąd ``ResourceWarning: unclosed event
+ loop``). Nasze „każde wywołanie dostaje świeżą pętlę" przestaje być prawdą;
+* łata ``policy.get_event_loop``, więc każda pętla wydana tą drogą też jest
+ załatana;
+* podmienia ``_run_once`` na wersję wywłaszczającą bieżące zadanie
+ (``curr_tasks.pop``), przez co ``asyncio.current_task()`` bywa ``None``.
+
+Na tym ostatnim opiera się przekazywanie sterowania w ``asgiref`` między
+``SyncToAsync`` a ``AsyncToSync``. A my przechodzimy przez nie na KAŻDYM
+żądaniu ``/api/v1/``, bo ``ApiReadOnlyForBearerMiddleware`` jest synchroniczne
+i Django musi je w łańcuchu ASGI zaadaptować. Efekt na CI: wątek roboczy stoi
+w ``CurrentThreadExecutor.run_until_future``, wątek pętli stoi w ``select()``,
+i shard wisi do limitu joba. Diagnoza: zrzut SIGABRT/faulthandler z sharda 5.
+
+Obrona jest dwuczęściowa, bo łatka atakuje na dwóch poziomach.
+
+``asyncio.Runner`` (3.11+, a ``requires-python`` to ``>=3.11``) nie jest przez
+``nest_asyncio`` ruszany i daje semantykę sprzątania ``asyncio.run``: anulowanie
+zadań, ``shutdown_asyncgens``, ``shutdown_default_executor``, ``close``.
+
+Sama fabryka pętli nie wystarcza. ``_patch_loop`` robi ``cls =
+loop.__class__`` i podmienia metody NA KLASIE, więc ``asyncio.new_event_loop()``
+oddaje po zatruciu pętlę równie załataną. Dlatego ``PetlaBezLatki`` przywraca
+cztery metody wprost z ``asyncio.BaseEventLoop`` (klasy bazowej łatka nie
+dotyka — celuje w konkretną, np. ``_UnixSelectorEventLoop``) i deklaruje
+``_nest_patched = True``. Ten marker jest celowy: ``_patch_loop`` zaczyna się
+od ``if hasattr(loop, '_nest_patched'): return``, więc późniejsze ``apply()``
+ominie naszą klasę zamiast ją zepsuć.
+"""
+
+import asyncio
+import json
+from concurrent.futures import ThreadPoolExecutor
+
+
+class PetlaBezLatki(asyncio.SelectorEventLoop):
+ """Pętla ze standardowym sterowaniem, odporna na ``nest_asyncio``."""
+
+ run_forever = asyncio.BaseEventLoop.run_forever
+ run_until_complete = asyncio.BaseEventLoop.run_until_complete
+ _run_once = asyncio.BaseEventLoop._run_once
+ _check_running = asyncio.BaseEventLoop._check_running
+
+ # NIE literówka i nie kopiowanie na ślepo — patrz docstring modułu:
+ # to jest wartownik, który powstrzymuje `nest_asyncio._patch_loop`.
+ _nest_patched = True
+
+
+def _na_swiezej_petli(coro_factory):
+ with asyncio.Runner(loop_factory=PetlaBezLatki) as runner:
+ return runner.run(coro_factory())
+
+
+def uruchom(coro_factory):
+ """Odpal fabrykę korutyn w osobnym wątku i zwróć jej wynik.
+
+ Przyjmujemy FABRYKĘ, nie korutynę: korutyna utworzona w wątku wołającym
+ byłaby związana z jego (nieistniejącą) pętlą.
+ """
+ with ThreadPoolExecutor(max_workers=1) as ex:
+ return ex.submit(_na_swiezej_petli, coro_factory).result()
+
+
+def zbuduj_scope(sciezka, metoda="POST", host="bpp.example.test", naglowki=None):
+ """Minimalny scope HTTP/ASGI do wołania aplikacji wprost."""
+ bazowe = [(b"host", host.encode())]
+ for k, v in (naglowki or {}).items():
+ bazowe.append((k.lower().encode(), v.encode()))
+ return {
+ "type": "http",
+ "asgi": {"version": "3.0", "spec_version": "2.3"},
+ "http_version": "1.1",
+ "method": metoda,
+ "scheme": "http",
+ "path": sciezka,
+ "raw_path": sciezka.encode(),
+ "query_string": b"",
+ "root_path": "",
+ "headers": bazowe,
+ "client": ("203.0.113.7", 54321),
+ "server": (host, 80),
+ }
+
+
+async def wywolaj(app, scope, cialo=None):
+ """Zawołaj aplikację ASGI i zwróć ``(status, naglowki, tekst)``."""
+ raw = json.dumps(cialo).encode() if cialo is not None else b""
+ do_odebrania = [{"type": "http.request", "body": raw, "more_body": False}]
+ wyslane = []
+
+ async def receive():
+ return do_odebrania.pop(0) if do_odebrania else {"type": "http.disconnect"}
+
+ async def send(msg):
+ wyslane.append(msg)
+
+ await app(scope, receive, send)
+ start = next((m for m in wyslane if m["type"] == "http.response.start"), None)
+ tresc = b"".join(
+ m.get("body", b"") for m in wyslane if m["type"] == "http.response.body"
+ )
+ naglowki = {k.decode(): v.decode() for k, v in (start or {}).get("headers", [])}
+ return (start or {}).get("status"), naglowki, tresc.decode(errors="replace")
diff --git a/src/mcp_server/uczelnia.py b/src/mcp_server/uczelnia.py
new file mode 100644
index 000000000..18f6eab09
--- /dev/null
+++ b/src/mcp_server/uczelnia.py
@@ -0,0 +1,92 @@
+"""Fail-closed rozstrzyganie uczelni dla nagłówka ``Host`` żądania MCP.
+
+Na ścieżce ``/mcp`` **Host jest kontrolą dostępu** (spec §7.2): to on decyduje,
+którą ``Uczelnia`` zobaczy ``BramkaApiV1``, ``scope_rekord_do_uczelni``
+i ``UkryjStatusyKorektyMixin`` w żądaniu wewnętrznym. A wszystkie trzy są
+zbudowane tak, że **brak uczelni znaczy „nie ograniczaj”**:
+
+* ``BramkaApiV1.has_permission`` przy ``uczelnia is None`` zwraca ``True``
+ BEZWARUNKOWO — także gdy administrator ustawił ``api_v1_wlaczone=False``
+ albo ``api_v1_tylko_zalogowani=True`` (``api_v1/permissions.py``);
+* ``scope_rekord_do_uczelni`` staje się no-op (``bpp/util/uczelnia_scope.py``);
+* ``UkryjStatusyKorektyMixin`` przestaje wykluczać ukryte statusy korekty
+ (``api_v1/viewsets/common.py``).
+
+To jest bezpieczne dla zwykłego ruchu HTTP, bo tam nagłówek ``Host`` przechodzi
+przez ``ALLOWED_HOSTS``, a ``ALLOWED_HOSTS`` na produkcji zawiera hosty
+INFRASTRUKTURALNE (``127.0.0.1``, ``appserver``, ``appserver:8000`` —
+``settings/production.py``), które nie mają swojego ``Site``. Dla nich
+``Uczelnia.objects.get_for_request`` schodzi na fallback ``SITE_ID`` albo
+zwraca ``None``: w instalacji wielouczelnianej ``curl -H 'Host: appserver'``
+obchodziłby wyłącznik API i filtr ukrytych statusów.
+
+Dlatego na tej jednej ścieżce host, który **nie identyfikuje jednoznacznie
+uczelni**, jest odrzucany, zamiast obsłużony z otwartą bramką.
+
+Dlaczego sprawdzenie jest PER ŻĄDANIE, a nie w allowliście transportowej
+(``aplikacja._dozwolone_hosty``): tamta jest liczona przy budowie aplikacji,
+czyli przy imporcie ``django_bpp.asgi``, a import modułu ASGI nie może zależeć
+od dostępnej bazy. Powiązanie host→``Site``→``Uczelnia`` to DANE, nie
+konfiguracja — administrator dokłada i zabiera je bez restartu procesu, więc
+tylko odczyt per żądanie mówi prawdę. Allowlista transportowa zostaje tym, czym
+jest w SDK: ochroną przed DNS rebindingiem, a nie kontrolą wielotenantową.
+"""
+
+from __future__ import annotations
+
+from asgiref.sync import sync_to_async
+from django.db import close_old_connections
+
+
+class _ZadanieZHostem:
+ """Namiastka ``HttpRequest`` — ``get_for_request`` czyta tylko ``get_host``."""
+
+ def __init__(self, host: str) -> None:
+ self._host = host
+
+ def get_host(self) -> str:
+ return self._host
+
+
+def _rozstrzygalny(host: str) -> bool:
+ """Czy ``host`` jednoznacznie wskazuje jedną ``Uczelnia``?
+
+ Dwie ścieżki, obie zamierzone:
+
+ 1. **Host ma swój ``Site``** — normalna sytuacja instalacji
+ wielouczelnianej. Wtedy pytamy ``get_for_request`` dokładnie tak, jak
+ zrobi to później ``BramkaApiV1``, i wymagamy niepustego wyniku. ``Site``
+ bez podpiętej uczelni w instalacji z wieloma uczelniami da ``None`` →
+ odmowa.
+ 2. **Host nie ma ``Site``** — dopuszczalne WYŁĄCZNIE w instalacji
+ jednouczelnianej, gdzie fallback na ``SITE_ID`` (a dalej na „jedyną
+ uczelnię”) jest legalny i tak działa cały serwis. Warunkiem jest, żeby
+ uczelnia w bazie była dokładnie jedna: przy zerze nie ma czego serwować
+ (świeża instalacja, niedokończony kreator), a przy dwóch i więcej host
+ niczego nie rozstrzyga i wybór byłby zgadywaniem.
+
+ ``close_old_connections`` jak w ``mcp_server.auth``: biegniemy poza cyklem
+ żądania Django, więc sygnały ``request_started/finished`` nie sprzątają
+ połączeń za nas.
+ """
+ from django.contrib.sites.models import Site
+
+ from bpp.models import Uczelnia
+
+ hostname = host.split(":")[0]
+ close_old_connections()
+ try:
+ if (
+ not Site.objects.filter(domain=hostname).exists()
+ and Uczelnia.objects.count() != 1
+ ):
+ return False
+ return Uczelnia.objects.get_for_request(_ZadanieZHostem(host)) is not None
+ finally:
+ close_old_connections()
+
+
+#: Wersja asynchroniczna — warstwa ASGI nie ma pętli, w której wolno jej
+#: blokować na ORM. ``thread_sensitive=False`` jak przy weryfikacji tokenu:
+#: to czysty odczyt, bez współdzielonego stanu transakcyjnego z wołającym.
+host_rozstrzyga_uczelnie = sync_to_async(_rozstrzygalny, thread_sensitive=False)
diff --git a/src/mcp_server/urls.py b/src/mcp_server/urls.py
new file mode 100644
index 000000000..4191edb87
--- /dev/null
+++ b/src/mcp_server/urls.py
@@ -0,0 +1,11 @@
+"""Routing Django dla strony /mcp/ (instrukcja dla człowieka)."""
+
+from django.urls import path
+
+from mcp_server.views import StronaMcp
+
+app_name = "mcp_server"
+
+urlpatterns = [
+ path("", StronaMcp.as_view(), name="strona"),
+]
diff --git a/src/mcp_server/views.py b/src/mcp_server/views.py
new file mode 100644
index 000000000..6bf9555a5
--- /dev/null
+++ b/src/mcp_server/views.py
@@ -0,0 +1,21 @@
+"""Widoki serwera MCP przeznaczone dla człowieka (nie dla klienta MCP)."""
+
+from django.views.generic import TemplateView
+
+
+class StronaMcp(TemplateView):
+ """Instrukcja podłączenia klienta AI — adresy składane per host.
+
+ Adresy MCP (``/mcp`` i ``/mcp/auth``) obsługuje warstwa ASGI
+ (``mcp_server.routing.RouterHttp``) — ta strona (``/mcp/``, ze
+ slashem) idzie normalnym trybem przez Django i tylko pokazuje,
+ dokąd wkleić te adresy w kliencie AI.
+ """
+
+ template_name = "mcp_server/index.html"
+
+ def get_context_data(self, **kwargs):
+ kontekst = super().get_context_data(**kwargs)
+ kontekst["adres_publiczny"] = self.request.build_absolute_uri("/mcp")
+ kontekst["adres_z_logowaniem"] = self.request.build_absolute_uri("/mcp/auth")
+ return kontekst
diff --git a/src/mcp_server/zdrowie.py b/src/mcp_server/zdrowie.py
new file mode 100644
index 000000000..8b02c0c4f
--- /dev/null
+++ b/src/mcp_server/zdrowie.py
@@ -0,0 +1,39 @@
+"""Stan endpointu MCP dla monitoringu (spec §10.3).
+
+Grupa zadań menedżera sesji MCP (``StartMcp``) może zostać zatruta wyjątkiem
+dziecka — proces wtedy żyje, ale każde ``/mcp`` zwraca błąd aż do recyklingu
+workera. Monitoring musi więc odróżniać „proces działa” od „endpoint MCP
+działa”: drugie jest ostrzejszym warunkiem od pierwszego.
+
+**Dlaczego to NIE jest wpięte w ``/health/``.** Tamten endpoint jest sondą
+Dockera (``docker/appserver/*``): 503 z niego restartuje cały appserver.
+Awaria izolowana do MCP zabijałaby wtedy serwis, który poza ``/mcp`` działa
+bez zarzutu — lekarstwo gorsze od choroby. Stąd osobny adres ``/mcp/status``,
+obsługiwany przez ``RouterHttp`` (a nie przez Django), dzięki czemu odpowiada
+bez uwierzytelnienia, bez bazy i bez przechodzenia przez cały stos middleware.
+"""
+
+from __future__ import annotations
+
+
+def stan_startu(start) -> tuple[dict, int]:
+ """Zwróć ``(treść JSON, kod HTTP)`` opisujące stan menedżera sesji MCP.
+
+ Czysta funkcja nad ``StartMcp`` — bierze obiekt, którym dysponuje warstwa
+ ASGI. Poprzednia wersja sięgała po ``django_bpp.asgi._mcp_start`` sama
+ i nie była wołana z ŻADNEGO miejsca produkcyjnego; szew przez argument
+ pozwala testom podstawić prawdziwy, zatruty ``StartMcp``, zamiast atrapy
+ z dwoma atrybutami, która przeszłaby przy dowolnej implementacji.
+
+ ``zywy`` (a nie ``wystartowany``) rozstrzyga o kodzie: to ono odpowiada
+ na pytanie „czy POST /mcp da teraz odpowiedź”. ``wystartowany`` zostaje
+ w treści, bo odróżnia „jeszcze nie wstał” od „wstał i padł” — czyli mówi,
+ czy warto czekać, czy trzeba restartować workera.
+ """
+ zywy = start.zywy
+ tresc = {
+ "status": "ok" if zywy else "error",
+ "wystartowany": start.wystartowany,
+ "zywy": zywy,
+ }
+ return tresc, (200 if zywy else 503)
diff --git a/src/oauth_mcp/audience.py b/src/oauth_mcp/audience.py
new file mode 100644
index 000000000..0f78fee05
--- /dev/null
+++ b/src/oauth_mcp/audience.py
@@ -0,0 +1,89 @@
+"""Walidator audience (RFC 8707) dopasowany do tego, czym BPP naprawdę jest.
+
+Kontekst — dlaczego domyślny walidator DOT tu nie działa
+========================================================
+
+Klient MCP zgodny ze specyfikacją autoryzacji (2025-06-18) **MUSI** wysłać
+parametr ``resource`` (RFC 8707) w żądaniu autoryzacji i w żądaniu o token,
+z kanonicznym adresem serwera MCP — u nas ``https:///mcp``. Wartość NIE
+pochodzi z naszego dokumentu PRM, tylko z adresu, pod który klient się łączy,
+więc **usunięcie pola ``resource`` z PRM niczego by nie zmieniło**: token i tak
+przyszedłby zawężony (zweryfikowane w kodzie ``oauthlib``: ``Request.__init__``
+wciąga KAŻDY parametr query/body do ``_params``, więc DOT zapisuje ``resource``
+w ``Grant`` i dalej w ``AccessToken``).
+
+Domyślny walidator DOT (``validate_resource_as_url_prefix``) porównuje adres
+żądania z audience PREFIKSEM ŚCIEŻKI. Tymczasem serwer MCP obsługuje wywołanie
+narzędzia, wołając WŁASNE ``/api/v1/`` w procesie (``mcp_server.klient``) —
+z tym samym bearerem. Adres tego żądania to ``https:///api/v1/...``,
+który nie zaczyna się od ``https:///mcp``, więc DOT odrzucał go 401-ką.
+
+Zmierzone na pełnym stosie (``mcp_server/tests/test_audience.py``): token
+z ``resource=["http:///mcp"]`` przechodził naszą bramkę ``/mcp``, po czym
+KAŻDE wywołanie narzędzia kończyło się ``BppError: Błąd HTTP 401`` z
+``/api/v1/``. Czyli: dla klienta zgodnego ze specyfikacją cała ścieżka
+zalogowana była martwa, a jednocześnie audience nie było przez nas sprawdzane
+nigdzie — deklarowaliśmy je w PRM i nie egzekwowaliśmy.
+
+Co robimy zamiast tego
+======================
+
+Egzekwujemy audience na **poziomie originu**, a nie prefiksu ścieżki:
+
+* ``/mcp`` i ``/api/v1/`` to JEDEN Resource Server — ta sama instancja BPP, ten
+ sam zbiór danych, ten sam scope ``read``. Narzędzie MCP z definicji czyta
+ ``/api/v1/``, więc token dopuszczony do ``/mcp`` z natury rzeczy sięga po te
+ same dane. Granulacja per-ścieżka wewnątrz jednego originu nie chroni tu
+ niczego, a łamie propagację tożsamości do żądania wewnętrznego.
+* Tym, przed czym RFC 8707 realnie chroni, jest użycie tokenu u INNEGO Resource
+ Servera — a w instalacji wielouczelnianej BPP „inny serwer" to inny host.
+ Origin egzekwujemy więc nadal, i to jest zysk: token wydany pod hostem
+ uczelni A zostaje odrzucony pod hostem uczelni B (wcześniej — przy domyślnym
+ walidatorze — również, ale za cenę odrzucania go także pod hostem A).
+
+Ograniczenie, którego ten walidator NIE usuwa: token wydany BEZ ``resource``
+(np. założony ręcznie w adminie albo przez klienta, który RFC 8707 nie
+implementuje) jest z definicji nieograniczony — ``AccessToken.allows_audience``
+zwraca dla pustej listy ``True``, zanim jakikolwiek walidator zostanie
+zawołany. Takiego tokenu nie da się związać z hostem bez zmiany semantyki DOT
+dla całego projektu; jest to odnotowane w otwartych kwestiach specyfikacji.
+"""
+
+from __future__ import annotations
+
+from oauth2_provider.oauth2_validators import _parse_and_validate_uri
+
+
+def waliduj_audience_po_originie(request_uri: str, audiences: list[str]) -> bool:
+ """Czy ``request_uri`` leży w tym samym originie, co któreś z ``audiences``?
+
+ Podpis i kontrakt jak w ``oauth2_provider.oauth2_validators
+ .validate_resource_as_url_prefix`` (to jest podmiana ustawienia
+ ``RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR``): pusta lista audience znaczy
+ „bez ograniczeń", niepoprawny adres żądania znaczy odmowa.
+
+ Parsowanie oddajemy funkcji DOT ``_parse_and_validate_uri`` — mimo
+ podkreślenia w nazwie — zamiast pisać własne. To ona odrzuca adresy
+ z ``userinfo`` (``https://host@evil.example/``) i z fragmentem, oraz
+ normalizuje domyślny port schematu (``https://h`` == ``https://h:443``).
+ Własna implementacja tych niuansów byłaby dokładnie tym miejscem, w którym
+ powstają obejścia typu „authority confusion".
+ """
+ if not audiences:
+ return True
+
+ czesci_zadania = _parse_and_validate_uri(request_uri)
+ if czesci_zadania is None:
+ return False
+ origin_zadania = czesci_zadania[:3] # (schemat, host, port)
+
+ for audience in audiences:
+ czesci_audience = _parse_and_validate_uri(audience)
+ if czesci_audience is None:
+ # Pojedynczy śmieciowy wpis nie może przesądzać o całości —
+ # dokładnie tak samo robi walidator DOT (``continue``).
+ continue
+ if czesci_audience[:3] == origin_zadania:
+ return True
+
+ return False
diff --git a/src/oauth_mcp/tests/test_audience.py b/src/oauth_mcp/tests/test_audience.py
new file mode 100644
index 000000000..6ed437e4a
--- /dev/null
+++ b/src/oauth_mcp/tests/test_audience.py
@@ -0,0 +1,105 @@
+"""Walidator audience RFC 8707 zawężający po ORIGINIE, nie po prefiksie ścieżki.
+
+Uzasadnienie wyboru (i pomiar, który go wymusił) siedzi w docstringu
+``oauth_mcp.audience``. Tu pilnujemy KONTRAKTU: co ma przejść, a co nie.
+"""
+
+import pytest
+
+from oauth_mcp.audience import waliduj_audience_po_originie
+
+ZASOB = "https://bpp.example.test/mcp"
+
+
+@pytest.mark.parametrize(
+ "adres",
+ [
+ "https://bpp.example.test/mcp",
+ "https://bpp.example.test/mcp/auth",
+ # SEDNO poprawki: żądanie wewnętrzne serwera MCP do własnego /api/v1/.
+ # Domyślny walidator DOT (prefiks ścieżki) odrzucał je 401-ką, przez co
+ # zalogowana ścieżka /mcp/auth była martwa dla zgodnego klienta.
+ "https://bpp.example.test/api/v1/autor/",
+ # Port domyślny schematu jest normalizowany po obu stronach.
+ "https://bpp.example.test:443/api/v1/rekord/",
+ ],
+)
+def test_ten_sam_origin_przechodzi(adres):
+ assert waliduj_audience_po_originie(adres, [ZASOB]) is True
+
+
+@pytest.mark.parametrize(
+ "adres",
+ [
+ # Inna uczelnia w instalacji wielouczelnianej — wspólna tabela tokenów,
+ # więc BEZ tej kontroli token z hosta A działałby pod hostem B.
+ "https://inna.example.test/api/v1/autor/",
+ # Inny schemat to inny origin (RFC 6454) — nie schodzimy z https na http.
+ "http://bpp.example.test/api/v1/autor/",
+ # Inny port to inny origin.
+ "https://bpp.example.test:8443/api/v1/autor/",
+ # Authority confusion: prawdziwym hostem jest evil.example.
+ "https://bpp.example.test@evil.example/api/v1/autor/",
+ # Adres względny (bez originu) niczego nie dowodzi → fail closed.
+ "/api/v1/autor/",
+ "",
+ ],
+)
+def test_obcy_albo_niepoprawny_origin_odrzucony(adres):
+ assert waliduj_audience_po_originie(adres, [ZASOB]) is False
+
+
+def test_schemat_musi_sie_zgadzac_nawet_przy_takim_samym_porcie():
+ """Audyt PR #804, punkt 3: przypadek ``http://…/autor/`` powyżej
+ (``test_obcy_albo_niepoprawny_origin_odrzucony``) różni się od ``ZASOB``
+ RÓWNOCZEŚNIE schematem i portem domyślnym (80 vs 443) — walidator
+ zmutowany tak, żeby porównywał TYLKO ``(host, port)`` (pomijając
+ schemat), i tak by go odrzucił, bo porty się różnią. Różnica portu
+ maskuje więc różnicę schematu i test nie dowodzi tego, co deklaruje.
+
+ Tu port jest JAWNY i RÓWNY po obu stronach (443), więc jedyną różnicą
+ zostaje schemat — dokładnie to, co ``waliduj_audience_po_originie`` ma
+ sprawdzać poprzez porównanie krotki ``[:3]`` (schemat, host, port), nie
+ samego ``[1:3]``."""
+ audiences = ["https://bpp.example.test:443/mcp"]
+ assert (
+ waliduj_audience_po_originie(
+ "http://bpp.example.test:443/api/v1/autor/", audiences
+ )
+ is False
+ )
+
+
+def test_ten_sam_origin_z_jawnym_portem_nadal_przechodzi():
+ """Kontrola pozytywna do testu wyżej — jawny, równy port po obu stronach
+ NIE ma sam z siebie nic odrzucać, gdy schemat i host też się zgadzają."""
+ audiences = ["https://bpp.example.test:443/mcp"]
+ assert (
+ waliduj_audience_po_originie(
+ "https://bpp.example.test:443/api/v1/autor/", audiences
+ )
+ is True
+ )
+
+
+def test_pusta_lista_audience_znaczy_bez_ograniczen():
+ """Kontrakt DOT: token bez ``resource`` jest nieograniczony (kompatybilność
+ wstecz z tokenami sprzed RFC 8707)."""
+ assert waliduj_audience_po_originie("https://gdziekolwiek.test/x", []) is True
+
+
+def test_smieciowy_wpis_nie_uniewaznia_pozostalych():
+ """Pojedynczy niepoprawny wpis w ``resource`` ma być pominięty, nie ma
+ przesądzać o całości — dokładnie jak w walidatorze DOT (``continue``)."""
+ audiences = ["urn:cos:tam", ZASOB]
+ assert (
+ waliduj_audience_po_originie(
+ "https://bpp.example.test/api/v1/autor/", audiences
+ )
+ is True
+ )
+
+
+def test_same_smieciowe_wpisy_odrzucone():
+ wynik = waliduj_audience_po_originie("https://bpp.example.test/mcp", ["nie-uri"])
+ assert wynik is False
diff --git a/src/oauth_mcp/tests/test_metadata_prm.py b/src/oauth_mcp/tests/test_metadata_prm.py
new file mode 100644
index 000000000..1ed7f650d
--- /dev/null
+++ b/src/oauth_mcp/tests/test_metadata_prm.py
@@ -0,0 +1,32 @@
+"""PRM (RFC 9728) — wskazuje klientowi, gdzie jest serwer autoryzacji."""
+
+import pytest
+
+
+@pytest.mark.django_db
+def test_prm_zwraca_wlasciwe_urle(client, settings):
+ # Django odda 400 (DisallowedHost) dla hosta spoza ALLOWED_HOSTS —
+ # bez tego nadpisania test padłby na kliencie testowym, nie na
+ # widoku, który mamy zweryfikować.
+ settings.ALLOWED_HOSTS = ["bpp.example.test"]
+ odp = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="bpp.example.test"
+ )
+ assert odp.status_code == 200
+ dane = odp.json()
+ assert dane["resource"] == "http://bpp.example.test/mcp"
+ assert dane["authorization_servers"] == ["http://bpp.example.test"]
+ assert dane["scopes_supported"] == ["read"]
+
+
+@pytest.mark.django_db
+def test_prm_jest_per_host(client, settings):
+ """Wielo-domenowość: każda uczelnia widzi swój adres."""
+ settings.ALLOWED_HOSTS = ["uczelnia1.localhost", "uczelnia2.localhost"]
+ a = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia1.localhost"
+ ).json()
+ b = client.get(
+ "/.well-known/oauth-protected-resource", HTTP_HOST="uczelnia2.localhost"
+ ).json()
+ assert a["resource"] != b["resource"]
diff --git a/src/oauth_mcp/urls.py b/src/oauth_mcp/urls.py
index 1f67b34d5..80c61954a 100644
--- a/src/oauth_mcp/urls.py
+++ b/src/oauth_mcp/urls.py
@@ -2,7 +2,10 @@
from oauth2_provider import views as oauth2_views
from oauth_mcp.views_dcr import DynamicClientRegistrationView
-from oauth_mcp.views_metadata import oauth_authorization_server_metadata
+from oauth_mcp.views_metadata import (
+ oauth_authorization_server_metadata,
+ oauth_protected_resource_metadata,
+)
# BEZ `app_name` na tym module! Deklaracja `app_name="oauth_mcp"` zagnieżdżałaby
# namespace DOT (`oauth_mcp:oauth2_provider:authorize`) i psuła
@@ -33,4 +36,9 @@
oauth_authorization_server_metadata,
name="oauth-as-metadata",
),
+ path(
+ ".well-known/oauth-protected-resource",
+ oauth_protected_resource_metadata,
+ name="oauth-prm",
+ ),
]
diff --git a/src/oauth_mcp/views_metadata.py b/src/oauth_mcp/views_metadata.py
index 895d66e6f..15c0a1a5c 100644
--- a/src/oauth_mcp/views_metadata.py
+++ b/src/oauth_mcp/views_metadata.py
@@ -22,3 +22,21 @@ def oauth_authorization_server_metadata(request):
"token_endpoint_auth_methods_supported": ["none"],
}
)
+
+
+def oauth_protected_resource_metadata(request):
+ """RFC 9728 — mówi klientowi MCP, który serwer autoryzacji obsługuje /mcp.
+
+ Poprzedni spec przypisywał ten dokument pakietowi bpp-mcp; odkąd Resource
+ Serverem jest sama instancja BPP, mieszka on tutaj. URL-e przez
+ build_absolute_uri — poprawny scheme z SECURE_PROXY_SSL_HEADER, host
+ per-request (wielo-domenowość).
+ """
+ return JsonResponse(
+ {
+ "resource": request.build_absolute_uri("/mcp"),
+ "authorization_servers": [request.build_absolute_uri("/").rstrip("/")],
+ "scopes_supported": ["read"],
+ "bearer_methods_supported": ["header"],
+ }
+ )
diff --git a/uv.lock b/uv.lock
index d154dd95b..47d6f15c8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -52,21 +52,30 @@ name = "amqp"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "vine" },
+ { name = "vine", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
]
+[[package]]
+name = "annotated-types"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
+]
+
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "idna" },
- { name = "sniffio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sniffio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
@@ -87,8 +96,8 @@ name = "arrow"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "python-dateutil" },
- { name = "tzdata" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzdata", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
wheels = [
@@ -145,10 +154,10 @@ name = "autobahn"
version = "24.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "hyperlink" },
- { name = "setuptools" },
- { name = "txaio" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "hyperlink", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "txaio", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/f2/8dffb3b709383ba5b47628b0cc4e43e8d12d59eecbddb62cfccac2e7cf6a/autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9", size = 482700, upload-time = "2024-08-02T09:26:48.241Z" }
wheels = [
@@ -178,8 +187,8 @@ name = "azure-core"
version = "1.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
- { name = "typing-extensions" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" }
wheels = [
@@ -191,10 +200,10 @@ name = "azure-storage-blob"
version = "12.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "azure-core" },
- { name = "cryptography" },
- { name = "isodate" },
- { name = "typing-extensions" },
+ { name = "azure-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "isodate", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" }
wheels = [
@@ -294,8 +303,8 @@ name = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "soupsieve" },
- { name = "typing-extensions" },
+ { name = "soupsieve", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
@@ -307,7 +316,7 @@ name = "bibtexparser"
version = "2.0.0b9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pylatexenc" },
+ { name = "pylatexenc", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/37/e21f79f5116ad1fb92a8eb0e8a005cad59eb24660003982840daa6b9fafd/bibtexparser-2.0.0b9.tar.gz", hash = "sha256:bcddf796cfe321bb92c17c844ca65d81731fb753a1dd0e82bc76a9947aa0f360", size = 40435, upload-time = "2026-01-29T19:47:52.605Z" }
wheels = [
@@ -328,7 +337,7 @@ name = "bleach"
version = "6.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "webencodings" },
+ { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
wheels = [
@@ -340,9 +349,9 @@ name = "boto3"
version = "1.43.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "botocore" },
- { name = "jmespath" },
- { name = "s3transfer" },
+ { name = "botocore", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jmespath", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "s3transfer", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/31/32388d5ec332ffe81d8f3650860f94b66294009172d188c390cad58c6f5f/boto3-1.43.22.tar.gz", hash = "sha256:2a7fe12d8e0731bb8aa7c1e59b4ccc770fda031b8659c2f6f497393bdcec3051", size = 113203, upload-time = "2026-06-03T19:33:13.39Z" }
wheels = [
@@ -354,9 +363,9 @@ name = "botocore"
version = "1.43.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "jmespath" },
- { name = "python-dateutil" },
- { name = "urllib3" },
+ { name = "jmespath", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/cf/840f1b8db16d45e3807c23d1ea779723eed1cd9cf3b6c49e16f372d2a777/botocore-1.43.22.tar.gz", hash = "sha256:b00de525e538289ed4a7a85263f1be4e47473c124cec87be6b23be49356bf745", size = 15458781, upload-time = "2026-06-03T19:33:02.882Z" }
wheels = [
@@ -368,186 +377,187 @@ name = "bpp-iplweb"
version = "202608.1401"
source = { editable = "." }
dependencies = [
- { name = "arrow" },
- { name = "babel" },
- { name = "beautifulsoup4" },
- { name = "bibtexparser" },
- { name = "celery" },
- { name = "celery-singleton" },
- { name = "channels", extra = ["daphne"] },
- { name = "channels-redis" },
- { name = "chardet" },
- { name = "crispy-bootstrap3" },
- { name = "crispy-bootstrap5" },
- { name = "crispy-forms-foundation" },
- { name = "crossrefapi" },
- { name = "cryptography" },
- { name = "cssmin" },
- { name = "dbfread" },
- { name = "dj-pagination" },
- { name = "django" },
- { name = "django-admin-sortable2" },
- { name = "django-admin-tools" },
- { name = "django-altcha" },
- { name = "django-autocomplete-light" },
- { name = "django-autoslug" },
- { name = "django-axes" },
- { name = "django-braces" },
- { name = "django-cacheops" },
- { name = "django-celery-email" },
- { name = "django-celery-results" },
- { name = "django-channels-broadcast" },
- { name = "django-classy-tags" },
- { name = "django-columns" },
- { name = "django-compressor" },
- { name = "django-constance" },
- { name = "django-cookie-law" },
- { name = "django-countdown" },
- { name = "django-crispy-forms" },
- { name = "django-dbtemplates-iplweb" },
- { name = "django-denorm-iplweb" },
- { name = "django-dirtyfields" },
- { name = "django-dsl" },
- { name = "django-dynamic-admin-columns" },
- { name = "django-easy-audit" },
- { name = "django-environ" },
- { name = "django-extensions" },
- { name = "django-favicon-plus-reloaded" },
- { name = "django-filter" },
- { name = "django-first-run-wizard" },
- { name = "django-flexible-reports" },
- { name = "django-formdefaults" },
- { name = "django-formtools" },
- { name = "django-fsm-2" },
- { name = "django-grappelli" },
- { name = "django-group-by" },
- { name = "django-import-export" },
- { name = "django-liveops", extra = ["celery"] },
- { name = "django-loginas" },
- { name = "django-messages-extends" },
- { name = "django-minify-html" },
- { name = "django-model-utils" },
- { name = "django-mptt" },
- { name = "django-multiseek" },
- { name = "django-oauth-toolkit" },
- { name = "django-password-policies-iplweb" },
- { name = "django-pbn-client" },
- { name = "django-pg-baseline" },
- { name = "django-polish-inflection" },
- { name = "django-prometheus" },
- { name = "django-querysetsequence" },
- { name = "django-redis-iplweb" },
- { name = "django-reversion" },
- { name = "django-reversion-compare" },
- { name = "django-sendfile2" },
- { name = "django-session-security" },
- { name = "django-site-blog" },
- { name = "django-soft-delete" },
- { name = "django-static-sitemaps" },
- { name = "django-tables2" },
- { name = "django-tabular-permissions" },
- { name = "django-taggit" },
- { name = "django-templated-email" },
- { name = "django-tinymce" },
- { name = "django-weasyprint" },
- { name = "django-webmaster-verification" },
- { name = "djangoql-iplweb" },
- { name = "djangorestframework" },
- { name = "dspace-rest-client" },
- { name = "gunicorn" },
- { name = "isbnlib" },
- { name = "langdetect" },
- { name = "markdown" },
- { name = "moai-iplweb" },
- { name = "mozilla-django-oidc" },
- { name = "nh3" },
- { name = "numpy" },
- { name = "openpyxl" },
- { name = "ortools" },
- { name = "pandas" },
- { name = "pathspec" },
- { name = "pbn-client" },
- { name = "pillow" },
- { name = "psycopg2-binary" },
- { name = "pygad" },
- { name = "pymed-iplweb" },
- { name = "pypandoc" },
- { name = "python-dateutil" },
- { name = "python-docx" },
- { name = "questionary" },
- { name = "requests" },
- { name = "requests-oauthlib" },
- { name = "rjsmin" },
- { name = "rollbar" },
- { name = "simplejson" },
- { name = "sqlparse" },
- { name = "tablib" },
- { name = "thefuzz" },
- { name = "tqdm" },
- { name = "twisted", extra = ["http2", "tls"] },
- { name = "unidecode" },
- { name = "urllib3" },
- { name = "uvicorn", extra = ["standard"] },
- { name = "uvicorn-worker" },
- { name = "watchdog" },
- { name = "weasyprint" },
- { name = "wosclient" },
- { name = "xhtml2pdf" },
- { name = "xlrd" },
+ { name = "arrow", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "babel", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "beautifulsoup4", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "bibtexparser", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "bpp-mcp", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "celery-singleton", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "channels", extra = ["daphne"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "channels-redis", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "chardet", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "crispy-bootstrap3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "crispy-bootstrap5", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "crispy-forms-foundation", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "crossrefapi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cssmin", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "dbfread", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "dj-pagination", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-admin-sortable2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-admin-tools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-altcha", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-autocomplete-light", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-autoslug", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-axes", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-braces", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-cacheops", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-celery-email", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-celery-results", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-classy-tags", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-columns", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-compressor", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-constance", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-cookie-law", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-countdown", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dbtemplates-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-denorm-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dirtyfields", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dsl", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dynamic-admin-columns", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-easy-audit", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-environ", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-favicon-plus-reloaded", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-filter", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-first-run-wizard", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-flexible-reports", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-formdefaults", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-formtools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-fsm-2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-grappelli", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-group-by", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-import-export", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-liveops", extra = ["celery"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-loginas", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-messages-extends", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-minify-html", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-model-utils", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-mptt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-multiseek", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-oauth-toolkit", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-password-policies-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-pbn-client", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-pg-baseline", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-polish-inflection", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-prometheus", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-querysetsequence", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-redis-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-reversion", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-reversion-compare", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-sendfile2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-session-security", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-site-blog", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-soft-delete", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-static-sitemaps", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-tables2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-tabular-permissions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-taggit", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-templated-email", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-tinymce", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-weasyprint", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-webmaster-verification", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "djangoql-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "djangorestframework", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "dspace-rest-client", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "gunicorn", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "isbnlib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "langdetect", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "markdown", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "moai-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mozilla-django-oidc", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "nh3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "numpy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "openpyxl", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ortools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pandas", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pathspec", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pbn-client", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pillow", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "psycopg2-binary", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pygad", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pymed-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pypandoc", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-docx", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "questionary", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests-oauthlib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rjsmin", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rollbar", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "simplejson", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tablib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "thefuzz", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "twisted", extra = ["http2", "tls"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "unidecode", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uvicorn", extra = ["standard"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uvicorn-worker", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "watchdog", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "weasyprint", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "wosclient", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "xhtml2pdf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "xlrd", marker = "platform_python_implementation != 'PyPy'" },
]
[package.optional-dependencies]
baseline-rebuild = [
- { name = "testcontainers" },
+ { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" },
]
ldap = [
- { name = "django-auth-ldap" },
+ { name = "django-auth-ldap", marker = "platform_python_implementation != 'PyPy'" },
]
office365 = [
- { name = "django-microsoft-auth" },
+ { name = "django-microsoft-auth", marker = "platform_python_implementation != 'PyPy'" },
]
[package.dev-dependencies]
dev = [
- { name = "bumpver" },
- { name = "django-debug-toolbar" },
- { name = "django-dev-helpers" },
- { name = "django-dynamic-fixture" },
- { name = "django-run-site" },
- { name = "django-webtest" },
- { name = "djlint" },
- { name = "ipdb" },
- { name = "ipython" },
- { name = "model-bakery" },
- { name = "nest-asyncio" },
- { name = "playwright" },
- { name = "pre-commit" },
- { name = "psutil" },
- { name = "pyparsing" },
- { name = "pytest" },
- { name = "pytest-cov" },
- { name = "pytest-django" },
- { name = "pytest-env" },
- { name = "pytest-httpserver" },
- { name = "pytest-mock" },
- { name = "pytest-playwright" },
- { name = "pytest-recording" },
- { name = "pytest-repeat" },
- { name = "pytest-rerunfailures" },
- { name = "pytest-split" },
- { name = "pytest-testcontainers" },
- { name = "pytest-testcontainers-django" },
- { name = "pytest-timeout" },
- { name = "pytest-tqdm" },
- { name = "pytest-xdist" },
- { name = "ruff" },
- { name = "testcontainers" },
- { name = "towncrier" },
- { name = "twine" },
- { name = "vcrpy" },
- { name = "vulture" },
- { name = "webtest" },
- { name = "werkzeug" },
+ { name = "bumpver", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-debug-toolbar", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dev-helpers", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dynamic-fixture", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-run-site", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-webtest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "djlint", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ipdb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ipython", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "model-bakery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "nest-asyncio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "playwright", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pre-commit", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "psutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyparsing", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-cov", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-env", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-httpserver", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-mock", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-playwright", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-recording", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-repeat", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-rerunfailures", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-split", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-testcontainers", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-testcontainers-django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-timeout", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-tqdm", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-xdist", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ruff", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "towncrier", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "twine", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "vcrpy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "vulture", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "webtest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "werkzeug", marker = "platform_python_implementation != 'PyPy'" },
]
[package.metadata]
@@ -556,6 +566,7 @@ requires-dist = [
{ name = "babel", specifier = ">=2.17" },
{ name = "beautifulsoup4", specifier = ">=4.15.0" },
{ name = "bibtexparser", specifier = ">=2.0.0b7" },
+ { name = "bpp-mcp", specifier = ">=0.4,<0.5" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "celery-singleton", specifier = ">=0.3.1" },
{ name = "channels", extras = ["daphne"], specifier = ">=4.3.2,<5" },
@@ -727,6 +738,20 @@ dev = [
{ name = "werkzeug", specifier = ">=3.1.6" },
]
+[[package]]
+name = "bpp-mcp"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mcp", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "starlette", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/82/eb56865df2ab216d71fd7ea932ae01083470f925414bf60c91f53dd50c77/bpp_mcp-0.4.0.tar.gz", hash = "sha256:fce4186bd736169789cd3fdd3f136cc5d6f1b8b88ec5acebdc0fc599a97a1281", size = 114594, upload-time = "2026-09-06T04:53:38.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/47/a67dfd205b77b4a127e32e4ee578ce611528bc0bbc00f19bd039c52a12dc/bpp_mcp-0.4.0-py3-none-any.whl", hash = "sha256:013daf032dc4e5480777c62c6aed4a7b81c2e4d58e4d2e6f3147c27de600ca72", size = 94069, upload-time = "2026-09-06T04:53:36.956Z" },
+]
+
[[package]]
name = "brotli"
version = "1.2.0"
@@ -780,7 +805,7 @@ name = "brotlicffi"
version = "1.1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi" },
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/9d/70caa61192f570fcf0352766331b735afa931b4c6bc9a348a0925cc13288/brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13", size = 465192, upload-time = "2023-09-14T14:22:40.707Z" }
wheels = [
@@ -797,10 +822,10 @@ name = "bumpver"
version = "2026.1132"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "colorama" },
- { name = "lexid" },
- { name = "toml" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "colorama", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "lexid", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "toml", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/22/c0c9bf9e7ad6877e4c7c3dedd464e390d9fadb8927c61d6d77547eb17539/bumpver-2026.1132.tar.gz", hash = "sha256:80b223c23fca9bc9dd569b7a44680949d34bee23a738860d9a9b36f1abe3b0e0", size = 116784, upload-time = "2026-05-22T18:40:32.333Z" }
wheels = [
@@ -812,15 +837,15 @@ name = "celery"
version = "5.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "billiard" },
- { name = "click" },
- { name = "click-didyoumean" },
- { name = "click-plugins" },
- { name = "click-repl" },
- { name = "kombu" },
- { name = "python-dateutil" },
- { name = "tzlocal" },
- { name = "vine" },
+ { name = "billiard", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "click-didyoumean", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "click-plugins", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "click-repl", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "kombu", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzlocal", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "vine", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" }
wheels = [
@@ -832,8 +857,8 @@ name = "celery-singleton"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "celery" },
- { name = "redis" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "redis", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/f0/ef4c769215086812a9e968bf9309fbd90bfe663d5175742e9386e1e1e052/celery-singleton-0.3.1.tar.gz", hash = "sha256:260ce4978e631f8682ea0ccb03d7f3b87d42bc20e04e9bd46ddb78a2f8035d1e", size = 4659, upload-time = "2021-01-14T16:23:40.801Z" }
wheels = [
@@ -854,7 +879,7 @@ name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+ { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
@@ -933,8 +958,8 @@ name = "channels"
version = "4.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "django" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" }
wheels = [
@@ -943,7 +968,7 @@ wheels = [
[package.optional-dependencies]
daphne = [
- { name = "daphne" },
+ { name = "daphne", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -951,10 +976,10 @@ name = "channels-redis"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "channels" },
- { name = "msgpack" },
- { name = "redis" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "channels", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "msgpack", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "redis", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" }
wheels = [
@@ -1076,7 +1101,7 @@ name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
@@ -1088,7 +1113,7 @@ name = "click-didyoumean"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
wheels = [
@@ -1100,7 +1125,7 @@ name = "click-plugins"
version = "1.1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
wheels = [
@@ -1112,8 +1137,8 @@ name = "click-repl"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "prompt-toolkit" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
wheels = [
@@ -1248,7 +1273,7 @@ wheels = [
[package.optional-dependencies]
toml = [
- { name = "tomli", marker = "python_full_version <= '3.11'" },
+ { name = "tomli", marker = "python_full_version <= '3.11' and platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -1256,8 +1281,8 @@ name = "crispy-bootstrap3"
version = "2024.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-crispy-forms" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/80/ca6242629ea7405b4d6deb206f94dc3e2bff355ba139e94827f3be944eff/crispy-bootstrap3-2024.1.tar.gz", hash = "sha256:343c696ae1a854ac0ccad25e9e7d782400783034220a11aa179d1d799acf6161", size = 29205, upload-time = "2024-01-13T09:04:54.782Z" }
wheels = [
@@ -1269,8 +1294,8 @@ name = "crispy-bootstrap5"
version = "2026.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-crispy-forms" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/e2/932204e16fd4b15522a481cb66c0529a24ea68678198b8ccd2f77be9082d/crispy_bootstrap5-2026.9.tar.gz", hash = "sha256:e574e6e910e97f5a32f5a18cc632d8bfd71cc4cb0f8fdd531e15f8618af2488b", size = 23530, upload-time = "2026-09-02T20:40:23.32Z" }
wheels = [
@@ -1282,8 +1307,8 @@ name = "crispy-forms-foundation"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-crispy-forms" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-crispy-forms", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/a3/baf46ed6d9a160444f50e579892bdfab97375142eb1dbec3ffcd49305079/crispy_forms_foundation-1.1.0.tar.gz", hash = "sha256:5f4544787277599e281964c0a5ffdac1d606613bd7bf4ce50a60554dd9eb3100", size = 21214, upload-time = "2025-07-16T01:09:39.086Z" }
@@ -1292,9 +1317,9 @@ name = "crossrefapi"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ipython" },
- { name = "requests" },
- { name = "urllib3" },
+ { name = "ipython", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/c7/bd020af76301bfede41869b3c18f3efb6fac7dd064371d2a9956bc3306c4/crossrefapi-1.7.0.tar.gz", hash = "sha256:598f27a3cc1bd8d29770de284b0b1315c820de1d70894084945d5265e6fe2dae", size = 13444, upload-time = "2025-07-03T20:39:49.223Z" }
wheels = [
@@ -1306,7 +1331,7 @@ name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi" },
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
@@ -1362,9 +1387,9 @@ name = "cssbeautifier"
version = "1.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "editorconfig" },
- { name = "jsbeautifier" },
- { name = "six" },
+ { name = "editorconfig", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jsbeautifier", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" }
wheels = [
@@ -1382,8 +1407,8 @@ name = "cssselect2"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "tinycss2" },
- { name = "webencodings" },
+ { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" }
wheels = [
@@ -1395,9 +1420,9 @@ name = "daphne"
version = "4.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "autobahn" },
- { name = "twisted", extra = ["tls"] },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "autobahn", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "twisted", extra = ["tls"], marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/d3/65ff32c01cc64d44441b038dbb7cfb0c6a5507a1c937b3d41bd99af7bdc4/daphne-4.2.2.tar.gz", hash = "sha256:6c3527d4ce32630ae054dfb0ef5578e9a35d2f39f0ebcd02ef4f9129a121ce8d", size = 47601, upload-time = "2026-06-03T10:53:13.31Z" }
wheels = [
@@ -1454,9 +1479,9 @@ name = "django"
version = "5.2.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "sqlparse" },
- { name = "tzdata", marker = "sys_platform == 'win32'" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzdata", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" }
wheels = [
@@ -1468,7 +1493,7 @@ name = "django-admin-sortable2"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e2/09/cc2d19412e47aaf627f54f9b5b544de73553bd71e25d417dd62e91b36edd/django_admin_sortable2-2.3.1.tar.gz", hash = "sha256:e397d08606f9e0da73e52661b841021ecab21be6f74783408e625ccddfe975af", size = 67497, upload-time = "2026-01-22T14:20:29.049Z" }
wheels = [
@@ -1489,8 +1514,8 @@ name = "django-altcha"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "altcha" },
- { name = "django" },
+ { name = "altcha", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/7d/c77f7a0b6aac8d08d27c0e7de98217c37413bdc641c496c228076875a645/django_altcha-1.0.0.tar.gz", hash = "sha256:06a4f437b086a0c296b53e02dbd7f783ce07972150b8238dbae6da8cfb284efa", size = 61790, upload-time = "2026-04-21T10:40:57.301Z" }
wheels = [
@@ -1502,7 +1527,7 @@ name = "django-appconf"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/a9/dcf95ff3fa0620b6818fc02276fbbb8926e7f286039b6d015e56e8b7af39/django-appconf-1.1.0.tar.gz", hash = "sha256:9fcead372f82a0f21ee189434e7ae9c007cbb29af1118c18251720f3d06243e4", size = 15986, upload-time = "2025-02-13T16:09:40.258Z" }
wheels = [
@@ -1514,8 +1539,8 @@ name = "django-auth-ldap"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "python-ldap" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-ldap", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/6d/d3ceb4b49e7153811a4b2d92bbe198a5ef2e2820469add3d6dc129ef2fab/django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19", size = 55272, upload-time = "2025-12-26T15:00:14.272Z" }
wheels = [
@@ -1527,7 +1552,7 @@ name = "django-autocomplete-light"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/ca/1288c2c7597484340c6ba5e3911c84b552511310633954d7aa496610e45a/django_autocomplete_light-5.0.0.tar.gz", hash = "sha256:115ee3541ee4a7ddd1b92396ea75d0f38d0b91899b0dc17259310975c728b32e", size = 117640, upload-time = "2026-06-18T01:43:28.909Z" }
wheels = [
@@ -1548,8 +1573,8 @@ name = "django-axes"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "django" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/00c701a22d46288e3eb1340a1c141f5b4002572ce836a3e8d069f1e1de8a/django_axes-8.3.1.tar.gz", hash = "sha256:d57e923c0ba33cf45275253dd1313c3a3ff04bec686b38d677beb2b3d43c7bcd", size = 254715, upload-time = "2026-02-11T20:19:52.172Z" }
wheels = [
@@ -1561,7 +1586,7 @@ name = "django-braces"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/c0/eb3e27e817eb4ba39d9c66fbd53139cb4db7073f5e6e71c28ce4d8b706da/django_braces-1.17.0.tar.gz", hash = "sha256:3a6fe630d3d91fef0711bfe1088d14af00c245559f79f6298040ed2b2f757337", size = 54227, upload-time = "2025-03-18T02:03:02.354Z" }
wheels = [
@@ -1573,9 +1598,9 @@ name = "django-cacheops"
version = "7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "funcy" },
- { name = "redis" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "funcy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "redis", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/92/9df8a60ee959c214705ec3d3ccf80820097672a0260b149cf2751e818086/django_cacheops-7.2.tar.gz", hash = "sha256:cbc11cc0321295a3644e27bcb26940f08cb9c2e71d3ee506cbcff0bdda1a9f33", size = 72751, upload-time = "2025-04-20T04:36:19.068Z" }
wheels = [
@@ -1587,9 +1612,9 @@ name = "django-celery-email"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "celery" },
- { name = "django" },
- { name = "django-appconf" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/78/95c9959978c1b8abd587735cf084065b0ac8196580f6f30b2c9e9636401a/django-celery-email-3.0.0.tar.gz", hash = "sha256:5546cbba80952cc3b8a0ffa4206ce90a4a996a7ffd1c385a2bdb65903ca18ece", size = 12644, upload-time = "2019-12-11T15:32:11.6Z" }
wheels = [
@@ -1601,8 +1626,8 @@ name = "django-celery-results"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "celery" },
- { name = "django" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" }
wheels = [
@@ -1614,10 +1639,10 @@ name = "django-channels-broadcast"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "channels" },
- { name = "django" },
- { name = "nest-asyncio" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "channels", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "nest-asyncio", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/e0/facb33741759eb54efb5fcff1ca83ead25aece1fa2725b6c0fd6abb6835a/django_channels_broadcast-0.3.0.tar.gz", hash = "sha256:34492908b917bfb01e453def1d07de15ffd7e96f9141b4e4a73fd6ab7d7b63d7", size = 73467, upload-time = "2026-08-07T09:05:59.412Z" }
wheels = [
@@ -1629,7 +1654,7 @@ name = "django-classy-tags"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/7f/18da9d8fcc469fc7aa74abc526aacaa3cc7180ad2208f80d43e8b670aed6/django-classy-tags-4.1.0.tar.gz", hash = "sha256:c8d9d1aa2fa6e71c4d866df4dd11d23a69b8d25bbb750b2490a17b161774ee59", size = 24692, upload-time = "2023-07-29T09:06:18.646Z" }
wheels = [
@@ -1641,7 +1666,7 @@ name = "django-columns"
version = "0.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/5a/5178d6f6d070e91877b163a21f9990b57d62c9f146a41c8e3fcf0493e5b5/django-columns-0.1.0.tar.gz", hash = "sha256:cd5ffb9b1e31908dba058344fc0e7def2367f13b6fa4c9a6b801e29fc864fdf9", size = 5621, upload-time = "2014-05-12T20:50:19.316Z" }
@@ -1650,10 +1675,10 @@ name = "django-compressor"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-appconf" },
- { name = "rcssmin" },
- { name = "rjsmin" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rcssmin", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rjsmin", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/e4/c6d87b1341d744ceafa85eeceb2adabb1c62b795b8207cbc580fb70df8f4/django_compressor-4.6.0.tar.gz", hash = "sha256:c7478feab98f3368780591f9ee28a433350f5277dd28811f7f710f5bc6dff3c0", size = 99735, upload-time = "2025-11-10T13:12:11.439Z" }
wheels = [
@@ -1674,8 +1699,8 @@ name = "django-cookie-law"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-classy-tags" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-classy-tags", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/9b/ff89a562a6662a3cb58e73cdcc4cd110701eb0d77611487e8250b0d2a77e/django-cookie-law-2.2.0.tar.gz", hash = "sha256:c3c6c10a01b257886077dd170ead11a49448b5bdfd06e81acc5c7c4e2292d4c8", size = 16605, upload-time = "2022-03-11T10:13:38.693Z" }
wheels = [
@@ -1687,7 +1712,7 @@ name = "django-countdown"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/dd/6df46b3a8a160d8198ccc934acc510e5ec5a0367bab4a0a68efc3c80bb81/django_countdown-0.4.0.tar.gz", hash = "sha256:fc9e4d311ce2a08324ed3588d10f7bc423424f4a5581e8f826dee341c73a5f55", size = 63524, upload-time = "2026-08-25T13:57:29.4Z" }
wheels = [
@@ -1699,7 +1724,7 @@ name = "django-crispy-forms"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/b79e3ed7cae871d5d71bf3448d627e1fabab769358bb90808bec056556fe/django_crispy_forms-2.7.tar.gz", hash = "sha256:4c59bed60417375cba26cebb2c67ab350b655934670270b1c89dbcd7e60f1b4c", size = 1097842, upload-time = "2026-07-29T11:12:12.081Z" }
wheels = [
@@ -1711,7 +1736,7 @@ name = "django-dbtemplates-iplweb"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django-appconf" },
+ { name = "django-appconf", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/02/3940dd656449f6e39d903857f41373d96725e18f567d85fd9c0df95f7d62/django_dbtemplates_iplweb-4.4.1.tar.gz", hash = "sha256:e6c88244b33ee4108e8c451c0208bd3a2b7ab47c1191f154e99cc298e6d254b1", size = 85494, upload-time = "2026-08-07T08:28:29.297Z" }
wheels = [
@@ -1723,8 +1748,8 @@ name = "django-debug-toolbar"
version = "7.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "sqlparse" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sqlparse", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/da/df67a8e407ab6141affee4ee5cb2704403b9d5465cc35abbb42f5190a3fa/django_debug_toolbar-7.1.1.tar.gz", hash = "sha256:2fb4093d95e6f87258606c55f4d2aa45d1847b17a415525a9dceac02e937a836", size = 363531, upload-time = "2026-08-14T13:18:47.219Z" }
wheels = [
@@ -1736,10 +1761,10 @@ name = "django-denorm-iplweb"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "celery" },
- { name = "celery-singleton" },
- { name = "django" },
- { name = "tqdm" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "celery-singleton", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/46/4df14edc899092155a9aa56a737ab25a541acbd98675cd4ba96071278d2e/django_denorm_iplweb-1.14.0.tar.gz", hash = "sha256:16f7b06753a74853176b9f167575a00f2663abbc7eca408e91d8488cd2a7c9b5", size = 99770, upload-time = "2026-08-07T09:30:56.733Z" }
wheels = [
@@ -1751,7 +1776,7 @@ name = "django-dev-helpers"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/e1/98d637a5f0de8e2b53ffd30b71894dc57f6edf4ef6f0c977ec735158d9d6/django_dev_helpers-0.2.0.tar.gz", hash = "sha256:98756fdcaac9a41b4eda96507ffb1a64790b82d213a1b42a342139e845911200", size = 119496, upload-time = "2026-08-07T08:33:59.356Z" }
wheels = [
@@ -1763,7 +1788,7 @@ name = "django-dirtyfields"
version = "1.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/0d/0c7d727f7f14f82450b72e5213c6ed0da3d16add2de2e59d46aea6b6eaa2/django_dirtyfields-1.9.9.tar.gz", hash = "sha256:c11c8e03827166d2c91f6634a4d10f5aa5cc7ea430d60e5bd576b3ebf319172f", size = 22209, upload-time = "2026-01-22T13:48:58.777Z" }
wheels = [
@@ -1775,8 +1800,8 @@ name = "django-dsl"
version = "0.1.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "ply" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ply", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/e8/3b7e7963c23dfd00e9d882af9a29c229156afc6bd98d37230411af8155c8/django_dsl-0.1.15.tar.gz", hash = "sha256:e7a6ca40ea48ea566a76c64b972a1b64f1611aebb7397823ab5f6e6cf4971a0b", size = 8567, upload-time = "2026-08-07T08:18:51.261Z" }
wheels = [
@@ -1788,8 +1813,8 @@ name = "django-dynamic-admin-columns"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-admin-sortable2" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-admin-sortable2", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/00/a4e9671c1c75b8f6abf5f7f7fc86fa82a4342b7a2d863c3e12cea31f9756/django_dynamic_admin_columns-0.6.0.tar.gz", hash = "sha256:c9ca73ef40ba5be6936181fed2ecd73300dd028134e65017409aee38165f5538", size = 52503, upload-time = "2026-08-07T08:33:40.235Z" }
wheels = [
@@ -1810,7 +1835,7 @@ name = "django-easy-audit"
version = "1.3.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/3e/ad1b812e580fabe91af7f8c4897b4fe6c4ede696f829a0152e24abd156da/django_easy_audit-1.3.9.tar.gz", hash = "sha256:931b9b6c8411e2b49e980989cbfd4dfd9fd3ea3b350846f9b92686bf7d275f8f", size = 41852, upload-time = "2026-06-30T03:07:30.952Z" }
wheels = [
@@ -1831,7 +1856,7 @@ name = "django-extensions"
version = "4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
wheels = [
@@ -1843,8 +1868,8 @@ name = "django-favicon-plus-reloaded"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "pillow" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pillow", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/35/0d/86d1fb1eebbbadeacf4d420cee9206b737c77609e01c93aab0cec7d344f3/django_favicon_plus_reloaded-1.2.4.tar.gz", hash = "sha256:e8363eb783e18c0ab02b8f1e2705d8648e01933bebda1f89cb7e9b00964e2380", size = 8560, upload-time = "2026-02-11T19:11:05.885Z" }
wheels = [
@@ -1856,7 +1881,7 @@ name = "django-filter"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/563965173d4cbb5fc308087e7b3d11a115b7b67273d093622480b1e31f78/django_filter-26.1.tar.gz", hash = "sha256:66ea04031b068c77c86e1ac26ced7a3f8f13ce797f5795751707e3deefc58054", size = 144299, upload-time = "2026-07-11T09:27:02.767Z" }
wheels = [
@@ -1868,7 +1893,7 @@ name = "django-first-run-wizard"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/a4/9c87cd899e7cc9843f7280d1f52afafdd3e86d135a4d9461d7b59a5aca45/django_first_run_wizard-0.2.1.tar.gz", hash = "sha256:84eec6363ca6bd35f9df129dc1702ea9a1915f8ef2f1304c31e08e678d35d321", size = 27833, upload-time = "2026-08-07T08:17:01.189Z" }
wheels = [
@@ -1880,14 +1905,14 @@ name = "django-flexible-reports"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "bleach" },
- { name = "django" },
- { name = "django-dsl" },
- { name = "django-tables2" },
- { name = "djangoql-iplweb" },
- { name = "lxml" },
- { name = "pypandoc" },
- { name = "tablib" },
+ { name = "bleach", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-dsl", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-tables2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "djangoql-iplweb", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "lxml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pypandoc", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tablib", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/50/704b363c4f2040f0ffd557f8e6e1e969a291aef073781a45bf20a73c6e5a/django_flexible_reports-0.5.0.tar.gz", hash = "sha256:181e9042d95de7282d1d94b09200a8ea2ae37a0eb0032d10693a2ab8ec07c4ec", size = 35580, upload-time = "2026-08-18T22:21:39.736Z" }
wheels = [
@@ -1899,7 +1924,7 @@ name = "django-formdefaults"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/af/a6b6c4419a5f2d2710b02f519ad2f7292450ec40d09063db54158e49fd3e/django_formdefaults-0.6.3.tar.gz", hash = "sha256:9d3b4386c3e6aead1c3801ca4ffa518c9d6f143c17c5e306be745c6f2c876b8c", size = 28578, upload-time = "2026-08-07T08:32:32.722Z" }
wheels = [
@@ -1911,7 +1936,7 @@ name = "django-formtools"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/fe/48cf5d38070577b83288f2bfd16a85aa864213d27d7dc7e8ec0feaa0a8ce/django_formtools-2.7.tar.gz", hash = "sha256:52b30c08571108023937e4b5d96a5cc35282ff0a2e51630aeea00e27d723d3ac", size = 92473, upload-time = "2026-07-09T16:06:34.341Z" }
wheels = [
@@ -1923,9 +1948,9 @@ name = "django-fsm-2"
version = "4.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-stubs-ext" },
- { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-stubs-ext", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/eb/594712952364f0b1f009b9c414940a09031d07fe95bb888e92f50aa9a550/django_fsm_2-4.2.4.tar.gz", hash = "sha256:0e395afd03ff504afd476e8568ca213aafc3324bd4e5427e5b3287f7b1e7d979", size = 21544, upload-time = "2026-03-16T20:33:33.476Z" }
wheels = [
@@ -1955,9 +1980,9 @@ name = "django-import-export"
version = "4.4.2.dev0+iplweb"
source = { git = "https://github.com/mpasternak/django-import-export.git?rev=d6ee0d39194fee31437affbdc6fee5ce549b4b8f#d6ee0d39194fee31437affbdc6fee5ce549b4b8f" }
dependencies = [
- { name = "diff-match-patch" },
- { name = "django" },
- { name = "tablib" },
+ { name = "diff-match-patch", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tablib", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -1965,7 +1990,7 @@ name = "django-js-asset"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/91/c63f136f553ec24fc46ccf20ac7292a8df04815b383975b6f3f7f0060217/django_js_asset-3.1.2.tar.gz", hash = "sha256:1fc7584199ed1941ed7c8e7b87ca5524bb0f2ba941561d2a104e88ee9f07bedd", size = 9471, upload-time = "2025-03-04T15:22:49.789Z" }
wheels = [
@@ -1977,9 +2002,9 @@ name = "django-liveops"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "channels" },
- { name = "django" },
- { name = "django-channels-broadcast" },
+ { name = "channels", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-channels-broadcast", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/ee/ee27891ea2e81d44b3d100b3b2b03b6fee2775bfe895bd98fd48d75fb7d0/django_liveops-0.4.1.tar.gz", hash = "sha256:2c6903881f26f287ce642503726172a650f846ab3b031879ed28655fdd6a2aa9", size = 279310, upload-time = "2026-08-07T08:29:30.625Z" }
wheels = [
@@ -1988,7 +2013,7 @@ wheels = [
[package.optional-dependencies]
celery = [
- { name = "celery" },
+ { name = "celery", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -2014,10 +2039,10 @@ name = "django-microsoft-auth"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "pyjwt", extra = ["crypto"] },
- { name = "requests" },
- { name = "requests-oauthlib" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyjwt", extra = ["crypto"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests-oauthlib", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/de/549f316f6a463a7f93e1f72cd8f1b186b821ced4aa4b89bf6421c243529b/django_microsoft_auth-3.0.1.tar.gz", hash = "sha256:e149b35e8061e12f060b4fc31a6bea1e0cb32047801a076aeb972d68f1f97655", size = 52289, upload-time = "2022-10-28T16:59:07.573Z" }
wheels = [
@@ -2029,9 +2054,9 @@ name = "django-minify-html"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asgiref" },
- { name = "django" },
- { name = "minify-html" },
+ { name = "asgiref", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "minify-html", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/aa/28cc8441ebe49d96b4c6727eb834ec949ac23865f56297b9cef54032ba75/django_minify_html-1.14.0.tar.gz", hash = "sha256:43c1c31c353ed40acfed4ddc15663bc6892c7745b09172e767dc944577e6241a", size = 10540, upload-time = "2025-10-27T14:44:31.259Z" }
wheels = [
@@ -2043,7 +2068,7 @@ name = "django-model-utils"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559, upload-time = "2024-09-04T11:35:22.858Z" }
wheels = [
@@ -2055,7 +2080,7 @@ name = "django-mptt"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django-js-asset" },
+ { name = "django-js-asset", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/25/04bb8e4384f3484dabbd0e2a84946f1c972b95b07512509a17aaa361be0f/django_mptt-0.18.0.tar.gz", hash = "sha256:cf5661357ff22bc64e20d3341c26e538aa54583aea0763cfe6aaec0ab8e3a8ee", size = 71407, upload-time = "2025-08-26T09:27:01.05Z" }
wheels = [
@@ -2067,9 +2092,9 @@ name = "django-multiseek"
version = "0.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-autocomplete-light" },
- { name = "python-dateutil" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-autocomplete-light", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/89/f7a47549eac60dc719b50a541a8f27ff2170a170cd64883903c3c8c5ed99/django_multiseek-0.10.3.tar.gz", hash = "sha256:9aaf2729c06d3b2053305aca382a04e3962a6c05e703e98550e6026a477569a4", size = 39559, upload-time = "2026-08-07T08:38:17.493Z" }
wheels = [
@@ -2081,11 +2106,11 @@ name = "django-oauth-toolkit"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "jwcrypto" },
- { name = "oauthlib" },
- { name = "requests" },
- { name = "urllib3" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jwcrypto", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "oauthlib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/42/b3c6a5d7e204011a702cc02ababb756d10538645dfd188ea441e4c65a082/django_oauth_toolkit-3.4.1.tar.gz", hash = "sha256:c4de73c0765eed55a99ea0fa8433b20664dcc1f43930149cff569abed166c5c2", size = 256139, upload-time = "2026-08-21T03:45:36.538Z" }
wheels = [
@@ -2097,7 +2122,7 @@ name = "django-password-policies-iplweb"
version = "0.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/84/b8f652879a3913ca1555f023e8b3912c862e48fd7a00721924778037d3f6/django_password_policies_iplweb-0.9.4.tar.gz", hash = "sha256:e2c02f7f288db169af93574b30117eaccc61e9f7ddab5989254f30230de31c47", size = 98777, upload-time = "2026-08-07T12:50:52.813Z" }
wheels = [
@@ -2109,8 +2134,8 @@ name = "django-pbn-client"
version = "0.2.2"
source = { git = "https://github.com/iplweb/django-pbn-client.git?rev=bb952e98967a96f96cd3773e3dcfcaf4555c8690#bb952e98967a96f96cd3773e3dcfcaf4555c8690" }
dependencies = [
- { name = "django" },
- { name = "pbn-client" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pbn-client", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -2118,8 +2143,8 @@ name = "django-pg-baseline"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "testcontainers" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/4c/ed474b704e03446fece5260d49c52f9978a0afa581b850f2521ff1e5c296/django_pg_baseline-0.3.1.tar.gz", hash = "sha256:3b58efe4bbba08de3ea0d84bcf931ffe900d3f53fda7cf77d8228430ff1c46e4", size = 24836, upload-time = "2026-08-07T08:26:30.59Z" }
wheels = [
@@ -2131,8 +2156,8 @@ name = "django-polish-inflection"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "polish-inflection" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "polish-inflection", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/2a/18d426e30c3ab185046dd33d6c3a37f7e6203b1cf1dc89c4046088795d6a/django_polish_inflection-0.1.1.tar.gz", hash = "sha256:42fa3d2d09a63e68bce1464d6cb5e13a5e257a752e45ad52e6e18faab6d5f822", size = 191869, upload-time = "2026-08-07T08:16:44.657Z" }
wheels = [
@@ -2144,8 +2169,8 @@ name = "django-prometheus"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "prometheus-client" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "prometheus-client", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/c7/dc39c4c19f7b35e827a486d08376de1fad31c50decb26c56e32668314f13/django_prometheus-2.5.0.tar.gz", hash = "sha256:4837b3c3734d8350880839ab8235aafd250b668c348e159d4aecc3cbefeee53e", size = 26465, upload-time = "2026-05-26T19:04:00.77Z" }
wheels = [
@@ -2157,7 +2182,7 @@ name = "django-querysetsequence"
version = "0.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/f6/22906bc0d74b17bd8203e7038b1159f8b228426a3ad24b0912388dcf980e/django_querysetsequence-0.18.tar.gz", hash = "sha256:008435bd44cf291de21238d26efda4a14f4dcb5f01adb19da8339f634f3a9338", size = 30432, upload-time = "2025-05-13T23:27:38.476Z" }
wheels = [
@@ -2169,8 +2194,8 @@ name = "django-redis-iplweb"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "redis" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "redis", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/93/25dfbe7067f6d09b12f00784fdc193ae47bc102d45d3d42571f7d2af6659/django_redis_iplweb-6.0.2.tar.gz", hash = "sha256:c4771d6297e3083cd275daed750472a2f855a6b29cb592dfb69c0ae8bb2ce8cf", size = 60919, upload-time = "2026-08-07T08:12:58.968Z" }
wheels = [
@@ -2182,7 +2207,7 @@ name = "django-render-block"
version = "0.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/2f/588cf58f7b37ab3b7994727d590381aed747173ef5670457c798518621dc/django_render_block-0.11.tar.gz", hash = "sha256:17886dd3bff222fe8a2f019509c6c53e0e4a38fe684e80d48e8697a879e1301b", size = 12432, upload-time = "2025-05-13T11:48:51.333Z" }
wheels = [
@@ -2194,7 +2219,7 @@ name = "django-reversion"
version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/93/0240c12e07f882e11bc4028b6d5570ff6305ac6237843853b9238895be3a/django_reversion-6.3.0.tar.gz", hash = "sha256:0aa54eb334694f522fd234743978c614d45aa785edc70a6177267cdaaf06f967", size = 79028, upload-time = "2026-06-12T19:55:46.603Z" }
wheels = [
@@ -2206,10 +2231,10 @@ name = "django-reversion-compare"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "diff-match-patch" },
- { name = "django" },
- { name = "django-reversion" },
- { name = "rich" },
+ { name = "diff-match-patch", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-reversion", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rich", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/28/54ba992a2d1e716fe8950b90791688a5890de4f6f68b0e78a86c8abfaabe/django_reversion_compare-0.19.2.tar.gz", hash = "sha256:b27f0616759eb529693956dca776009d9608900c90a97b09aefeede5d6703bee", size = 194226, upload-time = "2026-02-06T17:18:40.352Z" }
wheels = [
@@ -2221,8 +2246,8 @@ name = "django-run-site"
version = "0.20.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "docker" },
- { name = "testcontainers", extra = ["redis"] },
+ { name = "docker", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "testcontainers", extra = ["redis"], marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/a1/005f263d837b414d26c560312fe37677b4c933ded17d9a8b4daf63c3d9fb/django_run_site-0.20.2.tar.gz", hash = "sha256:32dd103efa8fc58184bbc9002e4b63ed8dbe547bd86030a2a4b4cf768ae943cf", size = 245654, upload-time = "2026-08-07T08:18:58.883Z" }
wheels = [
@@ -2234,7 +2259,7 @@ name = "django-sendfile2"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/18b884f5316329c914b6851f21fd1d88752627ab8da7dd0f0970cf2b58db/django_sendfile2-0.7.2.tar.gz", hash = "sha256:d0939d52217d314b110d0b7b15ace5063d96aeb4980b7e0f94c020c2e25646f8", size = 27488, upload-time = "2025-04-06T14:09:42.806Z" }
wheels = [
@@ -2246,7 +2271,7 @@ name = "django-session-security"
version = "2.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/7a/69a922ce628b7d91acb48101170741662f641cf7810d4a6273e3e37c702b/django_session_security-2.6.8.tar.gz", hash = "sha256:f8949d021eee5e237385413a1173bb90c3bd80ce2d56e412a5644608de815109", size = 104281, upload-time = "2026-04-24T14:41:42.034Z" }
wheels = [
@@ -2258,8 +2283,8 @@ name = "django-site-blog"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "django-model-utils" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django-model-utils", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/86/469a6b1e313f50b5b890922b9a4c47adb2da796a5446813c15ec4eb7d52c/django_site_blog-0.2.2.tar.gz", hash = "sha256:c3aa84915ec40962eb23b56c4e608c680fcabd8ef4906b26e41667566fd6d062", size = 13752, upload-time = "2026-08-07T08:17:59.878Z" }
wheels = [
@@ -2271,7 +2296,7 @@ name = "django-soft-delete"
version = "1.0.23"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/98/c7c52a85b070b1703774df817b6460a7714655302a2d503f6447544f1a29/django_soft_delete-1.0.23.tar.gz", hash = "sha256:814659f0d19d4f2afc58b31ff73f88f0af66715ccef3b4fcd8f6b3a011d59b2a", size = 22458, upload-time = "2026-02-21T17:48:41.345Z" }
wheels = [
@@ -2283,9 +2308,9 @@ name = "django-static-sitemaps"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "setuptools" },
- { name = "six" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/86/7d902fe13a60abc8601c6450a940973e2e4f9d4a409b34d6adb4f5ceaa66/django-static-sitemaps-5.0.0.tar.gz", hash = "sha256:4580bda5bbaa4dbf40bc8bbde5936e1675eac1c7b76f65286705befeeefbaa85", size = 8660, upload-time = "2024-02-18T09:04:01.556Z" }
@@ -2294,8 +2319,8 @@ name = "django-stubs-ext"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "typing-extensions" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/e6/5dcdaa785ec3eed5fc196c7e68fb7ad9d9fe6d5acccea4690e65f2546417/django_stubs_ext-6.0.3.tar.gz", hash = "sha256:3307d42132bc295d5744de6276bc5fdf6896efc70f891e21c0ae8bdf529d2762", size = 6663, upload-time = "2026-04-18T15:10:53.667Z" }
wheels = [
@@ -2307,7 +2332,7 @@ name = "django-tables2"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c4/ac3eee8fb075e0b945452780f876cdad72caff38dba84df7df42c9cc8763/django_tables2-3.0.1.tar.gz", hash = "sha256:5a8b51b6c18ce5ec4edd4d0f23c55a8b15590854168b2e5c734118ee369dcbfd", size = 130254, upload-time = "2026-08-30T10:59:02.682Z" }
wheels = [
@@ -2325,7 +2350,7 @@ name = "django-taggit"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/a6/f1beaf8f552fe90c153cc039316ebab942c23dfbc88588dde081fefca816/django_taggit-6.1.0.tar.gz", hash = "sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3", size = 38151, upload-time = "2024-09-29T08:07:39.477Z" }
wheels = [
@@ -2337,7 +2362,7 @@ name = "django-templated-email"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django-render-block" },
+ { name = "django-render-block", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/d3/689aee2477dcef5b1fca187897373882fb5aae9f58d1d0a207b8ee7acb94/django_templated_email-3.1.1.tar.gz", hash = "sha256:9c48863d96b89096fa84c6b48523920e90d55397d2895c752fdde5c228aee70f", size = 17026, upload-time = "2025-08-21T17:40:35.162Z" }
wheels = [
@@ -2349,7 +2374,7 @@ name = "django-tinymce"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/71/e9556c8bc1d99c0cb4081af0abc6440d7cf53c87b6c7da7b117f2de24881/django_tinymce-5.0.0.tar.gz", hash = "sha256:6257669ed596accf5fa967ff3061276b2c5352baac1bbc658fcd8252b12ca38a", size = 1161002, upload-time = "2025-10-21T19:25:25.831Z" }
wheels = [
@@ -2361,8 +2386,8 @@ name = "django-weasyprint"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
- { name = "weasyprint" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "weasyprint", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/5a/e4109aac85db984a020f4a3cff9836af13ee8bf5bdcf6e61818ea9422a48/django_weasyprint-2.5.0.tar.gz", hash = "sha256:362956b650d2a053967471c0ce15857f08e30253a3eeb746aef7c459d3ee9cf9", size = 14436, upload-time = "2026-04-10T23:03:34.315Z" }
wheels = [
@@ -2374,7 +2399,7 @@ name = "django-webmaster-verification"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/3d/f5ddb42ceeedda3098fa691920d77e44b33813834bc44fce40f0ce3fcdeb/django-webmaster-verification-0.4.3.tar.gz", hash = "sha256:1cc6958ee4142da63c1b3e03e537cce6051c740cb1adf6afb68ddb1cc85f4e8f", size = 7125, upload-time = "2022-02-09T09:01:54.524Z" }
wheels = [
@@ -2386,7 +2411,7 @@ name = "django-webtest"
version = "1.9.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "webtest" },
+ { name = "webtest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/4c/2c6f4dc32c785b4ed760878f972e0934dba3a4d4cedbfdf4fbce24225808/django_webtest-1.9.14.tar.gz", hash = "sha256:edbdc62f74b84e53ad3b49821e839669c812fafabb54ede713b45209bf732aeb", size = 29289, upload-time = "2025-10-06T21:08:10.761Z" }
wheels = [
@@ -2398,7 +2423,7 @@ name = "djangoql-iplweb"
version = "0.31.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ply" },
+ { name = "ply", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/d92ebaaf80dd2569911ff6a4fd9ed5ceb687b9ede56af2f233653b7fb05d/djangoql_iplweb-0.31.2.tar.gz", hash = "sha256:553c63856fd5789624abfd4aa8f0607a29992c4f74069c63c585b15de2b17916", size = 318199, upload-time = "2026-08-07T08:33:40.748Z" }
wheels = [
@@ -2410,7 +2435,7 @@ name = "djangorestframework"
version = "3.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/bc/de04e3d4dc65e8b926700956ee70d4f084f2005603d21122d4d0683006fd/djangorestframework-3.18.0.tar.gz", hash = "sha256:2323a5111837e0b784dcb8323abc78ecc54fa2a5af7aff2677cf50cdd849477f", size = 913667, upload-time = "2026-08-07T14:53:33.571Z" }
wheels = [
@@ -2422,14 +2447,14 @@ name = "djlint"
version = "1.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "cssbeautifier" },
- { name = "jsbeautifier" },
- { name = "json5" },
- { name = "pathspec" },
- { name = "pyyaml" },
- { name = "regex" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cssbeautifier", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jsbeautifier", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "json5", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pathspec", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "regex", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/8d/a93d4a0211c475276870333ac8353549e46f16ad1635794d6125be83b1c1/djlint-1.45.0.tar.gz", hash = "sha256:f1e931ffeafc6b807837aa0ea1db0d090ac10df9a8f9eb3f7b6558f802fde4f8", size = 98475, upload-time = "2026-09-03T01:11:27.132Z" }
wheels = [
@@ -2486,9 +2511,9 @@ name = "docker"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pywin32", marker = "sys_platform == 'win32'" },
- { name = "requests" },
- { name = "urllib3" },
+ { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
wheels = [
@@ -2509,10 +2534,10 @@ name = "dspace-rest-client"
version = "0.1.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pysocks" },
- { name = "pysolr" },
- { name = "requests" },
- { name = "smart-open", extra = ["all"] },
+ { name = "pysocks", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pysolr", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "smart-open", extra = ["all"], marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/2b/d23dc1619e223c8992a661654222a9f318ce08020c56b216160bbb12173f/dspace_rest_client-0.1.17.tar.gz", hash = "sha256:6a4e91fdaab415a203a2450e7894c258e64fbbc9db128128c9f164528b9a2a46", size = 32393, upload-time = "2026-03-03T04:25:26.587Z" }
wheels = [
@@ -2616,8 +2641,8 @@ wheels = [
[package.optional-dependencies]
woff = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
- { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
- { name = "zopfli" },
+ { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy'" },
+ { name = "zopfli", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -2648,11 +2673,11 @@ name = "google-api-core"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "google-auth" },
- { name = "googleapis-common-protos" },
- { name = "proto-plus" },
- { name = "protobuf" },
- { name = "requests" },
+ { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "googleapis-common-protos", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "proto-plus", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" }
wheels = [
@@ -2664,8 +2689,8 @@ name = "google-auth"
version = "2.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "pyasn1-modules" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" }
wheels = [
@@ -2677,8 +2702,8 @@ name = "google-cloud-core"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "google-api-core" },
- { name = "google-auth" },
+ { name = "google-api-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" }
wheels = [
@@ -2690,12 +2715,12 @@ name = "google-cloud-storage"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "google-api-core" },
- { name = "google-auth" },
- { name = "google-cloud-core" },
- { name = "google-crc32c" },
- { name = "google-resumable-media" },
- { name = "requests" },
+ { name = "google-api-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-auth", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-cloud-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-crc32c", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-resumable-media", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/09/8953e2993e604c8882fd441b5b2de624a2dfe7e6144c6166d7b477509596/google_cloud_storage-3.11.0.tar.gz", hash = "sha256:498bf37c999028f69a245f586b5e50d89f59df1fafc0e3a93783ac56be2a456b", size = 17335639, upload-time = "2026-06-03T16:14:04.649Z" }
wheels = [
@@ -2737,7 +2762,7 @@ name = "google-resumable-media"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "google-crc32c" },
+ { name = "google-crc32c", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" }
wheels = [
@@ -2749,7 +2774,7 @@ name = "googleapis-common-protos"
version = "1.75.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "protobuf" },
+ { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
wheels = [
@@ -2829,8 +2854,8 @@ name = "h2"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "hpack" },
- { name = "hyperframe" },
+ { name = "hpack", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "hyperframe", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
@@ -2851,14 +2876,40 @@ name = "html5lib"
version = "1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "six" },
- { name = "webencodings" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" },
]
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "h11", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpcore2"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+ { name = "truststore", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
+]
+
[[package]]
name = "httptools"
version = "0.8.0"
@@ -2902,6 +2953,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "certifi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "httpcore", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+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]]
+name = "httpx2"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten'" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "truststore", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
+]
+
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -2916,7 +3008,7 @@ name = "hyperlink"
version = "21.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "idna" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" }
wheels = [
@@ -2928,7 +3020,7 @@ name = "id"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "urllib3" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" }
wheels = [
@@ -2946,11 +3038,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.16"
+version = "3.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
@@ -2967,7 +3059,7 @@ name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "zipp" },
+ { name = "zipp", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
@@ -2979,7 +3071,7 @@ name = "incremental"
version = "24.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "setuptools" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/87/156b374ff6578062965afe30cc57627d35234369b3336cf244b240c8d8e6/incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9", size = 28157, upload-time = "2024-07-29T20:03:55.441Z" }
wheels = [
@@ -3009,8 +3101,8 @@ name = "ipdb"
version = "0.13.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "decorator" },
- { name = "ipython" },
+ { name = "decorator", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "ipython", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042, upload-time = "2023-03-09T15:40:57.487Z" }
wheels = [
@@ -3022,16 +3114,16 @@ name = "ipython"
version = "8.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+ { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jedi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "matplotlib-inline", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pexpect", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pygments", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "stack-data", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "traitlets", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" }
wheels = [
@@ -3061,7 +3153,7 @@ name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "more-itertools" },
+ { name = "more-itertools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
@@ -3073,7 +3165,7 @@ name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "backports-tarfile", marker = "python_full_version < '3.12'" },
+ { name = "backports-tarfile", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
@@ -3085,7 +3177,7 @@ name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "more-itertools" },
+ { name = "more-itertools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
@@ -3097,7 +3189,7 @@ name = "jedi"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "parso" },
+ { name = "parso", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
wheels = [
@@ -3118,7 +3210,7 @@ name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "markupsafe" },
+ { name = "markupsafe", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
@@ -3139,8 +3231,8 @@ name = "jsbeautifier"
version = "1.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "editorconfig" },
- { name = "six" },
+ { name = "editorconfig", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" }
wheels = [
@@ -3156,13 +3248,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" },
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jsonschema-specifications", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "referencing", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rpds-py", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
[[package]]
name = "jwcrypto"
version = "1.5.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "typing-extensions" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/7e/53c14813521693e931e420d9db00efe317419ff194495903bce03402275e/jwcrypto-1.5.8.tar.gz", hash = "sha256:c3d7114b6f6e65b52f6b7da817eb8cb8423e1da31e1ef13508447c81ecbdcc34", size = 90772, upload-time = "2026-06-24T19:36:50.782Z" }
wheels = [
@@ -3174,13 +3293,13 @@ name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
- { name = "jaraco-classes" },
- { name = "jaraco-context" },
- { name = "jaraco-functools" },
- { name = "jeepney", marker = "sys_platform == 'linux'" },
- { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
- { name = "secretstorage", marker = "sys_platform == 'linux'" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" },
+ { name = "jaraco-classes", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jaraco-context", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jaraco-functools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jeepney", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
+ { name = "pywin32-ctypes", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+ { name = "secretstorage", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
@@ -3192,10 +3311,10 @@ name = "kombu"
version = "5.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "amqp" },
- { name = "packaging" },
- { name = "tzdata" },
- { name = "vine" },
+ { name = "amqp", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "packaging", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzdata", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "vine", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
wheels = [
@@ -3207,7 +3326,7 @@ name = "langdetect"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "six" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" }
@@ -3407,7 +3526,7 @@ name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "mdurl" },
+ { name = "mdurl", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
@@ -3493,13 +3612,51 @@ name = "matplotlib-inline"
version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "traitlets" },
+ { name = "traitlets", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" },
]
+[[package]]
+name = "mcp"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "httpx2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jsonschema", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mcp-types", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "opentelemetry-api", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyjwt", extra = ["crypto"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-multipart", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+ { name = "sse-starlette", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "starlette", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-inspection", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uvicorn", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
+]
+
+[[package]]
+name = "mcp-types"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
+]
+
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -3550,14 +3707,14 @@ name = "moai-iplweb"
version = "2.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "oaipmh" },
- { name = "paste" },
- { name = "pastedeploy" },
- { name = "pastescript" },
- { name = "sqlalchemy" },
- { name = "webob" },
- { name = "wsgi-intercept" },
- { name = "wsgiutils" },
+ { name = "oaipmh", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "paste", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pastedeploy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pastescript", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sqlalchemy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "webob", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "wsgi-intercept", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "wsgiutils", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/2ccf7a12e5289e16b2a186df385777cce0906bb02aba5d4c299030be7c00/moai_iplweb-2.0.4.tar.gz", hash = "sha256:007eb7046534eac474801b655b55ac208b788011b1e94c00a1f9380c25413be1", size = 35500, upload-time = "2026-08-20T09:35:28.759Z" }
wheels = [
@@ -3569,7 +3726,7 @@ name = "model-bakery"
version = "1.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "django" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/49/3e5b51d7af52bf39e348767c1feb16c97d18654a8e3f3306eb4b899e98cf/model_bakery-1.24.0.tar.gz", hash = "sha256:6469223c9fb0eccb021d5c33667b914e9208dd2282d7dc1e55394c2310a7687d", size = 28372, upload-time = "2026-06-23T21:55:23.19Z" }
wheels = [
@@ -3590,10 +3747,10 @@ name = "mozilla-django-oidc"
version = "5.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "django" },
- { name = "pyjwt" },
- { name = "requests" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyjwt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/5e/d5906dc016253d248eabf3608a6bbfd0df75a993a467cf7cf0a0c7f2b18a/mozilla_django_oidc-5.0.2.tar.gz", hash = "sha256:4e953dcd963c036daaa2ac42b5bb6ea89a1c6ea7be0387c2022a59aca2f83043", size = 57360, upload-time = "2025-12-19T16:11:40.685Z" }
wheels = [
@@ -3799,7 +3956,7 @@ name = "oaipmh"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "lxml" },
+ { name = "lxml", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/0a/ec17bcfb5d1c3d25d9b5852cb59683182c29055ce3fae3cac6fd2c954e93/oaipmh-3.2.0.tar.gz", hash = "sha256:79235ef64cfdb39863919689c854e68989b6f9c40d016b01b65eaabce2bc0d4c", size = 26060, upload-time = "2026-05-15T18:04:18.455Z" }
wheels = [
@@ -3820,24 +3977,36 @@ name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "et-xmlfile" },
+ { name = "et-xmlfile", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
+[[package]]
+name = "opentelemetry-api"
+version = "1.44.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" },
+]
+
[[package]]
name = "ortools"
version = "9.15.6755"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "absl-py" },
- { name = "immutabledict" },
- { name = "numpy" },
- { name = "pandas" },
- { name = "protobuf" },
- { name = "typing-extensions" },
+ { name = "absl-py", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "immutabledict", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "numpy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pandas", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/16/a08369f1d2022b5aea8d9ceede08df417622015447b817019e11ef4a1d9b/ortools-9.15.6755-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:55e291560d2fdb9590656cbee06ba99ee7f2476bd7d316ff757eeab33e9b20d6", size = 23886068, upload-time = "2026-01-14T15:38:55.095Z" },
@@ -3871,7 +4040,7 @@ name = "oscrypto"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asn1crypto" },
+ { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/81/a7654e654a4b30eda06ef9ad8c1b45d1534bfd10b5c045d0c0f6b16fecd2/oscrypto-1.3.0.tar.gz", hash = "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4", size = 184590, upload-time = "2022-03-18T01:53:26.889Z" }
wheels = [
@@ -3892,9 +4061,9 @@ name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "numpy" },
- { name = "python-dateutil" },
- { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+ { name = "numpy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dateutil", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzdata", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'emscripten') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
wheels = [
@@ -3946,10 +4115,10 @@ name = "paramiko"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "bcrypt" },
- { name = "cryptography" },
- { name = "invoke" },
- { name = "pynacl" },
+ { name = "bcrypt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "invoke", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pynacl", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
wheels = [
@@ -3970,7 +4139,7 @@ name = "paste"
version = "3.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "setuptools" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/1c/6bc9040bf9b4cfc9334f66d2738f952384c106c48882adf6097fed3da966/paste-3.10.1.tar.gz", hash = "sha256:1c3d12065a5e8a7a18c0c7be1653a97cf38cc3e9a5a0c8334a9dd992d3a05e4a", size = 652629, upload-time = "2024-05-01T11:41:08.536Z" }
wheels = [
@@ -3991,9 +4160,9 @@ name = "pastescript"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "paste" },
- { name = "pastedeploy" },
- { name = "setuptools" },
+ { name = "paste", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pastedeploy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/c3/a12bc48375d6408eeef371a5c386ef4f62dac56ae35ae2eab042e00689e6/pastescript-3.7.0.tar.gz", hash = "sha256:536f028dfb6e5329c67b32299e1c9303f91264e49793cc6916eb0a73eb4a2521", size = 98747, upload-time = "2025-01-22T16:15:28.827Z" }
wheels = [
@@ -4014,8 +4183,8 @@ name = "pbn-client"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
- { name = "simplejson" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "simplejson", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/53/ef61b6f58592eef44a27a216e9a7687a782a09f5e3f2d499860e59bd9c0d/pbn_client-0.2.2.tar.gz", hash = "sha256:21b78873fbea40c4e13cee39c75ea3e1411429dd21a9c772c2fac83963e3f6c4", size = 64750, upload-time = "2026-08-05T07:27:16.427Z" }
wheels = [
@@ -4027,7 +4196,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess" },
+ { name = "ptyprocess", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -4112,8 +4281,8 @@ name = "playwright"
version = "1.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "greenlet" },
- { name = "pyee" },
+ { name = "greenlet", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyee", marker = "platform_python_implementation != 'PyPy'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" },
@@ -4149,8 +4318,8 @@ name = "polish-inflection"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "marisa-trie" },
- { name = "polish-inflection-data" },
+ { name = "marisa-trie", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "polish-inflection-data", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/21/53697694f0f813f5cc8c6a61c02b3d797edbe9337734d1dab749c690d2bd/polish_inflection-0.7.3.tar.gz", hash = "sha256:28aed6b2043e005298b1191f383c94a2d9eb29aa0210865d31f78b5baa96dc92", size = 70472, upload-time = "2026-07-03T20:21:01.566Z" }
wheels = [
@@ -4171,11 +4340,11 @@ name = "pre-commit"
version = "4.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cfgv" },
- { name = "identify" },
- { name = "nodeenv" },
- { name = "pyyaml" },
- { name = "virtualenv" },
+ { name = "cfgv", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "identify", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "nodeenv", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "virtualenv", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
wheels = [
@@ -4205,7 +4374,7 @@ name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "wcwidth" },
+ { name = "wcwidth", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
@@ -4217,7 +4386,7 @@ name = "proto-plus"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "protobuf" },
+ { name = "protobuf", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" }
wheels = [
@@ -4351,7 +4520,7 @@ name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyasn1" },
+ { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
@@ -4389,6 +4558,123 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
+[[package]]
+name = "pydantic"
+version = "2.13.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydantic-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-inspection", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
+ { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
+ { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
+ { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
+ { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
+ { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
+ { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
+ { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
+ { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
+ { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
+ { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
+ { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
+ { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
+ { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
+ { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
+ { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
+ { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
+ { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
+ { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
+ { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
+ { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
+ { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
+ { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
+ { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
+ { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
+ { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
+ { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
+ { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
+]
+
[[package]]
name = "pydyf"
version = "0.11.0"
@@ -4403,7 +4689,7 @@ name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
@@ -4415,8 +4701,8 @@ name = "pygad"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cloudpickle" },
- { name = "numpy" },
+ { name = "cloudpickle", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "numpy", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/af/05a6d18c27d196cbdce1131aef480441928940dfa33d55dc95dc701b420b/pygad-3.7.0.tar.gz", hash = "sha256:9858226f1b1482ab845b3129152fe9ca4457a7bcae5804717647beacd00b734c", size = 266300, upload-time = "2026-06-05T22:38:52.979Z" }
wheels = [
@@ -4437,13 +4723,13 @@ name = "pyhanko"
version = "0.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asn1crypto" },
- { name = "cryptography" },
- { name = "lxml" },
- { name = "pyhanko-certvalidator" },
- { name = "pyyaml" },
- { name = "requests" },
- { name = "tzlocal" },
+ { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "lxml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyhanko-certvalidator", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tzlocal", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/d5/9be7c6a62d9fe9ad824f4a3144e7f55bff11198446fe4ec7f96cd3f9673c/pyhanko-0.32.0.tar.gz", hash = "sha256:47df283f14289b9df72071c3e5c52c426998f1850e21bff9d6451e4369595820", size = 411900, upload-time = "2025-11-22T16:22:07.543Z" }
wheels = [
@@ -4455,11 +4741,11 @@ name = "pyhanko-certvalidator"
version = "0.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asn1crypto" },
- { name = "cryptography" },
- { name = "oscrypto" },
- { name = "requests" },
- { name = "uritools" },
+ { name = "asn1crypto", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "oscrypto", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uritools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/3d/3e41e3b492b84e327c2340c380439c6805f9d2a3b574f662041958f19373/pyhanko_certvalidator-0.29.0.tar.gz", hash = "sha256:8ebb98e742e4a2e2347374535c1329abf77c398addb31e623f33645ace02efa4", size = 93212, upload-time = "2025-09-12T22:05:37.207Z" }
wheels = [
@@ -4477,7 +4763,7 @@ wheels = [
[package.optional-dependencies]
crypto = [
- { name = "cryptography" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -4491,7 +4777,7 @@ name = "pymed-iplweb"
version = "0.8.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/85/5600c04401f34e2c6b13f243f41db6aa52342040ffc941908b1c3b9ab210/pymed_iplweb-0.8.11.tar.gz", hash = "sha256:ec24b07388fdec59fcb8ccfb926e5254f27ef06b039b017c80162bccb158854a", size = 11535, upload-time = "2026-04-16T11:50:21.128Z" }
wheels = [
@@ -4503,7 +4789,7 @@ name = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi" },
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
wheels = [
@@ -4538,8 +4824,8 @@ name = "pyopenssl"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
@@ -4596,8 +4882,8 @@ name = "pysolr"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
- { name = "setuptools" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "setuptools", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/b6c793a17e173524ccc7a90cdd8ebb65264f6ebcdb1942eba2478ec3a85e/pysolr-3.11.0.tar.gz", hash = "sha256:bbd0e7467835316880f994b55386c9bc3c146733c17d85a3fed317755976c40a", size = 63983, upload-time = "2025-11-18T10:42:40.247Z" }
@@ -4606,11 +4892,11 @@ name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
+ { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
+ { name = "iniconfig", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "packaging", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pluggy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pygments", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
@@ -4622,8 +4908,8 @@ name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
- { name = "requests" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
@@ -4635,9 +4921,9 @@ name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "coverage", extra = ["toml"] },
- { name = "pluggy" },
- { name = "pytest" },
+ { name = "coverage", extra = ["toml"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pluggy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
@@ -4649,7 +4935,7 @@ name = "pytest-django"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
wheels = [
@@ -4661,8 +4947,8 @@ name = "pytest-env"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
- { name = "python-dotenv" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/49/08ee056f9cc655e437abcf2ae399884844b623223476ae6a77244131db03/pytest_env-1.7.0.tar.gz", hash = "sha256:0c1dc1101fb8d3ab3611e8f8d657ba06c3c0c167fc85c90457e5b27f2508f43e", size = 16408, upload-time = "2026-07-21T13:09:21.834Z" }
wheels = [
@@ -4674,7 +4960,7 @@ name = "pytest-httpserver"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "werkzeug" },
+ { name = "werkzeug", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/17/ad187f46998814014f7cda309de700b87c0eb4b2e111e18bc8c819be7116/pytest_httpserver-1.1.5.tar.gz", hash = "sha256:dc3d82e1fe00e491829d8939c549bf4bd9b39a260f87113c619b9d517c2f8ff1", size = 70974, upload-time = "2026-02-14T13:27:23.412Z" }
wheels = [
@@ -4686,7 +4972,7 @@ name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
@@ -4698,10 +4984,10 @@ name = "pytest-playwright"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "playwright" },
- { name = "pytest" },
- { name = "pytest-base-url" },
- { name = "python-slugify" },
+ { name = "playwright", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-base-url", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-slugify", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/c4/31cab1a9edfa35546950bb30c033dbfe4d4f3a216f4bafb0537a1469a70c/pytest_playwright-0.9.0.tar.gz", hash = "sha256:bd44daa852b0fb8b0e55a2f88b727507dedda0e350bea5d09420647252f2454b", size = 17899, upload-time = "2026-08-10T10:20:16.407Z" }
wheels = [
@@ -4713,8 +4999,8 @@ name = "pytest-recording"
version = "0.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
- { name = "vcrpy" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "vcrpy", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" }
wheels = [
@@ -4726,7 +5012,7 @@ name = "pytest-repeat"
version = "0.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" }
wheels = [
@@ -4738,8 +5024,8 @@ name = "pytest-rerunfailures"
version = "16.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "packaging" },
- { name = "pytest" },
+ { name = "packaging", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/ce/065fde089d5a7f46fe8372c3f340c7aaa8708734d2e8ae3032f0c29a4b1d/pytest_rerunfailures-16.6.1.tar.gz", hash = "sha256:20ac38d31f9319c4e8f43fe0d0e9c22755508e8e7987a88de6d7fd93fc6bdabb", size = 47719, upload-time = "2026-09-03T06:50:07.893Z" }
wheels = [
@@ -4751,7 +5037,7 @@ name = "pytest-split"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/16/8af4c5f2ceb3640bb1f78dfdf5c184556b10dfe9369feaaad7ff1c13f329/pytest_split-0.11.0.tar.gz", hash = "sha256:8ebdb29cc72cc962e8eb1ec07db1eeb98ab25e215ed8e3216f6b9fc7ce0ec2b5", size = 13421, upload-time = "2026-02-03T09:14:31.469Z" }
wheels = [
@@ -4763,9 +5049,9 @@ name = "pytest-testcontainers"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "docker" },
- { name = "pytest" },
- { name = "testcontainers" },
+ { name = "docker", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/a1/08eb167860f55a7f4053511579566f4fb79135b4c4a54b44fd344f127b48/pytest_testcontainers-0.3.2.tar.gz", hash = "sha256:918b7048e13e5c928ed049f9f1d56b05b5ef9c55925b9b8df8172df61cac42cb", size = 57072, upload-time = "2026-08-07T15:10:04.933Z" }
wheels = [
@@ -4777,10 +5063,10 @@ name = "pytest-testcontainers-django"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
- { name = "pytest-django" },
- { name = "pytest-testcontainers" },
- { name = "testcontainers" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-django", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest-testcontainers", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "testcontainers", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/26/c4d4a2e2e25edaab9e586e6955ecd970c9767b0c8b945e5e6ef5824e1971/pytest_testcontainers_django-0.3.2.tar.gz", hash = "sha256:20c18926638e5f82df1841482ce198eb7c9ecd3c4b64fb5593a5b9dc3d3cebf0", size = 35288, upload-time = "2026-08-07T15:11:58.196Z" }
wheels = [
@@ -4792,7 +5078,7 @@ name = "pytest-timeout"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
wheels = [
@@ -4804,8 +5090,8 @@ name = "pytest-tqdm"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
- { name = "tqdm" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/27/08b7a2e45d469fd052a6c6719946300160a8bf5603b101922363922e0f69/pytest_tqdm-0.4.0.tar.gz", hash = "sha256:eefa4e26145e7ad7d4f3e1ec23c5000f4115d74ea1c19daa7487ca70e832e19a", size = 15113, upload-time = "2026-07-13T15:11:57.19Z" }
wheels = [
@@ -4817,8 +5103,8 @@ name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "execnet" },
- { name = "pytest" },
+ { name = "execnet", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pytest", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
@@ -4900,7 +5186,7 @@ name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "six" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
@@ -4912,8 +5198,8 @@ name = "python-discovery"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "filelock" },
- { name = "platformdirs" },
+ { name = "filelock", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "platformdirs", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
wheels = [
@@ -4925,8 +5211,8 @@ name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "lxml" },
- { name = "typing-extensions" },
+ { name = "lxml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
@@ -4947,17 +5233,26 @@ name = "python-ldap"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyasn1" },
- { name = "pyasn1-modules" },
+ { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" }
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "text-unidecode" },
+ { name = "text-unidecode", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
@@ -5052,7 +5347,7 @@ name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "prompt-toolkit" },
+ { name = "prompt-toolkit", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
@@ -5189,9 +5484,9 @@ name = "readme-renderer"
version = "44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "docutils" },
- { name = "nh3" },
- { name = "pygments" },
+ { name = "docutils", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "nh3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pygments", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" }
wheels = [
@@ -5203,13 +5498,27 @@ name = "redis"
version = "7.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "async-timeout", marker = "python_full_version < '3.11.3'" },
+ { name = "async-timeout", marker = "python_full_version < '3.11.3' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
]
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rpds-py", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
[[package]]
name = "regex"
version = "2026.5.9"
@@ -5319,8 +5628,8 @@ name = "reportlab"
version = "4.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "charset-normalizer" },
- { name = "pillow" },
+ { name = "charset-normalizer", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pillow", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/35/ec/f7a50b3cbee58407090bd1f2a9db2f1a23052c5de3bc7408024ca776ee02/reportlab-4.4.6.tar.gz", hash = "sha256:8792c87c23dd034d17530e6ebe4164d61bcc8f7b0eac203fe13cc03cc2c1c607", size = 3910805, upload-time = "2025-12-10T12:37:21.17Z" }
wheels = [
@@ -5332,10 +5641,10 @@ name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
+ { name = "certifi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "charset-normalizer", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
@@ -5347,8 +5656,8 @@ name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "oauthlib" },
- { name = "requests" },
+ { name = "oauthlib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
wheels = [
@@ -5360,7 +5669,7 @@ name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
@@ -5381,8 +5690,8 @@ name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "markdown-it-py" },
- { name = "pygments" },
+ { name = "markdown-it-py", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pygments", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
@@ -5438,8 +5747,8 @@ name = "rlpycairo"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "freetype-py" },
- { name = "pycairo" },
+ { name = "freetype-py", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pycairo", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/24/09b18821e06de45394d34dda706d34169db6bddd6a403346caa3496b3668/rlpycairo-0.4.0.tar.gz", hash = "sha256:07c2c3c47828e83d9c09657a54ecbcd1a97aac9dc199780234456d3473faadc7", size = 7692, upload-time = "2025-08-15T12:25:22.761Z" }
wheels = [
@@ -5451,13 +5760,107 @@ name = "rollbar"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "requests" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/3f/92e8c728e42aba86db5f8d6e2e9c23926b2d8df81a02cdcab0c044e285e9/rollbar-1.4.0.tar.gz", hash = "sha256:98ad7f90012c791d0b40eac51b6fe688b1cadcc4aadff772fc0a23efe75fb5fc", size = 94401, upload-time = "2026-07-10T16:32:15.518Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/0b/383e2a843722a256406edaae37561ff124af6633369920bef6ea3daa1678/rollbar-1.4.0-py3-none-any.whl", hash = "sha256:4f5c183ce1cdc6316a2a675f887a6ccead3f9b5da35b8d6c4ad7a7c2904c602d", size = 126652, upload-time = "2026-07-10T16:32:14.289Z" },
]
+[[package]]
+name = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
+ { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
+ { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+ { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
+ { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
+ { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
+ { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
+ { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
+ { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
+ { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
+ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
+]
+
[[package]]
name = "ruff"
version = "0.16.6"
@@ -5488,7 +5891,7 @@ name = "s3transfer"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "botocore" },
+ { name = "botocore", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
wheels = [
@@ -5500,8 +5903,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "jeepney" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "jeepney", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
@@ -5513,10 +5916,10 @@ name = "service-identity"
version = "24.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "attrs" },
- { name = "cryptography" },
- { name = "pyasn1" },
- { name = "pyasn1-modules" },
+ { name = "attrs", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cryptography", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyasn1", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyasn1-modules", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" }
wheels = [
@@ -5610,7 +6013,7 @@ name = "smart-open"
version = "7.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "wrapt" },
+ { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/d8/1481294b2d110b805c0f5d23ef34158b7d5d4283633c0d34c69ea89bb76b/smart_open-7.0.5.tar.gz", hash = "sha256:d3672003b1dbc85e2013e4983b88eb9a5ccfd389b0d4e5015f39a9ee5620ec18", size = 71693, upload-time = "2024-10-04T13:58:32.442Z" }
wheels = [
@@ -5619,14 +6022,14 @@ wheels = [
[package.optional-dependencies]
all = [
- { name = "azure-common" },
- { name = "azure-core" },
- { name = "azure-storage-blob" },
- { name = "boto3" },
- { name = "google-cloud-storage" },
- { name = "paramiko" },
- { name = "requests" },
- { name = "zstandard" },
+ { name = "azure-common", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "azure-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "azure-storage-blob", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "boto3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "google-cloud-storage", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "paramiko", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "zstandard", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -5652,7 +6055,7 @@ name = "sqlalchemy"
version = "1.4.54"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "greenlet", marker = "(platform_machine == 'AMD64' and platform_python_implementation != 'PyPy') or (platform_machine == 'WIN32' and platform_python_implementation != 'PyPy') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy') or (platform_machine == 'amd64' and platform_python_implementation != 'PyPy') or (platform_machine == 'ppc64le' and platform_python_implementation != 'PyPy') or (platform_machine == 'win32' and platform_python_implementation != 'PyPy') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350, upload-time = "2024-09-05T15:54:10.398Z" }
wheels = [
@@ -5677,30 +6080,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
+[[package]]
+name = "sse-starlette"
+version = "3.4.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "starlette", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" },
+]
+
[[package]]
name = "stack-data"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "asttokens" },
- { name = "executing" },
- { name = "pure-eval" },
+ { name = "asttokens", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "executing", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pure-eval", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
+[[package]]
+name = "starlette"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' and platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
+]
+
[[package]]
name = "svglib"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cssselect2" },
- { name = "lxml" },
- { name = "reportlab" },
- { name = "rlpycairo" },
- { name = "tinycss2" },
+ { name = "cssselect2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "lxml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "reportlab", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rlpycairo", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/16/6b5f9f8c8b18d9ab5112d11e2d6c40c965e3d7ab2948b744685fe6d2f9f5/svglib-1.6.0.tar.gz", hash = "sha256:4c38a274a744ef0d1677f55d5d62fc0fb798819f813e52872a796e615741733d", size = 1318276, upload-time = "2025-09-25T09:48:37.839Z" }
wheels = [
@@ -5721,11 +6150,11 @@ name = "testcontainers"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "docker" },
- { name = "python-dotenv" },
- { name = "typing-extensions" },
- { name = "urllib3" },
- { name = "wrapt" },
+ { name = "docker", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" }
wheels = [
@@ -5734,7 +6163,7 @@ wheels = [
[package.optional-dependencies]
redis = [
- { name = "redis" },
+ { name = "redis", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -5751,7 +6180,7 @@ name = "thefuzz"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "rapidfuzz" },
+ { name = "rapidfuzz", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/4b/d3eb25831590d6d7d38c2f2e3561d3ba41d490dc89cd91d9e65e7c812508/thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680", size = 19993, upload-time = "2024-01-19T19:18:23.135Z" }
wheels = [
@@ -5763,7 +6192,7 @@ name = "tinycss2"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "webencodings" },
+ { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
wheels = [
@@ -5775,7 +6204,7 @@ name = "tinyhtml5"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "webencodings" },
+ { name = "webencodings", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/03/6111ed99e9bf7dfa1c30baeef0e0fb7e0bd387bd07f8e5b270776fe1de3f/tinyhtml5-2.0.0.tar.gz", hash = "sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc", size = 179507, upload-time = "2024-10-29T15:37:14.078Z" }
wheels = [
@@ -5850,8 +6279,8 @@ name = "towncrier"
version = "25.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "jinja2" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jinja2", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" }
wheels = [
@@ -5863,7 +6292,7 @@ name = "tqdm"
version = "4.70.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
wheels = [
@@ -5879,20 +6308,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "twine"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "id" },
- { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
- { name = "packaging" },
- { name = "readme-renderer" },
- { name = "requests" },
- { name = "requests-toolbelt" },
- { name = "rfc3986" },
- { name = "rich" },
- { name = "urllib3" },
+ { name = "id", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy'" },
+ { name = "packaging", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "readme-renderer", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests-toolbelt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rfc3986", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "rich", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" }
wheels = [
@@ -5904,13 +6342,13 @@ name = "twisted"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "attrs" },
- { name = "automat" },
- { name = "constantly" },
- { name = "hyperlink" },
- { name = "incremental" },
- { name = "typing-extensions" },
- { name = "zope-interface" },
+ { name = "attrs", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "automat", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "constantly", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "hyperlink", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "incremental", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "zope-interface", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" }
wheels = [
@@ -5919,13 +6357,13 @@ wheels = [
[package.optional-dependencies]
http2 = [
- { name = "h2" },
- { name = "priority" },
+ { name = "h2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "priority", marker = "platform_python_implementation != 'PyPy'" },
]
tls = [
- { name = "idna" },
- { name = "pyopenssl" },
- { name = "service-identity" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyopenssl", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "service-identity", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -5946,6 +6384,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "typing-inspection"
+version = "0.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
+]
+
[[package]]
name = "tzdata"
version = "2025.2"
@@ -5960,7 +6410,7 @@ name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "tzdata", marker = "sys_platform == 'win32'" },
+ { name = "tzdata", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
@@ -5999,8 +6449,8 @@ name = "uvicorn"
version = "0.52.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "h11" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "h11", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
wheels = [
@@ -6009,12 +6459,12 @@ wheels = [
[package.optional-dependencies]
standard = [
- { name = "httptools" },
- { name = "python-dotenv" },
- { name = "pyyaml" },
- { name = "uvloop", marker = "sys_platform != 'cygwin' and sys_platform != 'win32'" },
- { name = "watchfiles" },
- { name = "websockets" },
+ { name = "httptools", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-dotenv", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "websockets", marker = "platform_python_implementation != 'PyPy'" },
]
[[package]]
@@ -6022,8 +6472,8 @@ name = "uvicorn-worker"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "gunicorn" },
- { name = "uvicorn" },
+ { name = "gunicorn", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "uvicorn", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" }
wheels = [
@@ -6061,8 +6511,8 @@ name = "vcrpy"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyyaml" },
- { name = "wrapt" },
+ { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/d5/8a1f8eb603e2d35fbb0ecd1e309d0c5c18a0ecfc8c0a8f04088bbc8f833b/vcrpy-8.3.0.tar.gz", hash = "sha256:46d64e77e8d95e5c76c7d9a94ff05d8b38b2ae4e1d4869eb0235024b6fcb5212", size = 96117, upload-time = "2026-07-04T14:27:01.608Z" }
wheels = [
@@ -6083,10 +6533,10 @@ name = "virtualenv"
version = "21.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "distlib" },
- { name = "filelock" },
- { name = "platformdirs" },
- { name = "python-discovery" },
+ { name = "distlib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "filelock", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "platformdirs", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-discovery", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/e1/665267cea4767debd19f584667a9197c2098b5e7f67a502da9f3a086ab37/virtualenv-21.3.2.tar.gz", hash = "sha256:3ecda97894a6fc1c53106356f488690e5c86278c1f693f3fc0805ac85a513686", size = 7613810, upload-time = "2026-05-12T14:44:18.01Z" }
wheels = [
@@ -6143,7 +6593,7 @@ name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "anyio" },
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
@@ -6239,14 +6689,14 @@ name = "weasyprint"
version = "69.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi" },
- { name = "cssselect2" },
- { name = "fonttools", extra = ["woff"] },
- { name = "pillow" },
- { name = "pydyf" },
- { name = "pyphen" },
- { name = "tinycss2" },
- { name = "tinyhtml5" },
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cssselect2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "fonttools", extra = ["woff"], marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pillow", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydyf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyphen", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tinycss2", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tinyhtml5", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
wheels = [
@@ -6267,7 +6717,7 @@ name = "webob"
version = "1.8.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "legacy-cgi", marker = "python_full_version >= '3.13'" },
+ { name = "legacy-cgi", marker = "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/92/9329872e36be90e25ba616ac6e9ee8c1be4326a1ea3fdffcdabd0eb48efb/webob-1.8.11.tar.gz", hash = "sha256:aa8c27231070b135c025e567a9cd7eda03f4df71352ffaac740cb6a75f0f81a5", size = 286047, upload-time = "2026-08-02T06:26:26.063Z" }
wheels = [
@@ -6321,9 +6771,9 @@ name = "webtest"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "beautifulsoup4" },
- { name = "waitress" },
- { name = "webob" },
+ { name = "beautifulsoup4", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "waitress", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "webob", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/c8/8ffba1782700eb06e9b9169156698c6ba95c05b66cda3fc9e025b6b3b649/WebTest-3.0.0.tar.gz", hash = "sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb", size = 75871, upload-time = "2021-08-19T12:40:07.234Z" }
wheels = [
@@ -6335,7 +6785,7 @@ name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "markupsafe" },
+ { name = "markupsafe", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
@@ -6347,8 +6797,8 @@ name = "wosclient"
version = "0.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "requests" },
+ { name = "click", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "requests", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/a1/5d170a12144cf75f06ec01f6ddd991b38f79ca8f018ae9831723207cc62d/wosclient-0.1.5.tar.gz", hash = "sha256:ca8e3c40cac64fdb22c3b0627ef8b9d115a38ffea237fc23c4197d34f0a4f384", size = 12655, upload-time = "2018-04-22T14:37:34.041Z" }
wheels = [
@@ -6419,7 +6869,7 @@ name = "wsgi-intercept"
version = "1.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "six" },
+ { name = "six", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/ed/28202fddeff86871b8a8d1fbb32878fe9df5592d5fd0fbac4ab81764202a/wsgi_intercept-1.13.1.tar.gz", hash = "sha256:79bd6c639ab11f7eabe5d18ad9a20f62ee82a1c435124a876cffd8038f6c24c1", size = 29288, upload-time = "2024-08-08T08:16:35.519Z" }
wheels = [
@@ -6437,15 +6887,15 @@ name = "xhtml2pdf"
version = "0.2.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "arabic-reshaper" },
- { name = "html5lib" },
- { name = "pillow" },
- { name = "pyhanko" },
- { name = "pyhanko-certvalidator" },
- { name = "pypdf" },
- { name = "python-bidi" },
- { name = "reportlab" },
- { name = "svglib" },
+ { name = "arabic-reshaper", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "html5lib", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pillow", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyhanko", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pyhanko-certvalidator", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pypdf", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "python-bidi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "reportlab", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "svglib", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/e9/afe9880fa3f705f8ec1ca68891179b56b66f6701d13522033aaf737bfa74/xhtml2pdf-0.2.18.tar.gz", hash = "sha256:2e1a8ec7a060497deaaeb87a607bdf1e6a23f69bb16edb25937b081b3aedd059", size = 212844, upload-time = "2026-09-03T02:02:04.001Z" }
wheels = [