Skip to content

Commit 8366fd6

Browse files
authored
Merge pull request #65 from agentic-labs/rob
Async client
2 parents 039eb5a + 64c974f commit 8366fd6

File tree

5 files changed

+242
-1
lines changed

5 files changed

+242
-1
lines changed

.DS_Store

-6 KB
Binary file not shown.

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,68 @@ pip install lsproxy-sdk
2020

2121
## Usage
2222

23+
You can use lsproxy either by running a local server or using Modal cloud infrastructure.
24+
25+
### Using Modal
26+
27+
First, install the Modal dependencies:
28+
```bash
29+
pip install 'lsproxy-sdk[modal]'
30+
```
31+
32+
Then use the SDK:
33+
34+
```python
35+
from lsproxy import Lsproxy
36+
37+
# Synchronous usage
38+
lsp = Lsproxy.initialize_with_modal(
39+
repo_url="https://github.com/username/repo",
40+
git_token="your-github-token", # Optional, for private repos
41+
)
42+
43+
# Async usage
44+
from lsproxy import AsyncLsproxy
45+
import asyncio
46+
47+
async def main():
48+
lsp = await AsyncLsproxy.initialize_with_modal(
49+
repo_url="https://github.com/username/repo"
50+
)
51+
try:
52+
files = await lsp.list_files()
53+
finally:
54+
await lsp.close()
55+
56+
asyncio.run(main())
57+
```
58+
59+
### Using Local Server
60+
2361
1. Start the LSProxy container:
2462
```bash
2563
docker run --rm -d -p 4444:4444 -v "/path/to/your/code:/mnt/workspace" --name lsproxy agenticlabs/lsproxy:0.1.0a1
2664
```
2765

2866
2. Use the SDK:
67+
2968
```python
69+
# Synchronous usage
3070
from lsproxy import Lsproxy
3171

3272
lsp = Lsproxy()
73+
74+
# Async usage
75+
from lsproxy import AsyncLsproxy
76+
import asyncio
77+
78+
async def main():
79+
async with AsyncLsproxy() as lsp:
80+
# Use async methods
81+
files = await lsp.list_files()
82+
83+
# Run the async code
84+
asyncio.run(main())
3385
```
3486

3587
## List all files in the workspace

lsproxy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .client import Lsproxy
2+
from .async_client import AsyncLsproxy
23
from .models import (
34
Position,
45
FilePosition,
@@ -17,6 +18,7 @@
1718

1819
__all__ = [
1920
"Lsproxy",
21+
"AsyncLsproxy",
2022
"Position",
2123
"FilePosition",
2224
"FileRange",

lsproxy/async_client.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import json
2+
import httpx
3+
import time
4+
import asyncio
5+
from typing import List, Optional
6+
7+
from .models import (
8+
DefinitionResponse,
9+
FileRange,
10+
ReadSourceCodeResponse,
11+
ReferencesResponse,
12+
GetDefinitionRequest,
13+
GetReferencesRequest,
14+
Symbol,
15+
FindIdentifierRequest,
16+
IdentifierResponse,
17+
GetReferencedSymbolsRequest,
18+
ReferencedSymbolsResponse,
19+
)
20+
21+
class AsyncLsproxy:
22+
"""Async client for interacting with the lsproxy API."""
23+
24+
def __init__(
25+
self,
26+
base_url: str = "http://localhost:4444/v1",
27+
timeout: float = 60.0,
28+
auth_token: Optional[str] = None,
29+
):
30+
self._client = httpx.AsyncClient(
31+
base_url=base_url,
32+
timeout=timeout,
33+
headers={"Content-Type": "application/json"},
34+
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100),
35+
)
36+
headers = {"Content-Type": "application/json"}
37+
if auth_token:
38+
headers["Authorization"] = f"Bearer {auth_token}"
39+
self._client.headers = headers
40+
41+
async def _request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
42+
"""Make HTTP request with retry logic and better error handling."""
43+
try:
44+
response = await self._client.request(method, endpoint, **kwargs)
45+
response.raise_for_status()
46+
return response
47+
except httpx.HTTPStatusError as e:
48+
if e.response.status_code == 400:
49+
error_data = e.response.json()
50+
raise ValueError(error_data.get("error", str(e)))
51+
raise
52+
53+
async def definitions_in_file(self, file_path: str) -> List[Symbol]:
54+
"""Retrieve symbols from a specific file."""
55+
response = await self._request(
56+
"GET", "/symbol/definitions-in-file", params={"file_path": file_path}
57+
)
58+
symbols = [
59+
Symbol.model_validate(symbol_dict)
60+
for symbol_dict in json.loads(response.text)
61+
]
62+
return symbols
63+
64+
async def find_definition(self, request: GetDefinitionRequest) -> DefinitionResponse:
65+
"""Get the definition of a symbol at a specific position in a file."""
66+
if not isinstance(request, GetDefinitionRequest):
67+
raise TypeError(
68+
f"Expected GetDefinitionRequest, got {type(request).__name__}. Please use GetDefinitionRequest model to construct the request."
69+
)
70+
response = await self._request(
71+
"POST", "/symbol/find-definition", json=request.model_dump()
72+
)
73+
definition = DefinitionResponse.model_validate_json(response.text)
74+
return definition
75+
76+
async def find_references(self, request: GetReferencesRequest) -> ReferencesResponse:
77+
"""Find all references to a symbol."""
78+
if not isinstance(request, GetReferencesRequest):
79+
raise TypeError(
80+
f"Expected GetReferencesRequest, got {type(request).__name__}. Please use GetReferencesRequest model to construct the request."
81+
)
82+
response = await self._request(
83+
"POST", "/symbol/find-references", json=request.model_dump()
84+
)
85+
references = ReferencesResponse.model_validate_json(response.text)
86+
return references
87+
88+
async def find_identifier(self, request: FindIdentifierRequest) -> IdentifierResponse:
89+
"""Find all occurrences of an identifier by name in a file."""
90+
if not isinstance(request, FindIdentifierRequest):
91+
raise TypeError(
92+
f"Expected FindIdentifierRequest, got {type(request).__name__}. Please use FindIdentifierRequest model to construct the request."
93+
)
94+
response = await self._request(
95+
"POST", "/symbol/find-identifier", json=request.model_dump()
96+
)
97+
return IdentifierResponse.model_validate_json(response.text)
98+
99+
async def list_files(self) -> List[str]:
100+
"""Get a list of all files in the workspace."""
101+
response = await self._request("GET", "/workspace/list-files")
102+
files = response.json()
103+
return files
104+
105+
async def read_source_code(self, request: FileRange) -> ReadSourceCodeResponse:
106+
"""Read source code from a specified file range."""
107+
if not isinstance(request, FileRange):
108+
raise TypeError(
109+
f"Expected FileRange, got {type(request).__name__}. Please use FileRange model to construct the request."
110+
)
111+
response = await self._request(
112+
"POST", "/workspace/read-source-code", json=request.model_dump()
113+
)
114+
return ReadSourceCodeResponse.model_validate_json(response.text)
115+
116+
@classmethod
117+
async def initialize_with_modal(
118+
cls,
119+
repo_url: str,
120+
git_token: Optional[str] = None,
121+
sha: Optional[str] = None,
122+
timeout: Optional[int] = None,
123+
version: str = "0.3",
124+
) -> "AsyncLsproxy":
125+
"""Initialize lsproxy by starting a Modal sandbox with the server and connecting to it."""
126+
try:
127+
from .modal import ModalSandbox
128+
except ImportError:
129+
raise ImportError(
130+
"Modal and PyJWT are required for this feature. "
131+
"Install them with: pip install 'lsproxy-sdk[modal]'"
132+
)
133+
134+
sandbox = ModalSandbox(repo_url, git_token, sha, timeout, version)
135+
136+
client = cls(base_url=f"{sandbox.tunnel_url}/v1", auth_token=sandbox.jwt_token)
137+
138+
print("Waiting for server start up (make take a minute)...")
139+
for attempt in range(180):
140+
if await client.check_health():
141+
break
142+
await asyncio.sleep(1)
143+
else:
144+
raise TimeoutError("Server did not start up within 3 minutes")
145+
146+
print("Server is ready to accept connections")
147+
client._sandbox = sandbox
148+
return client
149+
150+
async def check_health(self) -> bool:
151+
"""Check if the server is healthy and ready."""
152+
try:
153+
response = await self._request("GET", "/system/health")
154+
health_data = response.json()
155+
return health_data.get("status") == "ok"
156+
except Exception:
157+
return False
158+
159+
async def find_referenced_symbols(
160+
self, request: GetReferencedSymbolsRequest
161+
) -> ReferencedSymbolsResponse:
162+
"""Find all symbols that are referenced from the symbol at the given position."""
163+
if not isinstance(request, GetReferencedSymbolsRequest):
164+
raise TypeError(
165+
f"Expected GetReferencedSymbolsRequest, got {type(request).__name__}. "
166+
"Please use GetReferencedSymbolsRequest model to construct the request."
167+
)
168+
169+
response = await self._request(
170+
"POST",
171+
"/symbol/find-referenced-symbols",
172+
json=request.model_dump()
173+
)
174+
175+
return ReferencedSymbolsResponse.model_validate_json(response.text)
176+
177+
async def close(self):
178+
"""Close the HTTP client and cleanup Modal resources if present."""
179+
await self._client.aclose()
180+
if hasattr(self, "_sandbox"):
181+
self._sandbox.terminate()
182+
183+
async def __aenter__(self) -> "AsyncLsproxy":
184+
return self
185+
186+
async def __aexit__(self, exc_type, exc_val, exc_tb):
187+
await self.close()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "lsproxy-sdk"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
description = "SDK for interacting with lsproxy container"
55
readme = "README.md"
66
requires-python = ">=3.10"

0 commit comments

Comments
 (0)