Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gradata/src/gradata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
EmbeddingError,
EventPersistenceError,
ExportError,
GradataAuthError,
GradataError,
TaxonomyError,
ValidationError,
Expand All @@ -80,6 +81,7 @@
"EmbeddingError",
"EventPersistenceError",
"ExportError",
"GradataAuthError",
"GradataError",
"Lesson",
"LessonState",
Expand Down
24 changes: 20 additions & 4 deletions Gradata/src/gradata/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,25 @@
from gradata._env import env_str
from gradata.brain_inspection import BrainInspectionMixin

AUTH_ERROR_MESSAGE = (
"No API key found. Set GRADATA_API_KEY or pass api_key=... to Brain(). "
"Get one at https://gradata.ai/keys"
)

def _resolve_sync_api_key() -> str | None:

def _resolve_sync_api_key(api_key: str | None = None) -> str | None:
"""Resolve cloud API key for the write-through sync worker.

Order matches ``gradata.daemon._resolve_api_key``:
``GRADATA_API_KEY`` env var, then ``~/.gradata/key`` file.
Returns ``None`` when neither is set — write-through is silently
disabled (the local correct() path still works normally).
An explicit ``api_key=`` wins, then ``GRADATA_API_KEY``, then
``~/.gradata/key``. Returns ``None`` when neither is set so callers
can decide whether local-only operation is allowed.
"""
import os

if api_key:
return api_key
env_key = os.environ.get("GRADATA_API_KEY")
if env_key:
return env_key
Expand All @@ -90,9 +98,17 @@ def __init__(
brain_dir: str | Path | None = None,
working_dir: str | Path | None = None,
encryption_key: str | None = None,
api_key: str | None = None,
_skip_auth_check: bool = False,
):
from gradata._paths import resolve_brain_dir

self._api_key = _resolve_sync_api_key(api_key)
if not _skip_auth_check and not self._api_key:
from gradata.exceptions import GradataAuthError

raise GradataAuthError(AUTH_ERROR_MESSAGE)

self.dir = resolve_brain_dir(brain_dir)
if not self.dir.exists():
from gradata.exceptions import BrainNotFoundError
Expand Down Expand Up @@ -279,7 +295,7 @@ def _maybe_start_sync_worker(self) -> None:

if os.environ.get("GRADATA_DISABLE_WRITE_THROUGH") == "1":
return
api_key = _resolve_sync_api_key()
api_key = self._api_key or _resolve_sync_api_key()
if not api_key:
return

Expand Down
10 changes: 7 additions & 3 deletions Gradata/src/gradata/cloud/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from urllib.request import Request, urlopen

from gradata._http import require_https
from gradata.brain import AUTH_ERROR_MESSAGE
from gradata.exceptions import GradataAuthError
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Decouple auth message constant from gradata.brain to reduce module coupling.

Importing AUTH_ERROR_MESSAGE from gradata.brain makes the cloud client depend on the full Brain module. Move the shared message constant to gradata.exceptions (or a tiny shared auth constants module) and import it from both callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/cloud/client.py` around lines 27 - 28, AUTH_ERROR_MESSAGE
currently imported in Gradata/src/gradata/cloud/client.py from gradata.brain
creates an unnecessary coupling; move the constant into gradata.exceptions (or a
small shared module) and update imports. Specifically, add AUTH_ERROR_MESSAGE to
gradata.exceptions (next to GradataAuthError), change the import in
cloud/client.py to from gradata.exceptions import AUTH_ERROR_MESSAGE,
GradataAuthError, and update any other modules (e.g., gradata.brain) to import
AUTH_ERROR_MESSAGE from gradata.exceptions instead of defining or exporting it
from gradata.brain.


logger = logging.getLogger("gradata.cloud")

Expand Down Expand Up @@ -334,9 +336,11 @@ def _post(self, path: str, data: dict) -> dict[str, Any]:
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
if e.code == 413:
raise _TooLargeError() from e
except HTTPError as e:
if e.code in (401, 403):
raise GradataAuthError(AUTH_ERROR_MESSAGE) from e
if e.code == 413:
raise _TooLargeError() from e
Comment on lines +339 to +343

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a focused unit test for 401/403 → GradataAuthError mapping.

This is a behavior contract change; a deterministic test around mocked HTTPError for 401/403 will prevent regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/cloud/client.py` around lines 339 - 343, Add a
deterministic unit test that mocks/raises HTTPError with .code set to 401 and
403 and asserts that the call raises GradataAuthError (use the same client
method that contains the except HTTPError block to trigger the handler); create
separate subtests for 401 and 403, and also include a negative/other-code case
(e.g., 413) to assert the mapping to _TooLargeError remains unchanged so
regressions are caught. Use the real HTTPError, GradataAuthError, and
_TooLargeError symbols from the client code and patch the network call or method
that would raise HTTPError to simulate the conditions. Ensure the test is
isolated, deterministic, and added to the test suite where other cloud client
tests live.

try:
body = e.read().decode("utf-8", errors="replace")[:500]
except Exception:
Expand Down
4 changes: 4 additions & 0 deletions Gradata/src/gradata/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class BrainNotConfiguredError(BrainError):
"""Brain directory is required but could not be resolved."""


class GradataAuthError(BrainError):
"""Authentication is required for a Gradata Cloud operation."""


class BrainLockedError(BrainError):
"""Brain directory is already held by another daemon/server process."""

Expand Down
2 changes: 1 addition & 1 deletion Gradata/src/gradata/onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def onboard(

# ── Success output ─────────────────────────────────────────────────

brain = Brain(brain_dir)
brain = Brain(brain_dir, _skip_auth_check=True)

if interactive:
stats = brain.stats()
Expand Down
35 changes: 35 additions & 0 deletions Gradata/tests/test_auth_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

import pytest

from gradata import Brain, GradataAuthError
from gradata.brain import AUTH_ERROR_MESSAGE


def test_brain_constructor_without_api_key_raises_auth_error(tmp_path, monkeypatch):
brain_dir = tmp_path / "brain"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("GRADATA_API_KEY", raising=False)

Brain.init(brain_dir, name="TestBrain", domain="Testing", embedding="local", interactive=False)

with pytest.raises(GradataAuthError) as exc:
Brain(brain_dir)

assert "GRADATA_API_KEY" in str(exc.value)
assert str(exc.value) == AUTH_ERROR_MESSAGE


def test_brain_constructor_accepts_explicit_api_key(tmp_path, monkeypatch):
brain_dir = tmp_path / "brain"
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("GRADATA_API_KEY", raising=False)
monkeypatch.setenv("GRADATA_DISABLE_WRITE_THROUGH", "1")

Brain.init(brain_dir, name="TestBrain", domain="Testing", embedding="local", interactive=False)

brain = Brain(brain_dir, api_key="gd_test")
try:
assert brain._api_key == "gd_test"
finally:
brain.close()
Loading