Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

async def main():
# Construct async client
client = await AsyncAtlas.create()
client = AsyncAtlas()

# --- Models
models = await client.models.get()
Expand All @@ -28,7 +28,7 @@ async def main():
evaluation = await client.evaluations.wait_for_completion(
evaluation,
interval_seconds=10,
timeout=600, # 10 minutes
timeout_seconds=600, # 10 minutes
)
print(f"Evaluation {evaluation.id} finished with status={evaluation.status}")

Expand Down
2 changes: 1 addition & 1 deletion examples/async_client_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

async def main():
# Construct async client
client = await AsyncAtlas.create()
client = AsyncAtlas()

# --- Models
models = await client.models.get()
Expand Down
2 changes: 1 addition & 1 deletion examples/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
evaluation = client.evaluations.wait_for_completion(
evaluation,
interval_seconds=10,
timeout=600, # 10 minutes
timeout_seconds=600, # 10 minutes
)
print(f"Evaluation {evaluation.id} finished with status={evaluation.status}")

Expand Down
2 changes: 1 addition & 1 deletion examples/client_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# --- Wait for completion
evaluation.wait_for_completion(
interval_seconds=10,
timeout=600, # 10 minutes
timeout_seconds=600, # 10 minutes
)
print(f"Evaluation {evaluation.id} finished with status={evaluation.status}")

Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "1.0.0"
description = "The official Python library for the LayerLens Atlas API"
license = "Apache-2.0"
authors = [{ name = "LayerLens", email = "support@layerlens.ai" }]
dependencies = ["httpx>=0.23.0, <1", "pydantic>=1.9.0, <3"]
dependencies = ["httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", "requests"]
requires-python = ">= 3.8"
classifiers = [
"Typing :: Typed",
Expand Down Expand Up @@ -35,11 +35,12 @@ atlas = "atlas.cli:main"
managed = true
# version pins are in requirements-dev.lock
dev-dependencies = [
"pyright==1.1.399",
"mypy",
"pytest",
"ruff",
"pyright==1.1.399",
"pytest-cov>=6.2.1",
"ruff",
"types-requests",
]

[tool.rye.scripts]
Expand Down
10 changes: 10 additions & 0 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ anyio==4.9.0
certifi==2025.7.14
# via httpcore
# via httpx
# via requests
charset-normalizer==3.4.3
# via requests
coverage==7.10.2
# via pytest-cov
exceptiongroup==1.3.0
Expand All @@ -31,6 +34,7 @@ httpx==0.28.1
idna==3.10
# via anyio
# via httpx
# via requests
iniconfig==2.1.0
# via pytest
mypy==1.17.0
Expand All @@ -55,13 +59,16 @@ pyright==1.1.399
pytest==8.4.1
# via pytest-cov
pytest-cov==6.2.1
requests==2.32.5
# via atlas
ruff==0.12.7
sniffio==1.3.1
# via anyio
tomli==2.2.1
# via coverage
# via mypy
# via pytest
types-requests==2.32.4.20250809
typing-extensions==4.14.1
# via anyio
# via exceptiongroup
Expand All @@ -72,3 +79,6 @@ typing-extensions==4.14.1
# via typing-inspection
typing-inspection==0.4.1
# via pydantic
urllib3==2.5.0
# via requests
# via types-requests
8 changes: 8 additions & 0 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ anyio==4.9.0
certifi==2025.7.14
# via httpcore
# via httpx
# via requests
charset-normalizer==3.4.3
# via requests
exceptiongroup==1.3.0
# via anyio
h11==0.16.0
Expand All @@ -28,10 +31,13 @@ httpx==0.28.1
idna==3.10
# via anyio
# via httpx
# via requests
pydantic==2.11.7
# via atlas
pydantic-core==2.33.2
# via pydantic
requests==2.32.5
# via atlas
sniffio==1.3.1
# via anyio
typing-extensions==4.14.1
Expand All @@ -42,3 +48,5 @@ typing-extensions==4.14.1
# via typing-inspection
typing-inspection==0.4.1
# via pydantic
urllib3==2.5.0
# via requests
51 changes: 17 additions & 34 deletions src/atlas/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing_extensions import Self, override

import httpx
import requests

from . import _exceptions
from ._utils import is_mapping
Expand Down Expand Up @@ -98,10 +99,7 @@ def results(self) -> Results:
@property
@override
def auth_headers(self) -> dict[str, str]:
api_key = self.api_key
if not api_key:
return {}
return {"x-api-key": api_key}
return {"x-api-key": self.api_key} if self.api_key else {}

def copy(
self,
Expand Down Expand Up @@ -166,9 +164,7 @@ def _get_organization(self) -> Optional[Organization]:
timeout=30,
cast_to=OrganizationResponse,
)
if isinstance(organization, OrganizationResponse):
return organization.data
return None
return organization.data if isinstance(organization, OrganizationResponse) else None


class AsyncAtlas(BaseAsyncClient):
Expand Down Expand Up @@ -204,31 +200,16 @@ def __init__(

super().__init__(base_url=base_url, timeout=timeout)

# org/project must be fetched asynchronously
self.organization_id = None
self.project_id = None

@classmethod
async def create(
cls,
*,
api_key: str | None = None,
base_url: str | httpx.URL | None = None,
timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT,
) -> "AsyncAtlas":
"""Async factory that combines __init__ and ainit into one call."""
self = cls(api_key=api_key, base_url=base_url, timeout=timeout)
organization = await self._get_organization()
organization = self._get_organization()
if organization is None:
raise AtlasError("Organization could not be fetched. Please contact LayerLens Atlas support.")
raise AtlasError(f"Organization could not be fetched. Please contact LayerLens Atlas support.")
self.organization_id = organization.id

if not organization.projects:
if organization.projects is None or len(organization.projects) == 0:
raise AtlasError(
f"Organization {self.organization_id} is missing project. Please contact LayerLens Atlas support."
)
self.project_id = organization.projects[0].id
return self

@cached_property
def benchmarks(self) -> AsyncBenchmarks:
Expand All @@ -255,6 +236,7 @@ def results(self) -> AsyncResults:
return AsyncResults(self)

@property
@override
def auth_headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}

Expand Down Expand Up @@ -304,15 +286,16 @@ def _make_status_error(

return APIStatusError(err_msg, response=response, body=data)

async def _get_organization(self) -> Optional[Organization]:
organization = await super().get_cast(
"/organizations",
timeout=30,
cast_to=OrganizationResponse,
)
if isinstance(organization, OrganizationResponse):
return organization.data
return None
def _get_organization(self) -> Optional[Organization]:
url = f"{self.base_url}organizations"

response = requests.get(url, headers=self.default_headers, timeout=30)
response.raise_for_status()

data = response.json()

organization = OrganizationResponse(**data)
return organization.data if isinstance(organization, OrganizationResponse) else None


Client = Atlas
Expand Down
40 changes: 35 additions & 5 deletions src/atlas/models/evaluation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from enum import Enum
from typing import TYPE_CHECKING, Dict, Optional
from typing import TYPE_CHECKING, Dict, List, Optional
from datetime import timedelta

import httpx
Expand Down Expand Up @@ -70,6 +70,21 @@ def get_results(

return self._client.results.get(evaluation=self, page=page, page_size=page_size, timeout=timeout)

def get_all_results(
self,
*,
timeout: float | httpx.Timeout | None = None,
) -> List[Result]:
"""Fetch results synchronously if a sync client is attached."""
from .._client import AsyncAtlas

if self._client is None:
raise ValueError("No client attached")
if isinstance(self._client, AsyncAtlas):
raise RuntimeError("Use `await get_results_async()` with an async client")

return self._client.results.get_all(evaluation=self, timeout=timeout)

async def get_results_async(
self,
*,
Expand All @@ -87,8 +102,23 @@ async def get_results_async(

return await self._client.results.get(evaluation=self, page=page, page_size=page_size, timeout=timeout)

async def get_all_results_async(
self,
*,
timeout: float | httpx.Timeout | None = None,
) -> List[Result]:
"""Fetch results asynchronously if an async client is attached."""
from .._client import AsyncAtlas

if self._client is None:
raise ValueError("No client attached")
if not isinstance(self._client, AsyncAtlas):
raise RuntimeError("Use `get_results()` with a sync client")

return await self._client.results.get_all(evaluation=self, timeout=timeout)

def wait_for_completion(
self, *, interval_seconds: int = 30, timeout: Optional[float] = None
self, *, interval_seconds: int = 30, timeout_seconds: Optional[int] = None
) -> Optional["Evaluation"]:
"""Sync polling using a sync client."""
from .._client import AsyncAtlas
Expand All @@ -99,7 +129,7 @@ def wait_for_completion(
raise RuntimeError("Use `wait_for_completion_async()` with an async client")

evaluation = self._client.evaluations.wait_for_completion(
self, interval_seconds=interval_seconds, timeout=timeout
self, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds
)
if evaluation:
self.status = evaluation.status
Expand All @@ -110,7 +140,7 @@ def wait_for_completion(
return self

async def wait_for_completion_async(
self, *, interval_seconds: int = 30, timeout: Optional[float] = None
self, *, interval_seconds: int = 30, timeout_seconds: Optional[int] = None
) -> Optional["Evaluation"]:
"""Async polling using an async client."""
from .._client import AsyncAtlas
Expand All @@ -121,7 +151,7 @@ async def wait_for_completion_async(
raise RuntimeError("Use `wait_for_completion()` with a sync client")

evaluation = await self._client.evaluations.wait_for_completion(
self, interval_seconds=interval_seconds, timeout=timeout
self, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds
)
if evaluation:
self.status = evaluation.status
Expand Down
16 changes: 10 additions & 6 deletions src/atlas/resources/evaluations/evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,17 @@ def wait_for_completion(
evaluation: Evaluation,
*,
interval_seconds: int = 30,
timeout: float | None = None,
timeout_seconds: Optional[int] = None,
) -> Optional[Evaluation]:
"""Poll until the evaluation finishes or timeout is reached."""
start = time.time()

updated_evaluation: Optional[Evaluation] = self.get(evaluation)
while updated_evaluation and not updated_evaluation.is_finished:
if timeout and (time.time() - start) > timeout:
raise TimeoutError(f"Evaluation {updated_evaluation.id} did not complete within {timeout} seconds")
if timeout_seconds and (time.time() - start) > timeout_seconds:
raise TimeoutError(
f"Evaluation {updated_evaluation.id} did not complete within {timeout_seconds} seconds"
)

time.sleep(interval_seconds)
updated_evaluation = self.get(updated_evaluation)
Expand Down Expand Up @@ -146,15 +148,17 @@ async def wait_for_completion(
evaluation: Evaluation,
*,
interval_seconds: int = 30,
timeout: Optional[float] = None,
timeout_seconds: Optional[int] = None,
) -> Optional[Evaluation]:
"""Poll asynchronously until the evaluation finishes or timeout is reached."""
start = asyncio.get_event_loop().time()

updated_evaluation: Optional[Evaluation] = await self.get(evaluation)
while updated_evaluation and not updated_evaluation.is_finished:
if timeout and (asyncio.get_event_loop().time() - start) > timeout:
raise TimeoutError(f"Evaluation {updated_evaluation.id} did not complete within {timeout} seconds")
if timeout_seconds and (asyncio.get_event_loop().time() - start) > timeout_seconds:
raise TimeoutError(
f"Evaluation {updated_evaluation.id} did not complete within {timeout_seconds} seconds"
)

await asyncio.sleep(interval_seconds)
updated_evaluation = await self.get(updated_evaluation)
Expand Down
Loading
Loading