diff --git a/src/rainkeeper/client.py b/src/rainkeeper/client.py index 8497aa6..4690665 100644 --- a/src/rainkeeper/client.py +++ b/src/rainkeeper/client.py @@ -32,10 +32,17 @@ def _check_response(r: httpx.Response) -> None: class RaindropClient: def __init__(self): - self._client = httpx.AsyncClient(base_url=BASE_URL, headers=get_auth_header()) + self._httpx_client: httpx.AsyncClient | None = None + + @property + def client(self) -> httpx.AsyncClient: + if self._httpx_client is None: + self._httpx_client = httpx.AsyncClient(base_url=BASE_URL, headers=get_auth_header()) + return self._httpx_client async def aclose(self) -> None: - await self._client.aclose() + if self._httpx_client is not None: + await self._httpx_client.aclose() async def __aenter__(self): return self @@ -44,22 +51,22 @@ async def __aexit__(self, *args) -> None: await self.aclose() async def get(self, path: str, **params) -> dict: - r = await self._client.get(path, params={k: v for k, v in params.items() if v is not None}) + r = await self.client.get(path, params={k: v for k, v in params.items() if v is not None}) _check_response(r) return r.json() async def post(self, path: str, body: dict) -> dict: - r = await self._client.post(path, json=body) + r = await self.client.post(path, json=body) _check_response(r) return r.json() async def put(self, path: str, body: dict) -> dict: - r = await self._client.put(path, json=body) + r = await self.client.put(path, json=body) _check_response(r) return r.json() async def delete(self, path: str, body: dict | None = None) -> dict: - r = await self._client.request("DELETE", path, json=body) + r = await self.client.request("DELETE", path, json=body) _check_response(r) return r.json() diff --git a/src/rainkeeper/server.py b/src/rainkeeper/server.py index 18ff497..236c376 100644 --- a/src/rainkeeper/server.py +++ b/src/rainkeeper/server.py @@ -19,10 +19,7 @@ def main(): - try: - mcp.run() - finally: - asyncio.run(_client.aclose()) + mcp.run() if __name__ == "__main__": diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..ef932e1 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,32 @@ +import pytest +from unittest.mock import patch +from rainkeeper.client import RaindropClient + +@pytest.mark.asyncio +async def test_lazy_instantiation_no_token_crash(): + """Test that creating a client without a token does not crash instantly.""" + with patch("rainkeeper.config.RAINDROP_ACCESS_TOKEN", None): + client = RaindropClient() + # Should not crash on instantiation + assert client._httpx_client is None + +@pytest.mark.asyncio +async def test_lazy_instantiation_crash_on_request(): + """Test that it crashes on first request if token is missing.""" + with patch("rainkeeper.config.RAINDROP_ACCESS_TOKEN", None): + client = RaindropClient() + with pytest.raises(ValueError, match="RAINDROP_ACCESS_TOKEN is not set"): + # Trigger property access which should raise ValueError + _ = client.client + +@pytest.mark.asyncio +async def test_lazy_instantiation_success(): + """Test that client initializes successfully when token is present.""" + with patch("rainkeeper.config.RAINDROP_ACCESS_TOKEN", "fake_token"): + client = RaindropClient() + assert client._httpx_client is None + + # Accessing the client property should initialize the httpx.AsyncClient + httpx_client = client.client + assert httpx_client is not None + assert httpx_client.headers.get("Authorization") == "Bearer fake_token"