loki-mode 7.79.0 → 7.80.1

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.
@@ -0,0 +1,305 @@
1
+ """
2
+ Optional cloud LokiStore backends: S3, GCS, Azure Blob.
3
+
4
+ Each backend's SDK is imported ONLY inside its __init__, so:
5
+ - The SDK is never imported at module import time.
6
+ - Local-only users never touch these classes and never pay an import cost.
7
+ - Selecting a cloud backend without its SDK installed raises a clear,
8
+ actionable BackendNotAvailableError naming the missing package.
9
+
10
+ Backends are intentionally thin. They do not add retry/credential frameworks;
11
+ they rely on each SDK's default credential chain (env vars, instance metadata,
12
+ etc.). Keys are normalized identically to LocalStore (traversal rejected) and
13
+ an optional store-level prefix is prepended to every object key.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+ from typing import List, Optional, Union
21
+
22
+ from .base import (
23
+ BackendNotAvailableError,
24
+ LokiStore,
25
+ normalize_key,
26
+ read_source_bytes,
27
+ )
28
+
29
+
30
+ def _join_prefix(prefix: str, key: str) -> str:
31
+ """Join a normalized store prefix and a normalized key into an object key."""
32
+ key = normalize_key(key)
33
+ if not prefix:
34
+ return key
35
+ return f"{prefix.rstrip('/')}/{key}"
36
+
37
+
38
+ def _normalize_prefix(prefix: Optional[str]) -> str:
39
+ """Normalize an optional store prefix to a clean POSIX-style path (or '')."""
40
+ if not prefix:
41
+ return ""
42
+ # Reuse the key guard so a prefix cannot smuggle traversal either.
43
+ return normalize_key(prefix)
44
+
45
+
46
+ def _dir_prefix(obj_prefix: str) -> str:
47
+ """
48
+ Coerce a non-empty object-key prefix to its directory form (slash
49
+ terminated) so cloud list() matches at slash boundaries like LocalStore's
50
+ directory walk, per the LokiStore.list contract. An empty prefix (list
51
+ everything) is returned unchanged.
52
+ """
53
+ if obj_prefix and not obj_prefix.endswith("/"):
54
+ return obj_prefix + "/"
55
+ return obj_prefix
56
+
57
+
58
+ class S3Store(LokiStore):
59
+ """Amazon S3 (or S3-compatible) backend (requires boto3)."""
60
+
61
+ def __init__(
62
+ self,
63
+ bucket: str,
64
+ prefix: Optional[str] = None,
65
+ region: Optional[str] = None,
66
+ ):
67
+ if not bucket:
68
+ raise ValueError("S3Store requires a bucket name")
69
+ try:
70
+ import boto3 # noqa: F401 (imported here so missing dep is lazy)
71
+ except ImportError as exc:
72
+ raise BackendNotAvailableError(
73
+ "The s3 storage backend requires the 'boto3' package. "
74
+ "Install it with: pip install boto3"
75
+ ) from exc
76
+
77
+ self._bucket = bucket
78
+ self._prefix = _normalize_prefix(prefix)
79
+ # boto3 picks up credentials from its default chain (env, shared
80
+ # config, instance/role metadata). region_name is optional.
81
+ kwargs = {"region_name": region} if region else {}
82
+ self._client = boto3.client("s3", **kwargs)
83
+
84
+ def _object_key(self, key: str) -> str:
85
+ return _join_prefix(self._prefix, key)
86
+
87
+ def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
88
+ payload = read_source_bytes(data)
89
+ self._client.put_object(
90
+ Bucket=self._bucket, Key=self._object_key(key), Body=payload
91
+ )
92
+
93
+ def get(self, key: str) -> bytes:
94
+ try:
95
+ resp = self._client.get_object(
96
+ Bucket=self._bucket, Key=self._object_key(key)
97
+ )
98
+ except self._client.exceptions.NoSuchKey as exc:
99
+ raise FileNotFoundError(f"no such key: {key!r}") from exc
100
+ return resp["Body"].read()
101
+
102
+ def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
103
+ dest = Path(dest_path).expanduser()
104
+ dest.parent.mkdir(parents=True, exist_ok=True)
105
+ self._client.download_file(self._bucket, self._object_key(key), str(dest))
106
+
107
+ def exists(self, key: str) -> bool:
108
+ try:
109
+ self._client.head_object(Bucket=self._bucket, Key=self._object_key(key))
110
+ return True
111
+ except Exception:
112
+ # head_object raises ClientError (404) when absent; treat any
113
+ # lookup failure as "not present" for the boolean contract.
114
+ return False
115
+
116
+ def list(self, prefix: str = "") -> List[str]:
117
+ # A caller prefix is matched at slash boundaries (directory form); the
118
+ # store-level prefix, when listing everything, is left as-is.
119
+ obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
120
+ paginator = self._client.get_paginator("list_objects_v2")
121
+ strip = (self._prefix + "/") if self._prefix else ""
122
+ keys: List[str] = []
123
+ for page in paginator.paginate(Bucket=self._bucket, Prefix=obj_prefix):
124
+ for item in page.get("Contents", []):
125
+ full = item["Key"]
126
+ rel = full[len(strip):] if strip and full.startswith(strip) else full
127
+ keys.append(rel)
128
+ keys.sort()
129
+ return keys
130
+
131
+ def delete(self, key: str) -> bool:
132
+ # S3 delete is idempotent and does not signal whether an object
133
+ # existed. Probe first so the boolean contract is honest.
134
+ existed = self.exists(key)
135
+ self._client.delete_object(Bucket=self._bucket, Key=self._object_key(key))
136
+ return existed
137
+
138
+
139
+ class GCSStore(LokiStore):
140
+ """Google Cloud Storage backend (requires google-cloud-storage)."""
141
+
142
+ def __init__(
143
+ self,
144
+ bucket: str,
145
+ prefix: Optional[str] = None,
146
+ region: Optional[str] = None, # accepted for signature parity, unused
147
+ ):
148
+ if not bucket:
149
+ raise ValueError("GCSStore requires a bucket name")
150
+ try:
151
+ from google.cloud import storage # type: ignore
152
+ except ImportError as exc:
153
+ raise BackendNotAvailableError(
154
+ "The gcs storage backend requires the 'google-cloud-storage' "
155
+ "package. Install it with: pip install google-cloud-storage"
156
+ ) from exc
157
+
158
+ self._prefix = _normalize_prefix(prefix)
159
+ # The client uses Application Default Credentials (env, gcloud, or
160
+ # workload identity).
161
+ self._client = storage.Client()
162
+ self._bucket = self._client.bucket(bucket)
163
+
164
+ def _object_key(self, key: str) -> str:
165
+ return _join_prefix(self._prefix, key)
166
+
167
+ def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
168
+ payload = read_source_bytes(data)
169
+ blob = self._bucket.blob(self._object_key(key))
170
+ blob.upload_from_string(payload)
171
+
172
+ def get(self, key: str) -> bytes:
173
+ blob = self._bucket.blob(self._object_key(key))
174
+ if not blob.exists():
175
+ raise FileNotFoundError(f"no such key: {key!r}")
176
+ return blob.download_as_bytes()
177
+
178
+ def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
179
+ dest = Path(dest_path).expanduser()
180
+ dest.parent.mkdir(parents=True, exist_ok=True)
181
+ blob = self._bucket.blob(self._object_key(key))
182
+ if not blob.exists():
183
+ raise FileNotFoundError(f"no such key: {key!r}")
184
+ blob.download_to_filename(str(dest))
185
+
186
+ def exists(self, key: str) -> bool:
187
+ return self._bucket.blob(self._object_key(key)).exists()
188
+
189
+ def list(self, prefix: str = "") -> List[str]:
190
+ obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
191
+ strip = (self._prefix + "/") if self._prefix else ""
192
+ keys: List[str] = []
193
+ for blob in self._client.list_blobs(self._bucket, prefix=obj_prefix):
194
+ full = blob.name
195
+ rel = full[len(strip):] if strip and full.startswith(strip) else full
196
+ keys.append(rel)
197
+ keys.sort()
198
+ return keys
199
+
200
+ def delete(self, key: str) -> bool:
201
+ blob = self._bucket.blob(self._object_key(key))
202
+ if not blob.exists():
203
+ return False
204
+ blob.delete()
205
+ return True
206
+
207
+
208
+ class AzureBlobStore(LokiStore):
209
+ """Azure Blob Storage backend (requires azure-storage-blob)."""
210
+
211
+ def __init__(
212
+ self,
213
+ bucket: str,
214
+ prefix: Optional[str] = None,
215
+ region: Optional[str] = None, # accepted for signature parity, unused
216
+ ):
217
+ # In Azure terms `bucket` is the container name.
218
+ if not bucket:
219
+ raise ValueError("AzureBlobStore requires a container name (bucket)")
220
+ try:
221
+ from azure.storage.blob import ContainerClient # type: ignore
222
+ except ImportError as exc:
223
+ raise BackendNotAvailableError(
224
+ "The azure-blob storage backend requires the "
225
+ "'azure-storage-blob' package. "
226
+ "Install it with: pip install azure-storage-blob"
227
+ ) from exc
228
+
229
+ self._prefix = _normalize_prefix(prefix)
230
+
231
+ # Credentials come from the SDK's default chain. We accept either a
232
+ # full connection string (AZURE_STORAGE_CONNECTION_STRING) or an
233
+ # account URL (AZURE_STORAGE_ACCOUNT_URL) + DefaultAzureCredential.
234
+ conn = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
235
+ account_url = os.environ.get("AZURE_STORAGE_ACCOUNT_URL")
236
+ if conn:
237
+ self._container = ContainerClient.from_connection_string(
238
+ conn, container_name=bucket
239
+ )
240
+ elif account_url:
241
+ try:
242
+ from azure.identity import DefaultAzureCredential # type: ignore
243
+ except ImportError as exc:
244
+ raise BackendNotAvailableError(
245
+ "Azure account-URL auth requires the 'azure-identity' "
246
+ "package. Install it with: pip install azure-identity"
247
+ ) from exc
248
+ self._container = ContainerClient(
249
+ account_url=account_url,
250
+ container_name=bucket,
251
+ credential=DefaultAzureCredential(),
252
+ )
253
+ else:
254
+ raise BackendNotAvailableError(
255
+ "The azure-blob backend needs AZURE_STORAGE_CONNECTION_STRING "
256
+ "or AZURE_STORAGE_ACCOUNT_URL to be set."
257
+ )
258
+
259
+ def _object_key(self, key: str) -> str:
260
+ return _join_prefix(self._prefix, key)
261
+
262
+ def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
263
+ payload = read_source_bytes(data)
264
+ self._container.upload_blob(
265
+ name=self._object_key(key), data=payload, overwrite=True
266
+ )
267
+
268
+ def get(self, key: str) -> bytes:
269
+ from azure.core.exceptions import ResourceNotFoundError # type: ignore
270
+
271
+ try:
272
+ downloader = self._container.download_blob(self._object_key(key))
273
+ except ResourceNotFoundError as exc:
274
+ raise FileNotFoundError(f"no such key: {key!r}") from exc
275
+ return downloader.readall()
276
+
277
+ def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
278
+ payload = self.get(key)
279
+ dest = Path(dest_path).expanduser()
280
+ dest.parent.mkdir(parents=True, exist_ok=True)
281
+ with open(dest, "wb") as f:
282
+ f.write(payload)
283
+
284
+ def exists(self, key: str) -> bool:
285
+ return self._container.get_blob_client(self._object_key(key)).exists()
286
+
287
+ def list(self, prefix: str = "") -> List[str]:
288
+ obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
289
+ strip = (self._prefix + "/") if self._prefix else ""
290
+ keys: List[str] = []
291
+ for blob in self._container.list_blobs(name_starts_with=obj_prefix or None):
292
+ full = blob.name
293
+ rel = full[len(strip):] if strip and full.startswith(strip) else full
294
+ keys.append(rel)
295
+ keys.sort()
296
+ return keys
297
+
298
+ def delete(self, key: str) -> bool:
299
+ from azure.core.exceptions import ResourceNotFoundError # type: ignore
300
+
301
+ try:
302
+ self._container.delete_blob(self._object_key(key))
303
+ return True
304
+ except ResourceNotFoundError:
305
+ return False
@@ -0,0 +1,187 @@
1
+ """
2
+ LokiStore factory: select and construct a backend from config or env.
3
+
4
+ Defaults to local. With nothing configured, get_store() returns a LocalStore
5
+ rooted at the project `.loki/` directory, honoring LOKI_DIR / TARGET_DIR
6
+ exactly like the rest of the codebase. Cloud backends are constructed only when
7
+ explicitly selected, and only then is their SDK imported.
8
+
9
+ Config precedence: an explicit config dict overrides env, env overrides the
10
+ local default. Recognized config keys mirror the env vars (without the
11
+ LOKI_STORAGE_ prefix): backend, bucket, prefix, region, base_dir.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any, Dict, Optional
18
+
19
+ from .base import LokiStore, StoreError
20
+ from .local import LocalStore
21
+
22
+
23
+ def resolve_local_base(explicit_base: Optional[str] = None) -> str:
24
+ """
25
+ Resolve the local `.loki/` base directory exactly like run.sh and the
26
+ dashboard do.
27
+
28
+ Resolution order, matching autonomy/run.sh
29
+ (`${LOKI_DIR:-${TARGET_DIR:-.}/.loki}`):
30
+ 1. explicit_base argument, if given
31
+ 2. $LOKI_DIR, if set (used verbatim, like the shell)
32
+ 3. $TARGET_DIR/.loki, if TARGET_DIR is set
33
+ 4. ./.loki (current working directory)
34
+
35
+ Returns:
36
+ The resolved base directory as a string path. It is NOT created here;
37
+ LocalStore creates parents lazily on first write.
38
+ """
39
+ if explicit_base:
40
+ return explicit_base
41
+ loki_dir = os.environ.get("LOKI_DIR")
42
+ if loki_dir:
43
+ return loki_dir
44
+ target_dir = os.environ.get("TARGET_DIR", ".")
45
+ return os.path.join(target_dir, ".loki")
46
+
47
+
48
+ def _config_from_env() -> Dict[str, Any]:
49
+ """Read the LOKI_STORAGE_* env vars into a config dict (omitting unset)."""
50
+ cfg: Dict[str, Any] = {}
51
+ backend = os.environ.get("LOKI_STORAGE_BACKEND")
52
+ if backend:
53
+ cfg["backend"] = backend
54
+ bucket = os.environ.get("LOKI_STORAGE_BUCKET")
55
+ if bucket:
56
+ cfg["bucket"] = bucket
57
+ prefix = os.environ.get("LOKI_STORAGE_PREFIX")
58
+ if prefix:
59
+ cfg["prefix"] = prefix
60
+ region = os.environ.get("LOKI_STORAGE_REGION")
61
+ if region:
62
+ cfg["region"] = region
63
+ return cfg
64
+
65
+
66
+ def build_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
67
+ """
68
+ Construct a LokiStore from an explicit config dict (no env fallback).
69
+
70
+ Use this when a caller has already assembled config from its own source.
71
+ Prefer get_store() for the standard env-aware path.
72
+
73
+ Recognized keys:
74
+ backend : "local" (default) | "s3" | "gcs" | "azure-blob"
75
+ bucket : bucket/container name (cloud backends)
76
+ prefix : key prefix within the bucket (optional)
77
+ region : region (s3, optional)
78
+ base_dir: local base directory (local backend only; overrides resolution)
79
+ """
80
+ config = dict(config or {})
81
+ backend = (config.get("backend") or "local").strip().lower()
82
+
83
+ if backend in ("local", "", "file", "filesystem"):
84
+ return LocalStore(resolve_local_base(config.get("base_dir")))
85
+
86
+ bucket = config.get("bucket")
87
+ prefix = config.get("prefix")
88
+ region = config.get("region")
89
+
90
+ if backend in ("s3", "aws", "aws-s3"):
91
+ from .cloud import S3Store
92
+
93
+ return S3Store(bucket=bucket, prefix=prefix, region=region)
94
+
95
+ if backend in ("gcs", "gcp", "google", "google-cloud-storage"):
96
+ from .cloud import GCSStore
97
+
98
+ return GCSStore(bucket=bucket, prefix=prefix, region=region)
99
+
100
+ if backend in ("azure-blob", "azure", "azureblob", "azure_blob"):
101
+ from .cloud import AzureBlobStore
102
+
103
+ return AzureBlobStore(bucket=bucket, prefix=prefix, region=region)
104
+
105
+ raise StoreError(
106
+ f"unknown storage backend: {backend!r} "
107
+ "(expected one of: local, s3, gcs, azure-blob)"
108
+ )
109
+
110
+
111
+ def get_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
112
+ """
113
+ Return a LokiStore, defaulting to local.
114
+
115
+ Resolution:
116
+ 1. Start from the LOKI_STORAGE_* env vars.
117
+ 2. Overlay any explicit config dict (config keys win over env).
118
+ 3. If no backend is selected, default to local.
119
+
120
+ With a clean environment and no config, this returns a LocalStore rooted at
121
+ the project `.loki/` -- zero new deps, zero behavior change.
122
+ """
123
+ merged = _config_from_env()
124
+ if config:
125
+ merged.update({k: v for k, v in config.items() if v is not None})
126
+ return build_store(merged)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Metadata backend selector (stub for this release).
131
+ #
132
+ # Blobs (state, checkpoints, artifacts) go through LokiStore above. Structured
133
+ # metadata (the dashboard's task/run index) lives in a relational store. Today
134
+ # that is the existing async-SQLite database in dashboard/database.py at
135
+ # $LOKI_DATA_DIR/dashboard.db. A postgres backend can be added later for shared
136
+ # multi-instance fleets; the selector below is the seam for that, defaulting to
137
+ # sqlite so there is no behavior change now.
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def get_metadata_backend(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
141
+ """
142
+ Resolve the metadata (relational) backend selection.
143
+
144
+ This is intentionally a thin, documented descriptor rather than a live
145
+ connection: the existing dashboard/database.py owns the sqlite engine, and
146
+ a postgres implementation is deferred. Returning a descriptor lets future
147
+ callers branch on the choice without this module importing SQLAlchemy or
148
+ any database driver.
149
+
150
+ Selection (config["metadata_backend"] overrides
151
+ $LOKI_METADATA_BACKEND; default "sqlite"):
152
+ sqlite -> reuse $LOKI_DATA_DIR/dashboard.db (default, implemented today
153
+ in dashboard/database.py)
154
+ postgres -> read $LOKI_METADATA_URL (NOT YET IMPLEMENTED; selecting it
155
+ returns the descriptor with implemented=False so callers can
156
+ fail with a clear message until the impl lands)
157
+
158
+ Returns:
159
+ A descriptor dict: {backend, implemented, dsn, note}.
160
+ """
161
+ config = dict(config or {})
162
+ backend = (
163
+ config.get("metadata_backend")
164
+ or os.environ.get("LOKI_METADATA_BACKEND")
165
+ or "sqlite"
166
+ ).strip().lower()
167
+
168
+ if backend in ("sqlite", "", "default"):
169
+ data_dir = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
170
+ return {
171
+ "backend": "sqlite",
172
+ "implemented": True,
173
+ "dsn": os.path.join(data_dir, "dashboard.db"),
174
+ "note": "reuses dashboard/database.py async-sqlite engine",
175
+ }
176
+
177
+ if backend in ("postgres", "postgresql", "pg"):
178
+ return {
179
+ "backend": "postgres",
180
+ "implemented": False,
181
+ "dsn": config.get("metadata_url") or os.environ.get("LOKI_METADATA_URL"),
182
+ "note": "postgres metadata backend is planned, not yet implemented",
183
+ }
184
+
185
+ raise StoreError(
186
+ f"unknown metadata backend: {backend!r} (expected: sqlite, postgres)"
187
+ )