alion 0.1.0__py3-none-any.whl

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.
alion/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Official Python client for Alion: companies, startups, investors, job postings, hiring and pay
2
+ statistics, and people open to offers, for code and AI agents. https://alion.io/developers
3
+
4
+ >>> from alion import Alion
5
+ >>> alion = Alion() # free without a key
6
+ >>> alion.search_companies(industry="Fintech", country="DE", startup=True, limit=10)["items"]
7
+ """
8
+
9
+ from . import _types as types
10
+ from ._client import DEFAULT_BASE_URL, PROTOCOL_VERSION, Alion, AsyncAlion, Response, Transport, urllib_transport
11
+ from ._errors import AlionError
12
+ from ._types import PAGED_TOOLS, READ_ONLY_TOOLS, TOOL_NAMES
13
+ from ._version import __version__
14
+
15
+ __all__ = [
16
+ "Alion",
17
+ "AsyncAlion",
18
+ "AlionError",
19
+ "types",
20
+ "PAGED_TOOLS",
21
+ "READ_ONLY_TOOLS",
22
+ "TOOL_NAMES",
23
+ "PROTOCOL_VERSION",
24
+ "DEFAULT_BASE_URL",
25
+ "Response",
26
+ "Transport",
27
+ "urllib_transport",
28
+ "__version__",
29
+ ]
alion/_client.py ADDED
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import os
7
+ import random
8
+ import socket
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Mapping, Optional, Tuple
13
+
14
+ from ._errors import AlionError
15
+ from ._tools import AsyncTools, SyncTools
16
+ from ._types import PAGED_TOOLS, READ_ONLY_TOOLS
17
+ from ._version import __version__
18
+
19
+ PROTOCOL_VERSION = "2025-11-25"
20
+ """The protocol revision this client speaks (the server also accepts older ones)."""
21
+
22
+ DEFAULT_BASE_URL = "https://alion.io"
23
+
24
+ Response = Tuple[int, Dict[str, str], str]
25
+ """What a transport returns: HTTP status, headers with lower-case names, body text."""
26
+
27
+ Transport = Callable[[str, Dict[str, str], bytes, float], Response]
28
+ """A transport: (url, headers, body, timeout seconds) -> Response. Raise on a network failure."""
29
+
30
+
31
+ def urllib_transport(url: str, headers: Dict[str, str], body: bytes, timeout: float) -> Response:
32
+ """The default transport: the standard library, no dependencies."""
33
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=timeout) as r:
36
+ return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read().decode("utf-8", "replace")
37
+ except urllib.error.HTTPError as e:
38
+ text = e.read().decode("utf-8", "replace") if e.fp is not None else ""
39
+ return e.code, {k.lower(): v for k, v in (e.headers.items() if e.headers else [])}, text
40
+
41
+
42
+ class _Base:
43
+ def __init__(
44
+ self,
45
+ api_key: Optional[str] = None,
46
+ *,
47
+ base_url: Optional[str] = None,
48
+ timeout: float = 60.0,
49
+ max_retries: int = 2,
50
+ app_name: Optional[str] = None,
51
+ headers: Optional[Mapping[str, str]] = None,
52
+ transport: Optional[Transport] = None,
53
+ ) -> None:
54
+ key = os.environ.get("ALION_API_KEY", "") if api_key is None else api_key
55
+ self.api_key: Optional[str] = key or None
56
+ self.base_url: str = (base_url or os.environ.get("ALION_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
57
+ self.endpoint: str = self.base_url + ("/mcp" if self.api_key else "/mcp/open")
58
+ self.timeout = float(timeout)
59
+ self.max_retries = max(0, int(max_retries))
60
+ self._transport: Transport = transport or urllib_transport
61
+ self._user_agent = f"alion-python/{__version__}" + (f" {app_name}" if app_name else "")
62
+ self._extra_headers = dict(headers or {})
63
+ self._next_id = 0
64
+
65
+ def __repr__(self) -> str:
66
+ return f"{type(self).__name__}(endpoint={self.endpoint!r}, key={'yes' if self.api_key else 'no'})"
67
+
68
+ def _build(self, method: str, params: Dict[str, Any]) -> Tuple[Dict[str, str], bytes]:
69
+ self._next_id += 1
70
+ body = json.dumps({"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}).encode("utf-8")
71
+ headers = {
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json, text/event-stream",
74
+ "MCP-Protocol-Version": PROTOCOL_VERSION,
75
+ "User-Agent": self._user_agent,
76
+ }
77
+ headers.update(self._extra_headers)
78
+ if self.api_key:
79
+ headers["Authorization"] = f"Bearer {self.api_key}"
80
+ return headers, body
81
+
82
+ def _parse(self, status: int, headers: Mapping[str, str], text: str) -> Dict[str, Any]:
83
+ data = _parse_body(text, headers.get("content-type", ""))
84
+ if status < 200 or status >= 300:
85
+ raise AlionError._from_http(status, data, headers)
86
+ if not isinstance(data, dict):
87
+ raise AlionError(f"unreadable answer from {self.endpoint} (HTTP {status})", code="bad_response", status=status)
88
+ if isinstance(data.get("error"), dict):
89
+ raise AlionError._from_rpc(data["error"])
90
+ return data
91
+
92
+ def _network_error(self, e: BaseException) -> AlionError:
93
+ if isinstance(e, (socket.timeout, TimeoutError)) or "timed out" in str(e):
94
+ return AlionError(f"no answer from {self.endpoint} in time: {e}", code="timeout")
95
+ return AlionError(f"network error calling {self.endpoint}: {e}", code="network_error")
96
+
97
+ @staticmethod
98
+ def _is_timeout(e: BaseException) -> bool:
99
+ return isinstance(e, (socket.timeout, TimeoutError)) or (isinstance(e, urllib.error.URLError) and isinstance(e.reason, (socket.timeout, TimeoutError)))
100
+
101
+
102
+ class Alion(_Base, SyncTools):
103
+ """The Alion client: every tool of the Alion MCP server as a method.
104
+
105
+ >>> from alion import Alion
106
+ >>> alion = Alion() # no key: the free open address
107
+ >>> alion.search_companies(industry="Fintech", country="DE", startup=True, limit=10)["items"]
108
+
109
+ ``api_key`` defaults to the ALION_API_KEY environment variable; ``Alion(api_key="")`` forces the open address.
110
+ Without a key: compact company, investor and job searches, lookalikes, job postings, the people and agent tools,
111
+ free within a daily allowance per network. With a key (https://alion.io/developers/keys): everything.
112
+ """
113
+
114
+ def call(self, name: str, args: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = None) -> Any:
115
+ """Calls a tool by name. The typed methods (search_companies, get_company, ...) are shortcuts for this."""
116
+ rpc = self._rpc("tools/call", {"name": name, "arguments": _clean(args)}, timeout, name in READ_ONLY_TOOLS)
117
+ return _tool_result(name, rpc)
118
+
119
+ def paginate(self, name: str, *, max_items: Optional[int] = None, timeout: Optional[float] = None, **args: Any) -> Iterator[Dict[str, Any]]:
120
+ """Walks every page of a search, yielding one row at a time (searches stop at row 100 on the server).
121
+
122
+ >>> for job in alion.paginate("search_jobs", role="Data Engineer", country="PL", max_items=50): ...
123
+ """
124
+ if name not in PAGED_TOOLS:
125
+ raise ValueError(f"{name} does not page")
126
+ cursor: Optional[str] = None
127
+ seen = 0
128
+ while True:
129
+ page = self.call(name, {**args, **({"cursor": cursor} if cursor else {})}, timeout=timeout)
130
+ items = page.get("items") or []
131
+ for item in items:
132
+ yield item
133
+ seen += 1
134
+ if max_items is not None and seen >= max_items:
135
+ return
136
+ cursor = page.get("next_cursor")
137
+ if not cursor or page.get("has_more") is False or not items:
138
+ return
139
+
140
+ def list_tools(self, *, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
141
+ """The tools this address offers, with their input and output schemas."""
142
+ tools: List[Dict[str, Any]] = []
143
+ cursor: Optional[str] = None
144
+ while True:
145
+ res = self._rpc("tools/list", {"cursor": cursor} if cursor else {}, timeout, True).get("result") or {}
146
+ tools.extend(res.get("tools") or [])
147
+ cursor = res.get("nextCursor")
148
+ if not cursor:
149
+ return tools
150
+
151
+ def close(self) -> None:
152
+ """Nothing to release (every call is one stateless request); here for the with-statement."""
153
+
154
+ def __enter__(self) -> "Alion":
155
+ return self
156
+
157
+ def __exit__(self, *exc: Any) -> None:
158
+ self.close()
159
+
160
+ def _rpc(self, method: str, params: Dict[str, Any], timeout: Optional[float], retry_5xx: bool) -> Dict[str, Any]:
161
+ headers, body = self._build(method, params)
162
+ attempt = 0
163
+ while True:
164
+ try:
165
+ status, h, text = self._transport(self.endpoint, headers, body, timeout or self.timeout)
166
+ except AlionError:
167
+ raise
168
+ except Exception as e: # network failure
169
+ if self._is_timeout(e):
170
+ raise self._network_error(TimeoutError(str(e))) from e
171
+ if attempt < self.max_retries:
172
+ time.sleep(_backoff(attempt))
173
+ attempt += 1
174
+ continue
175
+ raise self._network_error(e) from e
176
+ if _retryable(status, retry_5xx) and attempt < self.max_retries:
177
+ ra = _retry_after(h)
178
+ time.sleep(ra if ra is not None else _backoff(attempt))
179
+ attempt += 1
180
+ continue
181
+ return self._parse(status, h, text)
182
+
183
+
184
+ class AsyncAlion(_Base, AsyncTools):
185
+ """The async client: the same methods as :class:`Alion`, awaited.
186
+
187
+ >>> from alion import AsyncAlion
188
+ >>> alion = AsyncAlion()
189
+ >>> result = await alion.search_jobs(query="rust", country="DE")
190
+ """
191
+
192
+ async def call(self, name: str, args: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = None) -> Any:
193
+ """Calls a tool by name. The typed methods are shortcuts for this."""
194
+ rpc = await self._rpc("tools/call", {"name": name, "arguments": _clean(args)}, timeout, name in READ_ONLY_TOOLS)
195
+ return _tool_result(name, rpc)
196
+
197
+ async def paginate(self, name: str, *, max_items: Optional[int] = None, timeout: Optional[float] = None, **args: Any) -> AsyncIterator[Dict[str, Any]]:
198
+ """Walks every page of a search, yielding one row at a time.
199
+
200
+ >>> async for job in alion.paginate("search_jobs", role="Data Engineer", max_items=50): ...
201
+ """
202
+ if name not in PAGED_TOOLS:
203
+ raise ValueError(f"{name} does not page")
204
+ cursor: Optional[str] = None
205
+ seen = 0
206
+ while True:
207
+ page = await self.call(name, {**args, **({"cursor": cursor} if cursor else {})}, timeout=timeout)
208
+ items = page.get("items") or []
209
+ for item in items:
210
+ yield item
211
+ seen += 1
212
+ if max_items is not None and seen >= max_items:
213
+ return
214
+ cursor = page.get("next_cursor")
215
+ if not cursor or page.get("has_more") is False or not items:
216
+ return
217
+
218
+ async def list_tools(self, *, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
219
+ """The tools this address offers, with their input and output schemas."""
220
+ tools: List[Dict[str, Any]] = []
221
+ cursor: Optional[str] = None
222
+ while True:
223
+ res = (await self._rpc("tools/list", {"cursor": cursor} if cursor else {}, timeout, True)).get("result") or {}
224
+ tools.extend(res.get("tools") or [])
225
+ cursor = res.get("nextCursor")
226
+ if not cursor:
227
+ return tools
228
+
229
+ async def aclose(self) -> None:
230
+ """Nothing to release; here for the async with-statement."""
231
+
232
+ async def __aenter__(self) -> "AsyncAlion":
233
+ return self
234
+
235
+ async def __aexit__(self, *exc: Any) -> None:
236
+ await self.aclose()
237
+
238
+ async def _rpc(self, method: str, params: Dict[str, Any], timeout: Optional[float], retry_5xx: bool) -> Dict[str, Any]:
239
+ headers, body = self._build(method, params)
240
+ loop = asyncio.get_running_loop()
241
+ attempt = 0
242
+ while True:
243
+ try:
244
+ # (the blocking transport runs in a worker thread: no event-loop stall, no extra dependency)
245
+ status, h, text = await loop.run_in_executor(None, functools.partial(self._transport, self.endpoint, headers, body, timeout or self.timeout))
246
+ except AlionError:
247
+ raise
248
+ except Exception as e:
249
+ if self._is_timeout(e):
250
+ raise self._network_error(TimeoutError(str(e))) from e
251
+ if attempt < self.max_retries:
252
+ await asyncio.sleep(_backoff(attempt))
253
+ attempt += 1
254
+ continue
255
+ raise self._network_error(e) from e
256
+ if _retryable(status, retry_5xx) and attempt < self.max_retries:
257
+ ra = _retry_after(h)
258
+ await asyncio.sleep(ra if ra is not None else _backoff(attempt))
259
+ attempt += 1
260
+ continue
261
+ return self._parse(status, h, text)
262
+
263
+
264
+ def _clean(args: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
265
+ return {k: v for k, v in (args or {}).items() if v is not None}
266
+
267
+
268
+ def _tool_result(name: str, rpc: Dict[str, Any]) -> Any:
269
+ result = rpc.get("result") or {}
270
+ payload = result.get("structuredContent")
271
+ if payload is None:
272
+ payload = _text_content(result.get("content"))
273
+ if result.get("isError"):
274
+ raise AlionError._from_tool(name, payload)
275
+ return payload
276
+
277
+
278
+ def _text_content(content: Any) -> Any:
279
+ for c in content or []:
280
+ if isinstance(c, dict) and c.get("type") == "text" and isinstance(c.get("text"), str):
281
+ try:
282
+ return json.loads(c["text"])
283
+ except ValueError:
284
+ return c["text"]
285
+ return None
286
+
287
+
288
+ def _parse_body(text: str, content_type: str) -> Any:
289
+ if not text:
290
+ return None
291
+ if "text/event-stream" in content_type:
292
+ lines = [l[5:].strip() for l in text.splitlines() if l.startswith("data:")]
293
+ text = lines[-1] if lines else ""
294
+ try:
295
+ return json.loads(text)
296
+ except ValueError:
297
+ return text
298
+
299
+
300
+ def _retryable(status: int, retry_5xx: bool) -> bool:
301
+ return status == 429 or (retry_5xx and status >= 500)
302
+
303
+
304
+ def _backoff(attempt: int) -> float:
305
+ return min(8.0, 0.5 * (1 << min(attempt, 6))) + random.random() * 0.25
306
+
307
+
308
+ def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
309
+ v = headers.get("retry-after")
310
+ if v is None:
311
+ return None
312
+ try:
313
+ return min(60.0, max(0.0, float(v)))
314
+ except ValueError:
315
+ return None
alion/_errors.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Mapping, Optional
4
+
5
+ _ACCOUNT_CODES = frozenset({"account_required", "payment_required", "auth_required", "unauthorized", "http_401", "http_402"})
6
+
7
+
8
+ class AlionError(Exception):
9
+ """Every failure of a call: a refusal by a tool (the server's code, e.g. ``account_required``,
10
+ ``rate_limited``, ``invalid_params``), an HTTP error, a JSON-RPC error, a timeout or a network error.
11
+
12
+ >>> try:
13
+ ... alion.get_company(domain="stripe.com")
14
+ ... except AlionError as e:
15
+ ... if e.needs_account: ...
16
+ """
17
+
18
+ code: str
19
+ """A machine-readable code: the server's error code, or timeout / network_error / http_<status> / rpc_<code>."""
20
+ status: Optional[int]
21
+ """The HTTP status, when the server answered with an error status."""
22
+ tool: Optional[str]
23
+ """The tool that refused, for a tool error."""
24
+ data: Any
25
+ """The whole error payload from the server (hints, free_tools, examples, ...)."""
26
+ retry_after: Optional[float]
27
+ """Seconds to wait before retrying, when the server said so."""
28
+
29
+ def __init__(self, message: str, *, code: str, status: Optional[int] = None, tool: Optional[str] = None, data: Any = None, retry_after: Optional[float] = None) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.code = code
33
+ self.status = status
34
+ self.tool = tool
35
+ self.data = data
36
+ self.retry_after = retry_after
37
+
38
+ @property
39
+ def needs_account(self) -> bool:
40
+ """True when the tool needs an API key (or a payment): it is not free on the open address."""
41
+ return self.code in _ACCOUNT_CODES
42
+
43
+ def __repr__(self) -> str:
44
+ return f"AlionError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
45
+
46
+ @classmethod
47
+ def _from_tool(cls, tool: str, payload: Any) -> "AlionError":
48
+ p: Dict[str, Any] = payload if isinstance(payload, dict) else {"message": "" if payload is None else str(payload)}
49
+ code = p.get("error") if isinstance(p.get("error"), str) else p.get("code") if isinstance(p.get("code"), str) else "tool_error"
50
+ msg = p.get("message") if isinstance(p.get("message"), str) else p.get("error") if isinstance(p.get("error"), str) else f"{tool} failed"
51
+ ra = p.get("retry_after")
52
+ return cls(f"{tool}: {msg}", code=str(code), tool=tool, data=payload, retry_after=float(ra) if isinstance(ra, (int, float)) else None)
53
+
54
+ @classmethod
55
+ def _from_rpc(cls, err: Mapping[str, Any]) -> "AlionError":
56
+ return cls(str(err.get("message") or f"JSON-RPC error {err.get('code')}"), code=f"rpc_{err.get('code')}", data=err.get("data", err))
57
+
58
+ @classmethod
59
+ def _from_http(cls, status: int, data: Any, headers: Mapping[str, str]) -> "AlionError":
60
+ retry_after: Optional[float] = None
61
+ try:
62
+ v = float(headers.get("retry-after", ""))
63
+ retry_after = v if v > 0 else None
64
+ except ValueError:
65
+ pass
66
+ if isinstance(data, dict):
67
+ rpc = data.get("error") if isinstance(data.get("error"), dict) else None
68
+ if rpc is not None:
69
+ return cls(str(rpc.get("message") or f"HTTP {status}"), code=_code_for(status, rpc.get("code")), status=status, data=rpc.get("data", data), retry_after=retry_after)
70
+ code = data["error"] if isinstance(data.get("error"), str) else f"http_{status}"
71
+ msg = data.get("message") if isinstance(data.get("message"), str) else data.get("detail") if isinstance(data.get("detail"), str) else f"HTTP {status}"
72
+ return cls(str(msg), code=code, status=status, data=data, retry_after=retry_after)
73
+ text = f": {data}" if isinstance(data, str) and len(data) < 300 and data else ""
74
+ return cls(f"HTTP {status}{text}", code=f"http_{status}", status=status, data=data, retry_after=retry_after)
75
+
76
+
77
+ def _code_for(status: int, rpc_code: Any) -> str:
78
+ if status == 401:
79
+ return "unauthorized"
80
+ if status == 402:
81
+ return "payment_required"
82
+ if status == 429:
83
+ return "rate_limited"
84
+ return f"rpc_{rpc_code}" if rpc_code is not None else f"http_{status}"