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 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)