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 +22 -0
- sudhanva-0.1.0/PKG-INFO +86 -0
- sudhanva-0.1.0/README.md +63 -0
- sudhanva-0.1.0/pyproject.toml +35 -0
- sudhanva-0.1.0/setup.cfg +4 -0
- sudhanva-0.1.0/src/sudhanva/__init__.py +7 -0
- sudhanva-0.1.0/src/sudhanva/client.py +204 -0
- sudhanva-0.1.0/src/sudhanva/py.typed +1 -0
- sudhanva-0.1.0/src/sudhanva.egg-info/PKG-INFO +86 -0
- sudhanva-0.1.0/src/sudhanva.egg-info/SOURCES.txt +11 -0
- sudhanva-0.1.0/src/sudhanva.egg-info/dependency_links.txt +1 -0
- sudhanva-0.1.0/src/sudhanva.egg-info/top_level.txt +1 -0
- sudhanva-0.1.0/tests/test_client.py +92 -0
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
|
+
|
sudhanva-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
sudhanva-0.1.0/README.md
ADDED
|
@@ -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
|
+
|
sudhanva-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+
|
|
@@ -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
|
+
|