datacules-agent-identity 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: datacules-agent-identity
3
+ Version: 0.3.3
4
+ Summary: Python SDK for the agent-identity credential routing sidecar — by Datacules LLC
5
+ Author-email: Datacules LLC <harshalrasal792@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hvrcharon1/agent-identity
8
+ Project-URL: Repository, https://github.com/hvrcharon1/agent-identity
9
+ Project-URL: Documentation, https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pytest-cov; extra == "dev"
15
+
16
+ # agent-identity Python SDK
17
+
18
+ Pure-Python client for the `agent-identity` sidecar. No Node.js required.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install agent-identity
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from agent_identity import AgentIdentityClient
30
+ from datetime import datetime, timezone
31
+
32
+ client = AgentIdentityClient(base_url="http://localhost:3001")
33
+
34
+ # Resolve a credential for a single agent request
35
+ resolved = client.resolve({
36
+ "userId": "user-abc",
37
+ "resourceId": "crm-db",
38
+ "resourceKind": "shared",
39
+ "provider": "anthropic",
40
+ "model": "claude-sonnet-4-20250514",
41
+ "action": "read",
42
+ "traceId": "trace-xyz",
43
+ "requestedAt": datetime.now(timezone.utc).isoformat(),
44
+ })
45
+ print(resolved["resolvedFor"]) # 'service' or userId
46
+
47
+ # Resolve source + target credentials for a migration phase
48
+ pair = client.resolve_migration({
49
+ "migrationId": "migration-2026-q2",
50
+ "phase": "load",
51
+ "sourceResourceId": "crm-postgres-prod",
52
+ "targetResourceId": "crm-postgres-v2",
53
+ "userId": "svc-migration-bot",
54
+ "provider": "anthropic",
55
+ "model": "claude-sonnet-4-20250514",
56
+ "traceId": "trace-abc123",
57
+ "dryRun": False,
58
+ })
59
+ print(pair["expiresAt"]) # ISO 8601 or None
60
+ ```
61
+
62
+ ## LangChain integration
63
+
64
+ ```python
65
+ from langchain_anthropic import ChatAnthropic
66
+ from agent_identity import AgentIdentityClient
67
+
68
+ client = AgentIdentityClient()
69
+ resolved = client.resolve({...})
70
+
71
+ # resolved["resolvedFor"] is safe to log; the raw API key stays on the server
72
+ llm = ChatAnthropic(model="claude-sonnet-4-20250514")
73
+ # The sidecar injects the API key server-side when you call /api/resolve
74
+ ```
75
+
76
+ ## Error handling
77
+
78
+ ```python
79
+ from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
80
+
81
+ try:
82
+ result = client.resolve(ctx)
83
+ except NoCredentialError:
84
+ # 403 — no routing rule matched this context
85
+ ...
86
+ except ValidationError as e:
87
+ # 400 — bad request body
88
+ print(e)
89
+ ```
@@ -0,0 +1,74 @@
1
+ # agent-identity Python SDK
2
+
3
+ Pure-Python client for the `agent-identity` sidecar. No Node.js required.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agent-identity
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from agent_identity import AgentIdentityClient
15
+ from datetime import datetime, timezone
16
+
17
+ client = AgentIdentityClient(base_url="http://localhost:3001")
18
+
19
+ # Resolve a credential for a single agent request
20
+ resolved = client.resolve({
21
+ "userId": "user-abc",
22
+ "resourceId": "crm-db",
23
+ "resourceKind": "shared",
24
+ "provider": "anthropic",
25
+ "model": "claude-sonnet-4-20250514",
26
+ "action": "read",
27
+ "traceId": "trace-xyz",
28
+ "requestedAt": datetime.now(timezone.utc).isoformat(),
29
+ })
30
+ print(resolved["resolvedFor"]) # 'service' or userId
31
+
32
+ # Resolve source + target credentials for a migration phase
33
+ pair = client.resolve_migration({
34
+ "migrationId": "migration-2026-q2",
35
+ "phase": "load",
36
+ "sourceResourceId": "crm-postgres-prod",
37
+ "targetResourceId": "crm-postgres-v2",
38
+ "userId": "svc-migration-bot",
39
+ "provider": "anthropic",
40
+ "model": "claude-sonnet-4-20250514",
41
+ "traceId": "trace-abc123",
42
+ "dryRun": False,
43
+ })
44
+ print(pair["expiresAt"]) # ISO 8601 or None
45
+ ```
46
+
47
+ ## LangChain integration
48
+
49
+ ```python
50
+ from langchain_anthropic import ChatAnthropic
51
+ from agent_identity import AgentIdentityClient
52
+
53
+ client = AgentIdentityClient()
54
+ resolved = client.resolve({...})
55
+
56
+ # resolved["resolvedFor"] is safe to log; the raw API key stays on the server
57
+ llm = ChatAnthropic(model="claude-sonnet-4-20250514")
58
+ # The sidecar injects the API key server-side when you call /api/resolve
59
+ ```
60
+
61
+ ## Error handling
62
+
63
+ ```python
64
+ from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
65
+
66
+ try:
67
+ result = client.resolve(ctx)
68
+ except NoCredentialError:
69
+ # 403 — no routing rule matched this context
70
+ ...
71
+ except ValidationError as e:
72
+ # 400 — bad request body
73
+ print(e)
74
+ ```
@@ -0,0 +1,213 @@
1
+ """
2
+ agent_identity — Python SDK for the agent-identity credential routing sidecar
3
+
4
+ Installation:
5
+ pip install datacules-agent-identity
6
+ # or directly:
7
+ pip install git+https://github.com/hvrcharon1/agent-identity.git#subdirectory=packages/python-sdk
8
+
9
+ Usage:
10
+ from agent_identity import AgentIdentityClient
11
+ from datetime import datetime, timezone
12
+
13
+ client = AgentIdentityClient(base_url="http://localhost:3001")
14
+
15
+ resolved = client.resolve({
16
+ "userId": "user-abc",
17
+ "resourceId": "crm-db",
18
+ "resourceKind": "shared",
19
+ "provider": "anthropic",
20
+ "model": "claude-sonnet-4-20250514",
21
+ "action": "read",
22
+ "traceId": "trace-xyz",
23
+ "requestedAt": datetime.now(timezone.utc).isoformat(),
24
+ })
25
+
26
+ pair = client.resolve_migration({
27
+ "migrationId": "migration-2026-q2",
28
+ "phase": "load",
29
+ "sourceResourceId": "crm-postgres-prod",
30
+ "targetResourceId": "crm-postgres-v2",
31
+ "userId": "svc-migration-bot",
32
+ "provider": "anthropic",
33
+ "model": "claude-sonnet-4-20250514",
34
+ "traceId": "trace-abc123",
35
+ "dryRun": False,
36
+ })
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from datetime import datetime, timezone
43
+ from typing import Any, Dict, Literal, Optional, TypedDict
44
+
45
+ try:
46
+ import urllib.request as _urllib
47
+ except ImportError: # pragma: no cover
48
+ raise RuntimeError("agent-identity requires Python 3.8+")
49
+
50
+
51
+ # ─── Type aliases ────────────────────────────────────────────────────────────────────
52
+
53
+ ResourceKind = Literal["shared", "personal"]
54
+ SupportedProvider = Literal["openai", "anthropic", "gemini", "mistral", "local"]
55
+ MigrationPhase = Literal["dry-run", "extract", "transform", "load", "verify", "rollback"]
56
+
57
+
58
+ class AgentRequestContext(TypedDict, total=False):
59
+ userId: str # required
60
+ resourceId: str # required
61
+ resourceKind: ResourceKind # required
62
+ provider: SupportedProvider # required
63
+ model: str # required
64
+ action: str # required
65
+ traceId: str # required
66
+ requestedAt: str # required (ISO 8601)
67
+ sessionId: str # optional
68
+ parentTraceId: str # optional
69
+
70
+
71
+ class MigrateResolveRequest(TypedDict, total=False):
72
+ migrationId: str # required
73
+ phase: MigrationPhase # required
74
+ sourceResourceId: str # required
75
+ targetResourceId: str # required
76
+ userId: str # required
77
+ provider: SupportedProvider # required
78
+ model: str # required
79
+ traceId: str # required
80
+ dryRun: bool # optional, default False
81
+ batchIndex: int # optional
82
+ totalBatches: int # optional
83
+
84
+
85
+ class ResolveResponse(TypedDict):
86
+ ok: bool
87
+ resolvedFor: str
88
+ expiresAt: Optional[str]
89
+
90
+
91
+ class MigrateResolveResponse(TypedDict):
92
+ migrationId: str
93
+ phase: MigrationPhase
94
+ sourceResolvedFor: str
95
+ targetResolvedFor: str
96
+ dryRun: bool
97
+ expiresAt: Optional[str]
98
+
99
+
100
+ # ─── Exceptions ───────────────────────────────────────────────────────────────────
101
+
102
+ class AgentIdentityError(Exception):
103
+ """Base exception for all agent-identity SDK errors."""
104
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
105
+ super().__init__(message)
106
+ self.status_code = status_code
107
+
108
+
109
+ class NoCredentialError(AgentIdentityError):
110
+ """Raised when the server returns 403 — no routing rule matched."""
111
+
112
+
113
+ class ValidationError(AgentIdentityError):
114
+ """Raised when the server returns 400 — invalid request body."""
115
+
116
+
117
+ # ─── Client ────────────────────────────────────────────────────────────────────────
118
+
119
+ class AgentIdentityClient:
120
+ """
121
+ Thin HTTP client for the agent-identity sidecar.
122
+ No Node.js required — pure Python 3.8+ stdlib.
123
+
124
+ Args:
125
+ base_url: URL of the running sidecar, e.g. 'http://localhost:3001'
126
+ timeout: Request timeout in seconds (default 10)
127
+ """
128
+
129
+ def __init__(self, base_url: str = "http://localhost:3001", timeout: int = 10) -> None:
130
+ self.base_url = base_url.rstrip("/")
131
+ self.timeout = timeout
132
+
133
+ # ─── Public API ────────────────────────────────────────────────────────────────────
134
+
135
+ def resolve(self, ctx: AgentRequestContext) -> ResolveResponse:
136
+ """
137
+ Resolve the best credential for a single agent request.
138
+
139
+ Args:
140
+ ctx: AgentRequestContext dict — all required fields must be present.
141
+ If 'requestedAt' is omitted, the current UTC time is used.
142
+
143
+ Returns:
144
+ ResolveResponse with resolvedFor and optional expiresAt.
145
+
146
+ Raises:
147
+ ValidationError: Server returned 400 (missing/invalid field).
148
+ NoCredentialError: Server returned 403 (no rule matched).
149
+ AgentIdentityError: Any other HTTP error.
150
+ """
151
+ if "requestedAt" not in ctx:
152
+ ctx = {**ctx, "requestedAt": datetime.now(timezone.utc).isoformat()} # type: ignore[assignment]
153
+ return self._post("/api/resolve", ctx) # type: ignore[return-value]
154
+
155
+ def resolve_migration(
156
+ self, request: MigrateResolveRequest
157
+ ) -> MigrateResolveResponse:
158
+ """
159
+ Resolve source + target credentials for a migration phase.
160
+
161
+ Call once at the start of each phase. The returned expiresAt lets
162
+ the agent decide when to re-call before the batch loop ends.
163
+
164
+ Args:
165
+ request: MigrateResolveRequest dict.
166
+
167
+ Returns:
168
+ MigrateResolveResponse with sourceResolvedFor, targetResolvedFor,
169
+ and optional expiresAt (earliest of the two credentials).
170
+ """
171
+ if "dryRun" not in request:
172
+ request = {**request, "dryRun": False} # type: ignore[assignment]
173
+ return self._post("/api/migrate/resolve", request) # type: ignore[return-value]
174
+
175
+ def health(self) -> bool:
176
+ """Returns True if the sidecar is reachable and healthy."""
177
+ try:
178
+ self._post("/api/health", {}, method="GET")
179
+ return True
180
+ except AgentIdentityError:
181
+ return False
182
+ except Exception: # noqa: BLE001
183
+ return False
184
+
185
+ # ─── Internal ──────────────────────────────────────────────────────────────────────
186
+
187
+ def _post(self, path: str, body: Dict[str, Any], method: str = "POST") -> Any:
188
+ url = f"{self.base_url}{path}"
189
+ data = json.dumps(body).encode("utf-8")
190
+ req = _urllib.Request(
191
+ url,
192
+ data=data if method == "POST" else None,
193
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
194
+ method=method,
195
+ )
196
+ try:
197
+ with _urllib.urlopen(req, timeout=self.timeout) as resp:
198
+ return json.loads(resp.read().decode("utf-8"))
199
+ except _urllib.HTTPError as e:
200
+ status = e.code
201
+ try:
202
+ error_body = json.loads(e.read().decode("utf-8"))
203
+ message = error_body.get("error", str(e))
204
+ except Exception: # noqa: BLE001
205
+ message = str(e)
206
+
207
+ if status == 400:
208
+ raise ValidationError(message, status_code=status) from e
209
+ if status == 403:
210
+ raise NoCredentialError(message, status_code=status) from e
211
+ raise AgentIdentityError(message, status_code=status) from e
212
+ except Exception as e:
213
+ raise AgentIdentityError(f"Request failed: {e}") from e
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: datacules-agent-identity
3
+ Version: 0.3.3
4
+ Summary: Python SDK for the agent-identity credential routing sidecar — by Datacules LLC
5
+ Author-email: Datacules LLC <harshalrasal792@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hvrcharon1/agent-identity
8
+ Project-URL: Repository, https://github.com/hvrcharon1/agent-identity
9
+ Project-URL: Documentation, https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pytest-cov; extra == "dev"
15
+
16
+ # agent-identity Python SDK
17
+
18
+ Pure-Python client for the `agent-identity` sidecar. No Node.js required.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install agent-identity
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from agent_identity import AgentIdentityClient
30
+ from datetime import datetime, timezone
31
+
32
+ client = AgentIdentityClient(base_url="http://localhost:3001")
33
+
34
+ # Resolve a credential for a single agent request
35
+ resolved = client.resolve({
36
+ "userId": "user-abc",
37
+ "resourceId": "crm-db",
38
+ "resourceKind": "shared",
39
+ "provider": "anthropic",
40
+ "model": "claude-sonnet-4-20250514",
41
+ "action": "read",
42
+ "traceId": "trace-xyz",
43
+ "requestedAt": datetime.now(timezone.utc).isoformat(),
44
+ })
45
+ print(resolved["resolvedFor"]) # 'service' or userId
46
+
47
+ # Resolve source + target credentials for a migration phase
48
+ pair = client.resolve_migration({
49
+ "migrationId": "migration-2026-q2",
50
+ "phase": "load",
51
+ "sourceResourceId": "crm-postgres-prod",
52
+ "targetResourceId": "crm-postgres-v2",
53
+ "userId": "svc-migration-bot",
54
+ "provider": "anthropic",
55
+ "model": "claude-sonnet-4-20250514",
56
+ "traceId": "trace-abc123",
57
+ "dryRun": False,
58
+ })
59
+ print(pair["expiresAt"]) # ISO 8601 or None
60
+ ```
61
+
62
+ ## LangChain integration
63
+
64
+ ```python
65
+ from langchain_anthropic import ChatAnthropic
66
+ from agent_identity import AgentIdentityClient
67
+
68
+ client = AgentIdentityClient()
69
+ resolved = client.resolve({...})
70
+
71
+ # resolved["resolvedFor"] is safe to log; the raw API key stays on the server
72
+ llm = ChatAnthropic(model="claude-sonnet-4-20250514")
73
+ # The sidecar injects the API key server-side when you call /api/resolve
74
+ ```
75
+
76
+ ## Error handling
77
+
78
+ ```python
79
+ from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
80
+
81
+ try:
82
+ result = client.resolve(ctx)
83
+ except NoCredentialError:
84
+ # 403 — no routing rule matched this context
85
+ ...
86
+ except ValidationError as e:
87
+ # 400 — bad request body
88
+ print(e)
89
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ agent_identity/__init__.py
4
+ datacules_agent_identity.egg-info/PKG-INFO
5
+ datacules_agent_identity.egg-info/SOURCES.txt
6
+ datacules_agent_identity.egg-info/dependency_links.txt
7
+ datacules_agent_identity.egg-info/requires.txt
8
+ datacules_agent_identity.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "datacules-agent-identity"
7
+ version = "0.3.3"
8
+ description = "Python SDK for the agent-identity credential routing sidecar — by Datacules LLC"
9
+ authors = [{name = "Datacules LLC", email = "harshalrasal792@gmail.com"}]
10
+ license = {text = "MIT"}
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+ # Zero runtime dependencies — pure Python stdlib
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest", "pytest-cov"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/hvrcharon1/agent-identity"
21
+ Repository = "https://github.com/hvrcharon1/agent-identity"
22
+ Documentation = "https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["."]
26
+ include = ["agent_identity*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,261 @@
1
+ """
2
+ pytest suite for agent_identity.AgentIdentityClient.
3
+
4
+ All HTTP calls are intercepted via unittest.mock so no live server is needed.
5
+ The SDK imports urllib.request as _urllib (a module-level alias), so the
6
+ correct patch target is urllib.request.urlopen — patching the real module
7
+ in-place so every call through _urllib is intercepted regardless of alias.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import unittest
14
+ from io import BytesIO
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ from agent_identity import (
18
+ AgentIdentityClient,
19
+ AgentIdentityError,
20
+ NoCredentialError,
21
+ ValidationError,
22
+ )
23
+
24
+
25
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
26
+
27
+ def _make_response(body: dict, status: int = 200) -> MagicMock:
28
+ """Build a fake urllib response context-manager."""
29
+ raw = json.dumps(body).encode()
30
+ mock_resp = MagicMock()
31
+ mock_resp.read.return_value = raw
32
+ mock_resp.status = status
33
+ mock_resp.__enter__ = lambda s: s
34
+ mock_resp.__exit__ = MagicMock(return_value=False)
35
+ return mock_resp
36
+
37
+
38
+ def _make_http_error(body: dict, code: int):
39
+ """Build a fake urllib.error.HTTPError."""
40
+ import urllib.error
41
+ raw = json.dumps(body).encode()
42
+ err = urllib.error.HTTPError(
43
+ url="http://localhost:3001/api/resolve",
44
+ code=code,
45
+ msg="Error",
46
+ hdrs=None, # type: ignore[arg-type]
47
+ fp=BytesIO(raw),
48
+ )
49
+ return err
50
+
51
+
52
+ MINIMAL_CTX = {
53
+ "userId": "user-1",
54
+ "resourceId": "kb-1",
55
+ "resourceKind": "personal",
56
+ "provider": "anthropic",
57
+ "model": "claude-sonnet-4-20250514",
58
+ "action": "read",
59
+ "traceId": "trace-001",
60
+ "requestedAt": "2026-05-27T00:00:00+00:00",
61
+ }
62
+
63
+ MINIMAL_MIGRATION = {
64
+ "migrationId": "mig-001",
65
+ "phase": "load",
66
+ "sourceResourceId": "pg-v1",
67
+ "targetResourceId": "pg-v2",
68
+ "userId": "svc-bot",
69
+ "provider": "anthropic",
70
+ "model": "claude-sonnet-4-20250514",
71
+ "traceId": "trace-mig-001",
72
+ "dryRun": False,
73
+ }
74
+
75
+
76
+ # ─── Tests ───────────────────────────────────────────────────────────────────
77
+
78
+ class TestAgentIdentityClientInit(unittest.TestCase):
79
+ def test_trailing_slash_stripped(self):
80
+ client = AgentIdentityClient(base_url="http://localhost:3001/")
81
+ self.assertEqual(client.base_url, "http://localhost:3001")
82
+
83
+ def test_default_base_url(self):
84
+ client = AgentIdentityClient()
85
+ self.assertEqual(client.base_url, "http://localhost:3001")
86
+
87
+ def test_custom_timeout(self):
88
+ client = AgentIdentityClient(timeout=30)
89
+ self.assertEqual(client.timeout, 30)
90
+
91
+
92
+ class TestResolve(unittest.TestCase):
93
+ def setUp(self):
94
+ self.client = AgentIdentityClient()
95
+
96
+ @patch("urllib.request.urlopen")
97
+ def test_resolve_success(self, mock_urlopen):
98
+ expected = {"ok": True, "resolvedFor": "user-1", "expiresAt": None}
99
+ mock_urlopen.return_value = _make_response(expected)
100
+
101
+ result = self.client.resolve(MINIMAL_CTX)
102
+
103
+ self.assertEqual(result["resolvedFor"], "user-1")
104
+ self.assertTrue(result["ok"])
105
+ mock_urlopen.assert_called_once()
106
+
107
+ @patch("urllib.request.urlopen")
108
+ def test_resolve_auto_injects_requested_at(self, mock_urlopen):
109
+ """resolve() must add requestedAt when the caller omits it."""
110
+ ctx_without_ts = {k: v for k, v in MINIMAL_CTX.items() if k != "requestedAt"}
111
+ expected = {"ok": True, "resolvedFor": "user-1", "expiresAt": None}
112
+ mock_urlopen.return_value = _make_response(expected)
113
+
114
+ self.client.resolve(ctx_without_ts) # type: ignore[arg-type]
115
+
116
+ call_args = mock_urlopen.call_args
117
+ request_obj = call_args[0][0]
118
+ sent_body = json.loads(request_obj.data.decode())
119
+ self.assertIn("requestedAt", sent_body)
120
+
121
+ @patch("urllib.request.urlopen")
122
+ def test_resolve_no_credential_raises(self, mock_urlopen):
123
+ mock_urlopen.side_effect = _make_http_error({"error": "No credential resolved"}, 403)
124
+
125
+ with self.assertRaises(NoCredentialError) as cm:
126
+ self.client.resolve(MINIMAL_CTX)
127
+
128
+ self.assertEqual(cm.exception.status_code, 403)
129
+ self.assertIn("No credential resolved", str(cm.exception))
130
+
131
+ @patch("urllib.request.urlopen")
132
+ def test_resolve_validation_error_raises(self, mock_urlopen):
133
+ mock_urlopen.side_effect = _make_http_error({"error": "Missing required field: userId"}, 400)
134
+
135
+ with self.assertRaises(ValidationError) as cm:
136
+ self.client.resolve(MINIMAL_CTX)
137
+
138
+ self.assertEqual(cm.exception.status_code, 400)
139
+
140
+ @patch("urllib.request.urlopen")
141
+ def test_resolve_server_error_raises(self, mock_urlopen):
142
+ mock_urlopen.side_effect = _make_http_error({"error": "Internal server error"}, 500)
143
+
144
+ with self.assertRaises(AgentIdentityError) as cm:
145
+ self.client.resolve(MINIMAL_CTX)
146
+
147
+ self.assertEqual(cm.exception.status_code, 500)
148
+
149
+ @patch("urllib.request.urlopen")
150
+ def test_resolve_network_failure_raises(self, mock_urlopen):
151
+ import urllib.error
152
+ mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
153
+
154
+ with self.assertRaises(AgentIdentityError) as cm:
155
+ self.client.resolve(MINIMAL_CTX)
156
+
157
+ self.assertIsNone(cm.exception.status_code)
158
+
159
+ @patch("urllib.request.urlopen")
160
+ def test_resolve_returns_expires_at(self, mock_urlopen):
161
+ expected = {
162
+ "ok": True,
163
+ "resolvedFor": "user-1",
164
+ "expiresAt": "2026-05-27T01:00:00+00:00",
165
+ }
166
+ mock_urlopen.return_value = _make_response(expected)
167
+
168
+ result = self.client.resolve(MINIMAL_CTX)
169
+
170
+ self.assertEqual(result["expiresAt"], "2026-05-27T01:00:00+00:00")
171
+
172
+
173
+ class TestResolveMigration(unittest.TestCase):
174
+ def setUp(self):
175
+ self.client = AgentIdentityClient()
176
+
177
+ @patch("urllib.request.urlopen")
178
+ def test_resolve_migration_success(self, mock_urlopen):
179
+ expected = {
180
+ "migrationId": "mig-001",
181
+ "phase": "load",
182
+ "sourceResolvedFor": "service",
183
+ "targetResolvedFor": "service",
184
+ "dryRun": False,
185
+ "expiresAt": None,
186
+ }
187
+ mock_urlopen.return_value = _make_response(expected)
188
+
189
+ result = self.client.resolve_migration(MINIMAL_MIGRATION)
190
+
191
+ self.assertEqual(result["migrationId"], "mig-001")
192
+ self.assertEqual(result["sourceResolvedFor"], "service")
193
+ mock_urlopen.assert_called_once()
194
+
195
+ @patch("urllib.request.urlopen")
196
+ def test_resolve_migration_injects_dry_run_false(self, mock_urlopen):
197
+ req_without_dry_run = {k: v for k, v in MINIMAL_MIGRATION.items() if k != "dryRun"}
198
+ expected = {
199
+ "migrationId": "mig-001",
200
+ "phase": "load",
201
+ "sourceResolvedFor": "service",
202
+ "targetResolvedFor": "service",
203
+ "dryRun": False,
204
+ "expiresAt": None,
205
+ }
206
+ mock_urlopen.return_value = _make_response(expected)
207
+
208
+ self.client.resolve_migration(req_without_dry_run) # type: ignore[arg-type]
209
+
210
+ call_args = mock_urlopen.call_args
211
+ request_obj = call_args[0][0]
212
+ sent_body = json.loads(request_obj.data.decode())
213
+ self.assertFalse(sent_body["dryRun"])
214
+
215
+ @patch("urllib.request.urlopen")
216
+ def test_resolve_migration_no_credential_raises(self, mock_urlopen):
217
+ mock_urlopen.side_effect = _make_http_error({"error": "No credential resolved"}, 403)
218
+
219
+ with self.assertRaises(NoCredentialError):
220
+ self.client.resolve_migration(MINIMAL_MIGRATION)
221
+
222
+
223
+ class TestHealth(unittest.TestCase):
224
+ def setUp(self):
225
+ self.client = AgentIdentityClient()
226
+
227
+ @patch("urllib.request.urlopen")
228
+ def test_health_returns_true_on_200(self, mock_urlopen):
229
+ mock_urlopen.return_value = _make_response({"ok": True})
230
+ self.assertTrue(self.client.health())
231
+
232
+ @patch("urllib.request.urlopen")
233
+ def test_health_returns_false_on_connection_error(self, mock_urlopen):
234
+ import urllib.error
235
+ mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
236
+ self.assertFalse(self.client.health())
237
+
238
+ @patch("urllib.request.urlopen")
239
+ def test_health_returns_false_on_500(self, mock_urlopen):
240
+ mock_urlopen.side_effect = _make_http_error({"error": "Internal error"}, 500)
241
+ self.assertFalse(self.client.health())
242
+
243
+
244
+ class TestExceptions(unittest.TestCase):
245
+ def test_no_credential_error_is_agent_identity_error(self):
246
+ err = NoCredentialError("No match", status_code=403)
247
+ self.assertIsInstance(err, AgentIdentityError)
248
+ self.assertEqual(err.status_code, 403)
249
+
250
+ def test_validation_error_is_agent_identity_error(self):
251
+ err = ValidationError("Bad input", status_code=400)
252
+ self.assertIsInstance(err, AgentIdentityError)
253
+ self.assertEqual(err.status_code, 400)
254
+
255
+ def test_base_error_status_code_none(self):
256
+ err = AgentIdentityError("Network failure")
257
+ self.assertIsNone(err.status_code)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ unittest.main()