Skip to content

Feat: Add AsyncClient, search API, and streaming bulk export#28

Merged
dannywillems merged 8 commits intomainfrom
feat/async-client-search-api
Mar 17, 2026
Merged

Feat: Add AsyncClient, search API, and streaming bulk export#28
dannywillems merged 8 commits intomainfrom
feat/async-client-search-api

Conversation

@Chocapikk
Copy link
Copy Markdown
Contributor

@Chocapikk Chocapikk commented Feb 7, 2026

Summary

This PR enhances the LeakIX Python client with async support, reduced code duplication, and improved developer experience.

New Features

  • AsyncClient: Full async/await support using httpx, mirrors the sync Client API
  • BaseClient: Shared logic between sync and async clients (headers, parsing, query serialization)
  • Simple search() API: Intuitive method accepting raw query strings (same syntax as the website)
  • Domain lookup: get_domain() method for domain-level service/leak queries
  • Streaming bulk export: Memory-efficient bulk_export_stream() generator

Code Quality

  • Extracted BaseClient to eliminate duplication between sync and async clients
  • AsyncClient returns AbstractResponse (not raw lists/dicts), consistent with sync client
  • Uses Scope enum instead of raw strings for type safety
  • Added serialize_queries() helper to centralize query serialization
  • All methods have return type annotations
  • No silent error swallowing

API Examples

# Sync client
from leakix import Client, Scope
client = Client(api_key="...")
results = client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK)

# Async client
from leakix import AsyncClient, Scope
async with AsyncClient(api_key="...") as client:
    results = await client.search("+country:FR +port:22", scope=Scope.SERVICE)

Changes

  • Add leakix/base.py with BaseClient shared logic
  • Add leakix/async_client.py with full async implementation
  • Add search(), get_domain(), bulk_export_stream() to both clients
  • Add serialize_queries() helper in query.py
  • Add httpx dependency
  • Export AsyncClient from package root
  • Version bump to 0.2.0

Recreated from #27 as an internal branch for CI.

@dannywillems dannywillems force-pushed the feat/async-client-search-api branch 2 times, most recently from 716aaeb to 702b65e Compare February 10, 2026 01:16
Comment thread README.md Outdated

## Quick Start

```python
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a good practice to never leave code in README, because it can be outdated very quickly. A more maintainable way to add code from files that is tested in the CI.

Comment thread README.md
Each object can be transformed back into a Python dictionary/JSON using the method `to_dict()`.
For instance, for the response of the subdomains endpoint, you can get back individual JSON by using:

```python
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Chocapikk Chocapikk force-pushed the feat/async-client-search-api branch from 5f44186 to aa7e53a Compare March 16, 2026 15:47
Copy link
Copy Markdown
Contributor

@dannywillems dannywillems left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This patch does many things, which should be in individual commits and individual patches.
Can you split in individual patches please?

Additionnaly, the async and sync code are duplicated. There should be an elegant way to share code between both.

Finally, the async client does not follow the same pattern as the sync client (AbstractResponse). The class AbstractResponse with its implementation SuccessResponse and ErrorResponse abstract LeakIX backend.

With the async client, the user does not know what actually happened.

Comment thread leakix/async_client.py Outdated
)
if status == 200 and isinstance(data, list):
return [l9format.L9Event.from_dict(item) for item in data]
return []
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This motivates my comment regarding using AbstractResponse. In this case, the user does not know what actually happened (an error happened?).

Comment thread leakix/async_client.py Outdated
async def search(
self,
query: str,
scope: str = "leak",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use an enum instead.

Comment thread leakix/async_client.py Outdated
}
return {"services": [], "leaks": []}

def _parse_events(self, items: list) -> list:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to have an homogenenous list. l9format should never fail. And if it fails, the user must know, and we should get a report for it.

Comment thread leakix/client.py Outdated
return self.get_service(queries=queries, page=page)
return self.get_leak(queries=queries, page=page)

def bulk_export_stream(self, queries: list[Query] | None = None):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the returned type?

@Chocapikk Chocapikk force-pushed the feat/async-client-search-api branch from e848685 to 362ce2b Compare March 17, 2026 08:37
@Chocapikk Chocapikk changed the title Feat: Add AsyncClient, search API, and Pro detection Feat: Add AsyncClient, search API, and streaming bulk export Mar 17, 2026
@Chocapikk Chocapikk force-pushed the feat/async-client-search-api branch from 010ea80 to e547391 Compare March 17, 2026 08:47
Comment thread leakix/async_client.py Outdated
Comment thread leakix/client.py
if page < 0:
raise ValueError("Page argument must be a positive integer")
url = f"{self.base_url}/search"
r = self.__get(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you introduce __get instead of _get? Having a uniform API would be better. Keep one of them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__get is already on main, it's the existing sync client code (client.py:53). I didn't change that. The async client uses _get since it's called from subclasses via BaseClient.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you introduce __get instead of _get?

I meant why do you introduce _get instead of __get.

The async client uses _get since it's called from subclasses via BaseClient.

Wdym?

Copy link
Copy Markdown
Contributor

@dannywillems dannywillems left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments

Comment thread leakix/client.py Outdated

class Client(BaseClient):
def __get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse:
def _get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you use _get instead of __get?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You asked for a uniform API. Both clients now use _get

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not answer the question. Can you explain why you picked _get instead of __get?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannywillems
Copy link
Copy Markdown
Contributor

Remove 3d2cf4e and d159e3b. This is out of the scope of this patch. It is in a follow-up.

@dannywillems
Copy link
Copy Markdown
Contributor

Can you clean this history please? You introduce a commit that changes __get to _get, and after that revert the changes.
Also, it would be better to have a commit for each individual change, as discussed on mattermost.

@Chocapikk Chocapikk force-pushed the feat/async-client-search-api branch from d05e130 to c29ab56 Compare March 17, 2026 10:26
@Chocapikk Chocapikk force-pushed the feat/async-client-search-api branch from c29ab56 to 44b55b6 Compare March 17, 2026 10:35
@dannywillems
Copy link
Copy Markdown
Contributor

This should be removed, as requested in #28 (comment).
I will go further with this change.

Copy link
Copy Markdown
Contributor

@dannywillems dannywillems left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments

@dannywillems dannywillems merged commit 1afe155 into main Mar 17, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants