lighton-sdk 1.0.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.
- lighton/__init__.py +48 -0
- lighton/_active_record.py +117 -0
- lighton/_client.py +138 -0
- lighton/apikey.py +94 -0
- lighton/batch.py +317 -0
- lighton/content_type.py +114 -0
- lighton/enums.py +77 -0
- lighton/exceptions.py +112 -0
- lighton/file.py +343 -0
- lighton/job.py +121 -0
- lighton/tag.py +114 -0
- lighton/types/__init__.py +3 -0
- lighton/types/api/__init__.py +2126 -0
- lighton/types/batch.py +57 -0
- lighton/types/client/__init__.py +3 -0
- lighton/types/client/configuration.py +40 -0
- lighton/utils.py +106 -0
- lighton/verbs/__init__.py +8 -0
- lighton/verbs/_base.py +15 -0
- lighton/verbs/ask.py +65 -0
- lighton/verbs/extract.py +109 -0
- lighton/verbs/parse.py +72 -0
- lighton/verbs/search.py +69 -0
- lighton/workspace.py +194 -0
- lighton_sdk-1.0.0.dist-info/METADATA +649 -0
- lighton_sdk-1.0.0.dist-info/RECORD +29 -0
- lighton_sdk-1.0.0.dist-info/WHEEL +5 -0
- lighton_sdk-1.0.0.dist-info/licenses/LICENSE +201 -0
- lighton_sdk-1.0.0.dist-info/top_level.txt +1 -0
lighton/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from lighton._client import LightOn
|
|
4
|
+
from lighton.apikey import ApiKey, ApiKeyScope
|
|
5
|
+
from lighton.batch import BatchIngest, BatchIngestJob, BatchProgress, FailedIngest
|
|
6
|
+
from lighton.content_type import Attribute, ContentType, Facet
|
|
7
|
+
from lighton.enums import (
|
|
8
|
+
ExecMode,
|
|
9
|
+
FileStatus,
|
|
10
|
+
JobStatus,
|
|
11
|
+
RelevanceScoring,
|
|
12
|
+
Role,
|
|
13
|
+
SearchMode,
|
|
14
|
+
)
|
|
15
|
+
from lighton.file import File, wait_all
|
|
16
|
+
from lighton.job import ExtractJob, ParseJob
|
|
17
|
+
from lighton.tag import Tag
|
|
18
|
+
from lighton.types import LightOnConfiguration
|
|
19
|
+
from lighton.workspace import Workspace
|
|
20
|
+
|
|
21
|
+
__version__ = version(
|
|
22
|
+
"lighton-sdk"
|
|
23
|
+
) # single source of truth: pyproject.toml (via installed metadata)
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ApiKey",
|
|
26
|
+
"ApiKeyScope",
|
|
27
|
+
"Attribute",
|
|
28
|
+
"BatchIngest",
|
|
29
|
+
"BatchIngestJob",
|
|
30
|
+
"BatchProgress",
|
|
31
|
+
"ContentType",
|
|
32
|
+
"ExecMode",
|
|
33
|
+
"ExtractJob",
|
|
34
|
+
"Facet",
|
|
35
|
+
"FailedIngest",
|
|
36
|
+
"File",
|
|
37
|
+
"FileStatus",
|
|
38
|
+
"JobStatus",
|
|
39
|
+
"LightOn",
|
|
40
|
+
"LightOnConfiguration",
|
|
41
|
+
"ParseJob",
|
|
42
|
+
"RelevanceScoring",
|
|
43
|
+
"Role",
|
|
44
|
+
"SearchMode",
|
|
45
|
+
"Tag",
|
|
46
|
+
"Workspace",
|
|
47
|
+
"wait_all",
|
|
48
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Shared active-record plumbing for the resource models.
|
|
2
|
+
|
|
3
|
+
Read-side lifecycle (list/get/refresh/delete) and the client-binding internals
|
|
4
|
+
(`_bind`/`_api`/`_absorb`) are identical across Workspace/ApiKey/File, so they live
|
|
5
|
+
here. What genuinely diverges stays on the subclass: the field schema, `create()`
|
|
6
|
+
(JSON vs multipart body), and `save()` (per-resource PATCH payload).
|
|
7
|
+
|
|
8
|
+
Subclasses set two ClassVars: `_base` (URL path) and `_resource` (name used in error
|
|
9
|
+
messages). `_absorb` overwrites only fields present in the response, so one-time or
|
|
10
|
+
local-only fields (ApiKey.key, File.path) survive a later refresh().
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# The list() classmethod shadows builtin list in annotations (class scope).
|
|
16
|
+
from builtins import list as _list
|
|
17
|
+
from typing import TYPE_CHECKING, ClassVar, Self
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from lighton._client import LightOn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ActiveRecord(BaseModel):
|
|
26
|
+
# Responses carry extra fields the curated schema doesn't model; ignore them.
|
|
27
|
+
model_config = ConfigDict(extra="ignore")
|
|
28
|
+
|
|
29
|
+
_base: ClassVar[str] # e.g. "/api/v3/workspaces"
|
|
30
|
+
_resource: ClassVar[str] # e.g. "workspace", used in error messages
|
|
31
|
+
|
|
32
|
+
id: int | str | None = Field(
|
|
33
|
+
None, description="Server-assigned id; None until created/retrieved."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_client: LightOn | None = PrivateAttr(default=None)
|
|
37
|
+
|
|
38
|
+
# --- class-level (no instance yet) -------------------------------------
|
|
39
|
+
@classmethod
|
|
40
|
+
def list(cls, client: LightOn, **params: object) -> _list[Self]:
|
|
41
|
+
"""List every resource, following pagination to the end.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
client: The client used to make the request and bind to each result.
|
|
45
|
+
**params: Optional query filters (e.g. workspace_id) sent on the first page.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
All matching resources, each bound to `client`.
|
|
49
|
+
"""
|
|
50
|
+
query: dict[str, object] | None = params or None
|
|
51
|
+
items: _list[Self] = []
|
|
52
|
+
path: str | None = cls._base
|
|
53
|
+
while path: # follow pagination, no silent truncation
|
|
54
|
+
page = client._request("GET", path, params=query)
|
|
55
|
+
items.extend(cls._bind(client, row) for row in page["results"])
|
|
56
|
+
path = page.get("next")
|
|
57
|
+
query = None # `next` already carries the query string
|
|
58
|
+
return items
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def get(cls, client: LightOn, id: int | str) -> Self:
|
|
62
|
+
"""Fetch a single resource by id.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
client: The client used to make the request and bind to the result.
|
|
66
|
+
id: The resource id to retrieve.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The resource, bound to `client`.
|
|
70
|
+
"""
|
|
71
|
+
return cls._bind(client, client._request("GET", f"{cls._base}/{id}"))
|
|
72
|
+
|
|
73
|
+
# --- instance lifecycle ------------------------------------------------
|
|
74
|
+
def refresh(self) -> Self:
|
|
75
|
+
"""Re-fetch this resource from the API (GET).
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
`self`, updated with the latest field values.
|
|
79
|
+
"""
|
|
80
|
+
return self._absorb(self._api("GET", f"{self._base}/{self.id}"))
|
|
81
|
+
|
|
82
|
+
def delete(self) -> None:
|
|
83
|
+
"""Delete this resource and clear its local id."""
|
|
84
|
+
self._api("DELETE", f"{self._base}/{self.id}")
|
|
85
|
+
self.id = None
|
|
86
|
+
|
|
87
|
+
# --- internals ---------------------------------------------------------
|
|
88
|
+
def _bound_client(self) -> LightOn:
|
|
89
|
+
"""Return the bound client, or raise if this instance isn't persisted yet.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The client bound by create()/get()/list().
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If the instance has no id or no bound client.
|
|
96
|
+
"""
|
|
97
|
+
if self.id is None or self._client is None:
|
|
98
|
+
raise ValueError(f"{self._resource} must be created or retrieved first")
|
|
99
|
+
return self._client
|
|
100
|
+
|
|
101
|
+
def _api(self, method: str, path: str, **kwargs: object):
|
|
102
|
+
return self._bound_client()._request(method, path, **kwargs)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def _bind(cls, client: LightOn, data: dict) -> Self:
|
|
106
|
+
obj = cls.model_validate(data)
|
|
107
|
+
obj._client = client
|
|
108
|
+
return obj
|
|
109
|
+
|
|
110
|
+
def _absorb(self, data: dict | None) -> Self:
|
|
111
|
+
"""Copy returned fields onto self, keeping _client and any local-only fields."""
|
|
112
|
+
if data:
|
|
113
|
+
fresh = self.model_validate(data)
|
|
114
|
+
for field in type(self).model_fields:
|
|
115
|
+
if field in data: # only overwrite what the response returned
|
|
116
|
+
setattr(self, field, getattr(fresh, field))
|
|
117
|
+
return self
|
lighton/_client.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Synchronous LightOn API client.
|
|
2
|
+
|
|
3
|
+
The 4 primary verbs (ask, search, parse, extract) are mixed in from lighton/verbs/;
|
|
4
|
+
this module holds only the transport core (auth, _request, lifecycle). CRUD-style
|
|
5
|
+
groups (files, ingestion, keys) hang off the active-record resources instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from lighton import exceptions as exc
|
|
20
|
+
from lighton.types import LightOnConfiguration
|
|
21
|
+
from lighton.verbs import AskMixin, ExtractMixin, ParseMixin, SearchMixin
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _RateGate:
|
|
25
|
+
"""Thread-safe minimum-interval pacer to hold a per-minute request ceiling.
|
|
26
|
+
|
|
27
|
+
ponytail: even spacing, not a burst-allowing token bucket, simplest thing that
|
|
28
|
+
keeps concurrent threads under a per-minute cap. Swap for a token bucket if you
|
|
29
|
+
need to allow short bursts. Clock/sleep are injectable so tests need no wall time.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
per_minute: int,
|
|
35
|
+
*,
|
|
36
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
37
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._interval = 60.0 / per_minute
|
|
40
|
+
self._sleep = sleep
|
|
41
|
+
self._monotonic = monotonic
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._next = 0.0
|
|
44
|
+
|
|
45
|
+
def acquire(self) -> None:
|
|
46
|
+
"""Block just long enough that requests stay spaced by the interval."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
now = self._monotonic()
|
|
49
|
+
wait = self._next - now
|
|
50
|
+
self._next = max(now, self._next) + self._interval
|
|
51
|
+
if wait > 0:
|
|
52
|
+
self._sleep(wait)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LightOn(AskMixin, SearchMixin, ParseMixin, ExtractMixin):
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
*,
|
|
60
|
+
config: LightOnConfiguration | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
api_key = api_key or os.environ.get("LIGHTON_API_KEY")
|
|
63
|
+
if not api_key:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"api_key is required (pass api_key= or set LIGHTON_API_KEY)"
|
|
66
|
+
)
|
|
67
|
+
config = config or LightOnConfiguration()
|
|
68
|
+
# httpx.HTTPTransport(retries=) retries connection errors only (exp. backoff).
|
|
69
|
+
# HTTP 429 cooldown/retry is handled separately in _request; 5xx isn't retried.
|
|
70
|
+
transport = config.transport or httpx.HTTPTransport(retries=config.retries)
|
|
71
|
+
self._http = httpx.Client(
|
|
72
|
+
base_url=config.base_url.rstrip("/"),
|
|
73
|
+
timeout=config.timeout,
|
|
74
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
75
|
+
transport=transport,
|
|
76
|
+
)
|
|
77
|
+
self._gate = (
|
|
78
|
+
_RateGate(config.max_requests_per_minute)
|
|
79
|
+
if config.max_requests_per_minute
|
|
80
|
+
else None
|
|
81
|
+
)
|
|
82
|
+
self._rate_limit_retries = config.rate_limit_retries
|
|
83
|
+
|
|
84
|
+
# --- lifecycle ---------------------------------------------------------
|
|
85
|
+
def __enter__(self) -> "LightOn":
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *_exc: object) -> None:
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._http.close()
|
|
93
|
+
|
|
94
|
+
# --- transport ---------------------------------------------------------
|
|
95
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
96
|
+
"""Send a request, raise on error, return parsed JSON (or None for empty 2xx).
|
|
97
|
+
|
|
98
|
+
Paces requests under the configured per-minute cap and, on HTTP 429, waits
|
|
99
|
+
the Retry-After cooldown (or exponential backoff) and retries up to
|
|
100
|
+
`rate_limit_retries` times. All callers route through here, so both the cap
|
|
101
|
+
and the cooldown apply to every endpoint uniformly.
|
|
102
|
+
"""
|
|
103
|
+
for attempt in range(self._rate_limit_retries + 1):
|
|
104
|
+
if self._gate is not None:
|
|
105
|
+
self._gate.acquire()
|
|
106
|
+
try:
|
|
107
|
+
response = self._http.request(method, path, **kwargs)
|
|
108
|
+
except httpx.TransportError as e:
|
|
109
|
+
raise exc.LightOnConnectionError(str(e)) from e
|
|
110
|
+
if response.is_success:
|
|
111
|
+
if not response.content:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return response.json()
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
raise exc.MalformedResponseError(
|
|
117
|
+
f"expected JSON but got: {response.text[:200]!r}"
|
|
118
|
+
) from e
|
|
119
|
+
error = exc.from_response(response)
|
|
120
|
+
if (
|
|
121
|
+
isinstance(error, exc.RateLimitError)
|
|
122
|
+
and attempt < self._rate_limit_retries
|
|
123
|
+
):
|
|
124
|
+
time.sleep(_cooldown(error.retry_after, attempt))
|
|
125
|
+
continue
|
|
126
|
+
raise error
|
|
127
|
+
raise AssertionError("unreachable") # loop either returns or raises
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cooldown(retry_after: float | None, attempt: int) -> float:
|
|
131
|
+
"""Seconds to wait before a 429 retry: honor Retry-After, else backoff+jitter.
|
|
132
|
+
|
|
133
|
+
ponytail: exponential backoff capped at 60s with small jitter; good enough for a
|
|
134
|
+
rate-limit cooldown. Uses `random` for jitter, fine at SDK runtime.
|
|
135
|
+
"""
|
|
136
|
+
if retry_after is not None:
|
|
137
|
+
return retry_after
|
|
138
|
+
return min(60.0, 2.0**attempt) + random.uniform(0.0, 0.5)
|
lighton/apikey.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""API key schema + management (active-record, see `_ActiveRecord`).
|
|
2
|
+
|
|
3
|
+
The plaintext secret (`key`) is returned only by create(), once. list()/get()/
|
|
4
|
+
save()/refresh() never resend it, create() is your only chance to read it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# The list() classmethod shadows builtin list in annotations (class scope).
|
|
10
|
+
from builtins import list as _list
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
15
|
+
|
|
16
|
+
from lighton._active_record import _ActiveRecord
|
|
17
|
+
from lighton.enums import Role
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from lighton._client import LightOn
|
|
21
|
+
|
|
22
|
+
_BASE = "/api/v3/keys"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiKeyScope(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="ignore")
|
|
27
|
+
|
|
28
|
+
workspace_id: int = Field(description="Workspace this scope grants access to.")
|
|
29
|
+
role: Role = Field(description="Access role granted on the workspace.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiKey(_ActiveRecord):
|
|
33
|
+
_base: ClassVar[str] = _BASE
|
|
34
|
+
_resource: ClassVar[str] = "api key"
|
|
35
|
+
|
|
36
|
+
id: str | None = Field(
|
|
37
|
+
None, description="Server-assigned id; None until created/retrieved."
|
|
38
|
+
)
|
|
39
|
+
name: str = Field(description="API key display name.")
|
|
40
|
+
expires_at: datetime | None = Field(
|
|
41
|
+
None, description="Expiry timestamp; None for no expiry."
|
|
42
|
+
)
|
|
43
|
+
scopes: _list[ApiKeyScope] = Field(
|
|
44
|
+
default_factory=list,
|
|
45
|
+
description="Per-workspace access scopes; empty for an unscoped key.",
|
|
46
|
+
)
|
|
47
|
+
# Read-only, populated from responses.
|
|
48
|
+
prefix: str | None = Field(
|
|
49
|
+
None, description="Non-secret key prefix for identification (read-only)."
|
|
50
|
+
)
|
|
51
|
+
created_at: datetime | None = Field(
|
|
52
|
+
None, description="Creation timestamp (read-only)."
|
|
53
|
+
)
|
|
54
|
+
key: SecretStr | None = Field(
|
|
55
|
+
None,
|
|
56
|
+
description="Plaintext secret, returned ONLY by create(), once. Use .get_secret_value().",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# --- instance lifecycle ------------------------------------------------
|
|
60
|
+
def create(self, client: LightOn) -> ApiKey:
|
|
61
|
+
"""Create this API key and bind the client for later lifecycle calls.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
client: The client to create the key with and bind to `self`.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
`self`, updated with the id and the one-time plaintext `key`.
|
|
68
|
+
"""
|
|
69
|
+
payload = {
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
72
|
+
# Providing scopes marks the key workspace-scoped; omit when empty.
|
|
73
|
+
"scopes": [s.model_dump() for s in self.scopes] or None,
|
|
74
|
+
}
|
|
75
|
+
data = client._request("POST", _BASE, json=payload)
|
|
76
|
+
self._client = client
|
|
77
|
+
return self._absorb(data)
|
|
78
|
+
|
|
79
|
+
def save(self) -> ApiKey:
|
|
80
|
+
"""Persist local edits to name/scopes (PATCH).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
`self`, refreshed with the server's response (never re-includes `key`).
|
|
84
|
+
"""
|
|
85
|
+
data = self._api(
|
|
86
|
+
"PATCH",
|
|
87
|
+
f"{_BASE}/{self.id}",
|
|
88
|
+
# `or None`: empty scopes means unscoped, matching create(), not [].
|
|
89
|
+
json={
|
|
90
|
+
"name": self.name,
|
|
91
|
+
"scopes": [s.model_dump() for s in self.scopes] or None,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
return self._absorb(data)
|