sudhanva 0.1.0__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.
sudhanva-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sudhanva Narayana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: sudhanva
3
+ Version: 0.1.0
4
+ Summary: Minimal Python client for the public sudhanva.me API.
5
+ Author-email: Sudhanva Narayana <nsudhanva@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sudhanva.me
8
+ Project-URL: Documentation, https://sudhanva.me/developers/sdks/
9
+ Project-URL: Source, https://github.com/nsudhanva/sudhanva-python
10
+ Project-URL: Issues, https://github.com/nsudhanva/sudhanva-python/issues
11
+ Project-URL: OpenAPI, https://sudhanva.me/openapi.json
12
+ Keywords: sudhanva,api,sdk,agents,machine-learning
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # sudhanva for Python
25
+
26
+ Minimal, dependency-free Python client for the public
27
+ [sudhanva.me API](https://sudhanva.me/openapi.json). It retrieves published profile and article
28
+ metadata, performs bounded batch reads, searches the published site, and creates or polls temporary
29
+ profile-insight jobs.
30
+
31
+ The API is public and requires no credentials. Do not send private data.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ python -m pip install sudhanva
37
+ ```
38
+
39
+ ## Use
40
+
41
+ ```python
42
+ from sudhanva import Client
43
+
44
+ client = Client()
45
+
46
+ profile = client.profile()
47
+ posts = client.posts(limit=5, tag="kubernetes")
48
+ article = client.post("making-your-site-agent-friendly")
49
+
50
+ job = client.create_profile_insight(
51
+ audience="hiring-manager",
52
+ focus=["production-ml", "inference"],
53
+ idempotency_key="my-workflow-2026-08-23",
54
+ )
55
+ result = client.wait_for_profile_insight(job["job_id"])
56
+ ```
57
+
58
+ All methods return decoded JSON dictionaries. Non-success responses raise `sudhanva.APIError` with
59
+ `status`, `code`, `message`, and the decoded response body.
60
+
61
+ ## API coverage
62
+
63
+ - `profile()`
64
+ - `posts()` and `post()`
65
+ - `batch()`
66
+ - `create_profile_insight()`, `profile_insight()`, and `wait_for_profile_insight()`
67
+ - `ask()` for NLWeb conversational search
68
+
69
+ The client follows the stable `/api/v1` contract. See the
70
+ [developer documentation](https://sudhanva.me/developers/sdks/) and
71
+ [versioning policy](https://sudhanva.me/developers/versioning/).
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ python -m pip install -e .
77
+ python -m unittest discover -s tests -v
78
+ python -m build
79
+ ```
80
+
81
+ The test suite uses an injected transport and never calls production.
82
+
83
+ ## License
84
+
85
+ MIT
86
+
@@ -0,0 +1,63 @@
1
+ # sudhanva for Python
2
+
3
+ Minimal, dependency-free Python client for the public
4
+ [sudhanva.me API](https://sudhanva.me/openapi.json). It retrieves published profile and article
5
+ metadata, performs bounded batch reads, searches the published site, and creates or polls temporary
6
+ profile-insight jobs.
7
+
8
+ The API is public and requires no credentials. Do not send private data.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ python -m pip install sudhanva
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```python
19
+ from sudhanva import Client
20
+
21
+ client = Client()
22
+
23
+ profile = client.profile()
24
+ posts = client.posts(limit=5, tag="kubernetes")
25
+ article = client.post("making-your-site-agent-friendly")
26
+
27
+ job = client.create_profile_insight(
28
+ audience="hiring-manager",
29
+ focus=["production-ml", "inference"],
30
+ idempotency_key="my-workflow-2026-08-23",
31
+ )
32
+ result = client.wait_for_profile_insight(job["job_id"])
33
+ ```
34
+
35
+ All methods return decoded JSON dictionaries. Non-success responses raise `sudhanva.APIError` with
36
+ `status`, `code`, `message`, and the decoded response body.
37
+
38
+ ## API coverage
39
+
40
+ - `profile()`
41
+ - `posts()` and `post()`
42
+ - `batch()`
43
+ - `create_profile_insight()`, `profile_insight()`, and `wait_for_profile_insight()`
44
+ - `ask()` for NLWeb conversational search
45
+
46
+ The client follows the stable `/api/v1` contract. See the
47
+ [developer documentation](https://sudhanva.me/developers/sdks/) and
48
+ [versioning policy](https://sudhanva.me/developers/versioning/).
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ python -m pip install -e .
54
+ python -m unittest discover -s tests -v
55
+ python -m build
56
+ ```
57
+
58
+ The test suite uses an injected transport and never calls production.
59
+
60
+ ## License
61
+
62
+ MIT
63
+
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sudhanva"
7
+ version = "0.1.0"
8
+ description = "Minimal Python client for the public sudhanva.me API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Sudhanva Narayana", email = "nsudhanva@gmail.com" }]
13
+ keywords = ["sudhanva", "api", "sdk", "agents", "machine-learning"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://sudhanva.me"
25
+ Documentation = "https://sudhanva.me/developers/sdks/"
26
+ Source = "https://github.com/nsudhanva/sudhanva-python"
27
+ Issues = "https://github.com/nsudhanva/sudhanva-python/issues"
28
+ OpenAPI = "https://sudhanva.me/openapi.json"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ sudhanva = ["py.typed"]
35
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Python client for the public sudhanva.me API."""
2
+
3
+ from .client import APIError, Client
4
+
5
+ __all__ = ["APIError", "Client"]
6
+ __version__ = "0.1.0"
7
+
@@ -0,0 +1,204 @@
1
+ """Dependency-free client for the public sudhanva.me API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any, Callable, Mapping, Optional, Sequence
12
+
13
+ DEFAULT_BASE_URL = "https://sudhanva.me/api/v1"
14
+ USER_AGENT = "sudhanva-python/0.1.0"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Response:
19
+ status: int
20
+ headers: Mapping[str, str]
21
+ body: bytes
22
+
23
+
24
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes], float], Response]
25
+
26
+
27
+ class APIError(RuntimeError):
28
+ """An error response returned by the sudhanva.me API."""
29
+
30
+ def __init__(self, status: int, code: str, message: str, body: Any) -> None:
31
+ super().__init__(f"{status} {code}: {message}")
32
+ self.status = status
33
+ self.code = code
34
+ self.message = message
35
+ self.body = body
36
+
37
+
38
+ class Client:
39
+ """Small synchronous client for every stable public API operation."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ base_url: str = DEFAULT_BASE_URL,
45
+ timeout: float = 10.0,
46
+ transport: Optional[Transport] = None,
47
+ ) -> None:
48
+ if timeout <= 0:
49
+ raise ValueError("timeout must be positive")
50
+ self.base_url = base_url.rstrip("/")
51
+ parsed = urllib.parse.urlsplit(self.base_url)
52
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
53
+ raise ValueError("base_url must be an absolute HTTP(S) URL")
54
+ self.site_url = f"{parsed.scheme}://{parsed.netloc}"
55
+ self.timeout = timeout
56
+ self._transport = transport or self._default_transport
57
+
58
+ def profile(self, *, locale: str = "en") -> dict[str, Any]:
59
+ return self._request("GET", "/profile", query={"locale": locale})
60
+
61
+ def posts(
62
+ self,
63
+ *,
64
+ limit: int = 20,
65
+ tag: Optional[str] = None,
66
+ cursor: Optional[str] = None,
67
+ ) -> dict[str, Any]:
68
+ if not 1 <= limit <= 100:
69
+ raise ValueError("limit must be between 1 and 100")
70
+ return self._request(
71
+ "GET",
72
+ "/posts",
73
+ query={"limit": limit, "tag": tag, "cursor": cursor},
74
+ )
75
+
76
+ def post(self, slug: str) -> dict[str, Any]:
77
+ if not slug:
78
+ raise ValueError("slug is required")
79
+ return self._request("GET", f"/posts/{urllib.parse.quote(slug, safe='')}")
80
+
81
+ def batch(self, operations: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
82
+ if not 1 <= len(operations) <= 20:
83
+ raise ValueError("operations must contain between 1 and 20 items")
84
+ return self._request("POST", "/batch", json_body={"operations": list(operations)})
85
+
86
+ def create_profile_insight(
87
+ self,
88
+ *,
89
+ audience: str,
90
+ idempotency_key: str,
91
+ focus: Optional[Sequence[str]] = None,
92
+ ) -> dict[str, Any]:
93
+ if not idempotency_key:
94
+ raise ValueError("idempotency_key is required")
95
+ body: dict[str, Any] = {"audience": audience}
96
+ if focus is not None:
97
+ body["focus"] = list(focus)
98
+ return self._request(
99
+ "POST",
100
+ "/profile-insights",
101
+ headers={"Idempotency-Key": idempotency_key},
102
+ json_body=body,
103
+ )
104
+
105
+ def profile_insight(self, job_id: str) -> dict[str, Any]:
106
+ if not job_id:
107
+ raise ValueError("job_id is required")
108
+ return self._request(
109
+ "GET", f"/profile-insights/{urllib.parse.quote(job_id, safe='')}"
110
+ )
111
+
112
+ def wait_for_profile_insight(
113
+ self,
114
+ job_id: str,
115
+ *,
116
+ timeout: float = 30.0,
117
+ poll_interval: float = 1.0,
118
+ ) -> dict[str, Any]:
119
+ if timeout <= 0 or poll_interval < 0:
120
+ raise ValueError("timeout must be positive and poll_interval cannot be negative")
121
+ deadline = time.monotonic() + timeout
122
+ while True:
123
+ job = self.profile_insight(job_id)
124
+ if job.get("status") in {"succeeded", "failed"}:
125
+ return job
126
+ if time.monotonic() >= deadline:
127
+ raise TimeoutError(f"profile insight {job_id} did not finish within {timeout}s")
128
+ time.sleep(poll_interval)
129
+
130
+ def ask(
131
+ self,
132
+ text: str,
133
+ *,
134
+ limit: int = 10,
135
+ mode: str = "list",
136
+ ) -> dict[str, Any]:
137
+ if not text:
138
+ raise ValueError("text is required")
139
+ if not 1 <= limit <= 20:
140
+ raise ValueError("limit must be between 1 and 20")
141
+ return self._request(
142
+ "POST",
143
+ "/ask",
144
+ absolute_base=self.site_url,
145
+ json_body={
146
+ "query": {"text": text, "site": self.site_url, "limit": limit},
147
+ "prefer": {"streaming": False, "response_format": "conversational_search", "mode": mode},
148
+ "meta": {"version": "0.55"},
149
+ },
150
+ )
151
+
152
+ def _request(
153
+ self,
154
+ method: str,
155
+ path: str,
156
+ *,
157
+ query: Optional[Mapping[str, Any]] = None,
158
+ headers: Optional[Mapping[str, str]] = None,
159
+ json_body: Optional[Mapping[str, Any]] = None,
160
+ absolute_base: Optional[str] = None,
161
+ ) -> dict[str, Any]:
162
+ pairs = [] if query is None else [(key, value) for key, value in query.items() if value is not None]
163
+ query_string = urllib.parse.urlencode(pairs)
164
+ url = f"{(absolute_base or self.base_url).rstrip('/')}/{path.lstrip('/')}"
165
+ if query_string:
166
+ url = f"{url}?{query_string}"
167
+
168
+ request_headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
169
+ request_headers.update(headers or {})
170
+ body = None
171
+ if json_body is not None:
172
+ body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
173
+ request_headers["Content-Type"] = "application/json"
174
+
175
+ response = self._transport(method, url, request_headers, body, self.timeout)
176
+ try:
177
+ payload = json.loads(response.body.decode("utf-8")) if response.body else {}
178
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
179
+ raise APIError(response.status, "invalid_response", "API returned invalid JSON", None) from exc
180
+
181
+ if not 200 <= response.status < 300:
182
+ error = payload.get("error", payload) if isinstance(payload, dict) else {}
183
+ code = str(error.get("code", payload.get("code", "api_error")))
184
+ message = str(error.get("message", payload.get("message", "Request failed")))
185
+ raise APIError(response.status, code, message, payload)
186
+ if not isinstance(payload, dict):
187
+ raise APIError(response.status, "invalid_response", "API returned a non-object response", payload)
188
+ return payload
189
+
190
+ @staticmethod
191
+ def _default_transport(
192
+ method: str,
193
+ url: str,
194
+ headers: Mapping[str, str],
195
+ body: Optional[bytes],
196
+ timeout: float,
197
+ ) -> Response:
198
+ request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
199
+ try:
200
+ with urllib.request.urlopen(request, timeout=timeout) as result:
201
+ return Response(result.status, dict(result.headers.items()), result.read())
202
+ except urllib.error.HTTPError as error:
203
+ return Response(error.code, dict(error.headers.items()), error.read())
204
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: sudhanva
3
+ Version: 0.1.0
4
+ Summary: Minimal Python client for the public sudhanva.me API.
5
+ Author-email: Sudhanva Narayana <nsudhanva@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sudhanva.me
8
+ Project-URL: Documentation, https://sudhanva.me/developers/sdks/
9
+ Project-URL: Source, https://github.com/nsudhanva/sudhanva-python
10
+ Project-URL: Issues, https://github.com/nsudhanva/sudhanva-python/issues
11
+ Project-URL: OpenAPI, https://sudhanva.me/openapi.json
12
+ Keywords: sudhanva,api,sdk,agents,machine-learning
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # sudhanva for Python
25
+
26
+ Minimal, dependency-free Python client for the public
27
+ [sudhanva.me API](https://sudhanva.me/openapi.json). It retrieves published profile and article
28
+ metadata, performs bounded batch reads, searches the published site, and creates or polls temporary
29
+ profile-insight jobs.
30
+
31
+ The API is public and requires no credentials. Do not send private data.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ python -m pip install sudhanva
37
+ ```
38
+
39
+ ## Use
40
+
41
+ ```python
42
+ from sudhanva import Client
43
+
44
+ client = Client()
45
+
46
+ profile = client.profile()
47
+ posts = client.posts(limit=5, tag="kubernetes")
48
+ article = client.post("making-your-site-agent-friendly")
49
+
50
+ job = client.create_profile_insight(
51
+ audience="hiring-manager",
52
+ focus=["production-ml", "inference"],
53
+ idempotency_key="my-workflow-2026-08-23",
54
+ )
55
+ result = client.wait_for_profile_insight(job["job_id"])
56
+ ```
57
+
58
+ All methods return decoded JSON dictionaries. Non-success responses raise `sudhanva.APIError` with
59
+ `status`, `code`, `message`, and the decoded response body.
60
+
61
+ ## API coverage
62
+
63
+ - `profile()`
64
+ - `posts()` and `post()`
65
+ - `batch()`
66
+ - `create_profile_insight()`, `profile_insight()`, and `wait_for_profile_insight()`
67
+ - `ask()` for NLWeb conversational search
68
+
69
+ The client follows the stable `/api/v1` contract. See the
70
+ [developer documentation](https://sudhanva.me/developers/sdks/) and
71
+ [versioning policy](https://sudhanva.me/developers/versioning/).
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ python -m pip install -e .
77
+ python -m unittest discover -s tests -v
78
+ python -m build
79
+ ```
80
+
81
+ The test suite uses an injected transport and never calls production.
82
+
83
+ ## License
84
+
85
+ MIT
86
+
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sudhanva/__init__.py
5
+ src/sudhanva/client.py
6
+ src/sudhanva/py.typed
7
+ src/sudhanva.egg-info/PKG-INFO
8
+ src/sudhanva.egg-info/SOURCES.txt
9
+ src/sudhanva.egg-info/dependency_links.txt
10
+ src/sudhanva.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ sudhanva
@@ -0,0 +1,92 @@
1
+ import json
2
+ import unittest
3
+
4
+ from sudhanva import APIError, Client
5
+ from sudhanva.client import Response
6
+
7
+
8
+ class FakeTransport:
9
+ def __init__(self, responses):
10
+ self.responses = list(responses)
11
+ self.requests = []
12
+
13
+ def __call__(self, method, url, headers, body, timeout):
14
+ self.requests.append((method, url, headers, body, timeout))
15
+ return self.responses.pop(0)
16
+
17
+
18
+ def response(status, payload):
19
+ return Response(status, {"Content-Type": "application/json"}, json.dumps(payload).encode())
20
+
21
+
22
+ class ClientTest(unittest.TestCase):
23
+ def test_posts_encodes_filters_and_identifies_client(self):
24
+ transport = FakeTransport([response(200, {"posts": []})])
25
+ client = Client(base_url="https://example.test/api/v1", transport=transport)
26
+
27
+ self.assertEqual(client.posts(limit=5, tag="machine-learning"), {"posts": []})
28
+
29
+ method, url, headers, body, timeout = transport.requests[0]
30
+ self.assertEqual(method, "GET")
31
+ self.assertEqual(url, "https://example.test/api/v1/posts?limit=5&tag=machine-learning")
32
+ self.assertEqual(headers["User-Agent"], "sudhanva-python/0.1.0")
33
+ self.assertIsNone(body)
34
+ self.assertEqual(timeout, 10.0)
35
+
36
+ def test_profile_insight_sends_idempotency_key(self):
37
+ transport = FakeTransport([response(202, {"job_id": "pi_1", "status": "queued"})])
38
+ client = Client(transport=transport)
39
+
40
+ client.create_profile_insight(
41
+ audience="agent",
42
+ focus=["production-ml"],
43
+ idempotency_key="python-test-123",
44
+ )
45
+
46
+ method, _, headers, body, _ = transport.requests[0]
47
+ self.assertEqual(method, "POST")
48
+ self.assertEqual(headers["Idempotency-Key"], "python-test-123")
49
+ self.assertEqual(
50
+ json.loads(body),
51
+ {"audience": "agent", "focus": ["production-ml"]},
52
+ )
53
+
54
+ def test_wait_stops_at_terminal_state(self):
55
+ transport = FakeTransport([
56
+ response(200, {"status": "running"}),
57
+ response(200, {"status": "succeeded", "result": {"summary": "done"}}),
58
+ ])
59
+ client = Client(transport=transport)
60
+
61
+ job = client.wait_for_profile_insight("pi_test", timeout=1, poll_interval=0)
62
+
63
+ self.assertEqual(job["status"], "succeeded")
64
+ self.assertEqual(len(transport.requests), 2)
65
+
66
+ def test_ask_uses_site_root_instead_of_api_base(self):
67
+ transport = FakeTransport([response(200, {"results": []})])
68
+ client = Client(base_url="https://example.test/api/v1", transport=transport)
69
+
70
+ client.ask("Kubernetes", limit=3, mode="summarize")
71
+
72
+ _, url, _, body, _ = transport.requests[0]
73
+ self.assertEqual(url, "https://example.test/ask")
74
+ self.assertEqual(json.loads(body)["query"]["limit"], 3)
75
+ self.assertEqual(json.loads(body)["prefer"]["mode"], "summarize")
76
+
77
+ def test_structured_errors_are_exposed(self):
78
+ transport = FakeTransport([
79
+ response(404, {"error": {"code": "not_found", "message": "Missing"}})
80
+ ])
81
+ client = Client(transport=transport)
82
+
83
+ with self.assertRaises(APIError) as caught:
84
+ client.post("missing")
85
+
86
+ self.assertEqual(caught.exception.status, 404)
87
+ self.assertEqual(caught.exception.code, "not_found")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
92
+