Przejdź do treści

Rozwój

uv sync --extra dev
uv run ruff format .
uv run ruff check .
uv run pytest -q

Testy są w pełni offline (mock httpx przez respx); domyślne CI nie wykonuje żadnych żywych wywołań.

Dokumentacja

Ta dokumentacja to MkDocs + Material. Podgląd lokalny:

uv sync --extra docs
uv run mkdocs serve

Build produkcyjny (jak w CI) — --strict traktuje ostrzeżenia (m.in. martwe linki) jako błędy:

uv run mkdocs build --strict

Po merge do main workflow .github/workflows/docs.yml publikuje stronę na GitHub Pages.

Wydanie na PyPI

Publikacja idzie przez trusted publishing (OIDC) — w repozytorium nie ma i nie może być tokenu API PyPI. Wydanie wyzwala push tagu:

# 0. UPEWNIJ SIĘ, ŻE JESTEŚ NA AKTUALNYM main
git fetch origin && git status -sb   # ahead/behind musi być czyste
# 1. podbij wersję w TRZECH miejscach (niżej), zacommituj
# 2. otaguj i wypchnij
git tag vX.Y.Z
git push origin vX.Y.Z

Krok 0 nie jest ozdobnikiem: praca ze starego lokalnego main kończy się gałęzią, która celuje w nieistniejące API i podbija wersję na numer już zajęty. git log bez wcześniejszego fetch pokazuje stan sprzed tygodni i wygląda zupełnie wiarygodnie.

Numer wersji żyje w trzech plikach i wszystkie trzy trzeba podbić razem:

Plik Kto to czyta
pyproject.toml (project.version) build, PyPI — i tylko to porównuje z tagiem workflow wydaniowy
src/bpp_mcp/__init__.py (__version__) kod w runtime; trafia do serverInfo w handshake MCP
manifest.json (version) bundle .mcpb dla Claude Desktop

Gate w CI sprawdza wyłącznie zgodność tagu z pyproject.toml, więc rozjazd dwóch pozostałych nie przerwie wydania — użytkownik zobaczyłby wtedy w handshake albo w instalatorze Claude Desktop numer inny niż zainstalowany. Sprawdź przed tagowaniem:

grep -rn '<stara-wersja>' pyproject.toml src/bpp_mcp/__init__.py manifest.json

Workflow .github/workflows/release.yml przepuszcza pełną matrycę testów, sprawdza, czy tag zgadza się z project.version (rozjazd = przerwane wydanie, bo numeru raz zajętego na PyPI nie da się odzyskać), buduje sdist + wheel, weryfikuje je twine check --strict i obecność zbundlowanych schematów DjangoQL, po czym publikuje z osobnego joba w środowisku pypi.

Hostowanie narzędzi w cudzym procesie

Od 0.4.0 ten pakiet da się wpiąć do aplikacji, która sama wystawia endpoint MCP — w praktyce: do instancji BPP serwującej własne /mcp. Wtedy narzędzia nie chodzą do API po sieci, tylko wołają aplikację hosta w tym samym procesie, a użytkownik nie musi nic instalować ani znać adresu swojej uczelni.

Służą do tego cztery szwy. Wszystkie są addytywne — domyślne wartości odtwarzają zachowanie sprzed 0.4.0.

import httpx
from mcp.server.mcpserver import MCPServer
from bpp_mcp import KontekstApp, register_tools
from bpp_mcp.client import BppClient, TrybAuth

class KlientHosta(BppClient):
    def _slownik_cache(self):                            # (3)
        return cache_biezacego_zadania.get()

client = KlientHosta(
    config,
    transport=httpx.ASGITransport(app=aplikacja_hosta),  # (1)
    tryb_auth=TrybAuth.W_PROCESIE,                       # (2)
    max_retries=0,
)

mcp = MCPServer("bpp", version="…", lifespan=wlasny_lifespan)
register_tools(mcp)                                      # (4)

(1) transport= podstawia warstwę transportową httpx. Z httpx.ASGITransport żądanie idzie wprost do aplikacji ASGI hosta: bez gniazda, bez TLS-a, bez adresu, który trzeba znać. Uwaga — ASGITransport ignoruje timeouty httpx, więc sufit czasu odpowiedzi musi zapewnić host. Ponawianie też traci sens (nie ma sieci, która by zamigotała): max_retries=0.

Drugie zastrzeżenie: ASGITransport ma domyślnie raise_app_exceptions=True, a BppClient._request łapie wyłącznie httpx.HTTPError. Wyjątek, który ucieknie z aplikacji hosta, przejdzie przez klienta surowy — jako RuntimeError czy DatabaseError, nie jako BppError — i wyląduje w tracebacku w wyniku narzędzia. Podaj raise_app_exceptions=False, jeśli wolisz, żeby błąd aplikacji zamienił się w 500, a więc w BppNetworkError z czytelnym komunikatem.

(2) tryb_auth=TrybAuth.W_PROCESIE — bearer bieżącego żądania albo anonimowo, nigdy Basic. Tryb ZDALNY (dotąd wybierany przez transport="http") przy braku bearera rzuca, co dla endpointu z dostępem publicznym jest złe; tryb LOKALNY sięgnąłby po BPP_BASIC_AUTH, czyli po wspólne konto omijające scope i revoke tokenu. Polityka jest od 0.4.0 rozłączna od nazwy transportu: po stronie klienta config.transport nie steruje już niczym — zarówno politykę auth, jak i treść podpowiedzi po 401 niesie tryb_auth. (Poza klientem config.transport nadal wybiera tryb serwera w build_mcp.)

Podawaj człon enuma, nie string. TrybAuth ma mixin str, więc "w-procesie" wygląda na równoważne — konstruktor koercjonuje je poprawnie i rzuca ValueError na nieznanej wartości, ale porównania w środku idą przez is, więc obchodzenie konstruktora skończyłoby się cichym Basikiem.

(3) _slownik_cache() to metoda do nadpisania w podklasie. Domyślnie cache URL → JSON jest jeden na instancję klienta, co jest poprawne, gdy proces obsługuje jednego użytkownika. W SDK 2.0 lifespan wchodzi raz, a jego wynik dzielą wszystkie sesje i żądania — klient jest więc bezwarunkowo współdzielony, a kluczem cache jest sam URL. Jeden słownik mieszałby wtedy użytkowników i (w instalacji wielo-tenantowej) uczelnie. Podstaw słownik o właściwym zasięgu, np. per-żądanie z ContextVar — tak samo, jak SDK 2.0 wymusiło to już na izolacji bearera.

(4) register_tools(mcp) rejestruje komplet 11 narzędzi i 1 prompt na instancji MCPServer hosta — bez kopiowania wrapperów. Kontrakt: lifespan przekazany do MCPServer musi oddawać KontekstApp (albo obiekt o tych samych atrybutach), bo wrappery sięgają po klienta przez ctx.request_context.lifespan_context. Host wielo-użytkownikowy ustawia bearer_provider=None — fallback na token z lokalnego cache ma sens wyłącznie w stdio, inaczej token jednej osoby trafiłby do żądania innej. Zamknięcie klienta (await client.aclose()) należy do lifespanu hosta.