Skip to content

Commit 65b403e

Browse files
committed
Feat: Add AsyncClient extending BaseClient
- AsyncClient inherits BaseClient, shares init/parsing logic - Uses httpx for async operations, returns AbstractResponse - Uses Scope enum instead of raw strings - No duplicated transformation logic (uses inherited static methods) - Add httpx>=0.28.0 dependency, bump version to 0.2.0
1 parent 63a939f commit 65b403e

3 files changed

Lines changed: 214 additions & 3 deletions

File tree

leakix/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from importlib.metadata import version
22

3+
from leakix.async_client import AsyncClient as AsyncClient
4+
from leakix.base import HostResult as HostResult
35
from leakix.client import Client as Client
4-
from leakix.client import HostResult as HostResult
56
from leakix.client import Scope as Scope
67
from leakix.domain import L9Subdomain as L9Subdomain
78
from leakix.field import (
@@ -71,6 +72,7 @@
7172

7273
__all__ = [
7374
"__version__",
75+
"AsyncClient",
7476
"Client",
7577
"HostResult",
7678
"L9Subdomain",

leakix/async_client.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Async LeakIX API client using httpx."""
2+
3+
import json
4+
from collections.abc import AsyncIterator
5+
from typing import Any, cast
6+
7+
import httpx
8+
from l9format import l9format
9+
10+
from leakix.base import DEFAULT_URL, BaseClient
11+
from leakix.client import Scope
12+
from leakix.query import Query, serialize_queries
13+
from leakix.response import (
14+
AbstractResponse,
15+
ErrorResponse,
16+
RateLimitResponse,
17+
SuccessResponse,
18+
)
19+
20+
DEFAULT_TIMEOUT = 30.0
21+
22+
23+
class AsyncClient(BaseClient):
24+
"""Async client for the LeakIX API.
25+
26+
Mirrors the sync Client API but uses httpx for async operations.
27+
All methods return AbstractResponse for consistency with the sync client.
28+
"""
29+
30+
def __init__(
31+
self,
32+
api_key: str | None = None,
33+
base_url: str | None = DEFAULT_URL,
34+
timeout: float = DEFAULT_TIMEOUT,
35+
) -> None:
36+
super().__init__(api_key=api_key, base_url=base_url)
37+
self.timeout = timeout
38+
self._client: httpx.AsyncClient | None = None
39+
40+
async def _get_client(self) -> httpx.AsyncClient:
41+
"""Get or create the HTTP client."""
42+
if self._client is None or self._client.is_closed:
43+
self._client = httpx.AsyncClient(
44+
base_url=self.base_url,
45+
headers=self.headers,
46+
timeout=self.timeout,
47+
)
48+
return self._client
49+
50+
async def close(self) -> None:
51+
"""Close the HTTP client."""
52+
if self._client is not None and not self._client.is_closed:
53+
await self._client.aclose()
54+
self._client = None
55+
56+
async def __aenter__(self) -> "AsyncClient":
57+
return self
58+
59+
async def __aexit__(self, *args: Any) -> None:
60+
await self.close()
61+
62+
async def _get(
63+
self, path: str, params: dict[str, Any] | None = None
64+
) -> AbstractResponse:
65+
"""Make a GET request and return an AbstractResponse."""
66+
client = await self._get_client()
67+
r = await client.get(path, params=params)
68+
if r.status_code == 200:
69+
response_json = r.json() if r.content else []
70+
return SuccessResponse(response=r, response_json=response_json)
71+
elif r.status_code == 429:
72+
return RateLimitResponse(response=r)
73+
elif r.status_code == 204:
74+
return ErrorResponse(response=r, response_json=[], status_code=200)
75+
else:
76+
return ErrorResponse(response=r, response_json=r.json())
77+
78+
async def get(
79+
self,
80+
scope: Scope,
81+
queries: list[Query] | None = None,
82+
page: int = 0,
83+
) -> AbstractResponse:
84+
"""Search LeakIX for services or leaks."""
85+
if page < 0:
86+
raise ValueError("Page argument must be a positive integer")
87+
serialized_query = serialize_queries(queries)
88+
return await self._get(
89+
"/search",
90+
params={"scope": scope.value, "q": serialized_query, "page": page},
91+
)
92+
93+
async def get_service(
94+
self, queries: list[Query] | None = None, page: int = 0
95+
) -> AbstractResponse:
96+
"""Shortcut for get with scope=Scope.SERVICE."""
97+
return self._parse_events(
98+
await self.get(Scope.SERVICE, queries=queries, page=page)
99+
)
100+
101+
async def get_leak(
102+
self, queries: list[Query] | None = None, page: int = 0
103+
) -> AbstractResponse:
104+
"""Shortcut for get with scope=Scope.LEAK."""
105+
return self._parse_events(
106+
await self.get(Scope.LEAK, queries=queries, page=page)
107+
)
108+
109+
async def search(
110+
self, query: str, scope: Scope = Scope.LEAK, page: int = 0
111+
) -> AbstractResponse:
112+
"""
113+
Simple search using a raw query string (same syntax as the website).
114+
115+
Example:
116+
>>> await client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK)
117+
"""
118+
if page < 0:
119+
raise ValueError("Page argument must be a positive integer")
120+
r = await self._get(
121+
"/search",
122+
params={"scope": scope.value, "q": query, "page": page},
123+
)
124+
return self._parse_events(r)
125+
126+
async def get_host(self, ipv4: str) -> AbstractResponse:
127+
"""Returns the list of services and associated leaks for a given host."""
128+
return self._parse_host_result(await self._get(f"/host/{ipv4}"))
129+
130+
async def get_domain(self, domain: str) -> AbstractResponse:
131+
"""Returns the list of services and associated leaks for a given domain."""
132+
return self._parse_host_result(await self._get(f"/domain/{domain}"))
133+
134+
async def get_plugins(self) -> AbstractResponse:
135+
"""Returns the list of plugins the authenticated user has access to."""
136+
return self._parse_plugins(await self._get("/api/plugins"))
137+
138+
async def get_subdomains(self, domain: str) -> AbstractResponse:
139+
"""Returns the list of subdomains for a given domain."""
140+
return self._parse_subdomains(await self._get(f"/api/subdomains/{domain}"))
141+
142+
async def get_api_status(self, force: bool = False) -> AbstractResponse:
143+
"""
144+
Check API status and subscription info via /api/user/info endpoint.
145+
Results are cached per client instance. Use force=True to refresh.
146+
"""
147+
if self._api_status is not None and not force:
148+
return self._api_status
149+
150+
r = await self._get("/api/user/info")
151+
self._api_status = r
152+
return r
153+
154+
async def is_pro(self) -> bool:
155+
"""Check if the API key has Pro access. Result is cached."""
156+
r = await self.get_api_status()
157+
if r.is_success():
158+
return bool(r.json().get("is_pro", False))
159+
return False
160+
161+
async def bulk_export(self, queries: list[Query] | None = None) -> AbstractResponse:
162+
"""Bulk export leaks (Pro API feature)."""
163+
serialized_query = serialize_queries(queries)
164+
client = await self._get_client()
165+
async with client.stream(
166+
"GET", "/bulk/search", params={"q": serialized_query}
167+
) as r:
168+
if r.status_code == 200:
169+
response_json = []
170+
async for line in r.aiter_lines():
171+
if line:
172+
json_event = json.loads(line)
173+
response_json.append(
174+
l9format.L9Aggregation.from_dict(json_event)
175+
)
176+
return SuccessResponse(response=r, response_json=response_json)
177+
elif r.status_code == 429:
178+
return RateLimitResponse(response=r)
179+
elif r.status_code == 204:
180+
return ErrorResponse(response=r, response_json=[], status_code=200)
181+
else:
182+
await r.aread()
183+
return ErrorResponse(response=r, response_json=r.json())
184+
185+
async def bulk_export_stream(
186+
self, queries: list[Query] | None = None
187+
) -> AsyncIterator[l9format.L9Aggregation]:
188+
"""
189+
Streaming version of bulk_export. Yields L9Aggregation objects one by one.
190+
More memory efficient for large result sets.
191+
"""
192+
serialized_query = serialize_queries(queries)
193+
client = await self._get_client()
194+
async with client.stream(
195+
"GET", "/bulk/search", params={"q": serialized_query}
196+
) as r:
197+
if r.status_code != 200:
198+
return
199+
async for line in r.aiter_lines():
200+
if line:
201+
json_event = json.loads(line)
202+
yield cast(
203+
l9format.L9Aggregation,
204+
l9format.L9Aggregation.from_dict(json_event),
205+
)

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
[project]
22
name = "leakix"
3-
version = "0.1.10"
3+
version = "0.2.0"
44
description = "Official python client for LeakIX (https://leakix.net)"
5-
authors = [{ name = "Danny Willems", email = "danny@leakix.net" }]
5+
authors = [
6+
{ name = "Danny Willems", email = "danny@leakix.net" },
7+
{ name = "Valentin Lobstein", email = "valentin@leakix.net" },
8+
]
69
requires-python = ">=3.11"
710
dependencies = [
811
"requests",
12+
"httpx>=0.28.0",
913
"l9format==2.0.0",
1014
"fire>=0.5,<0.8",
1115
]

0 commit comments

Comments
 (0)