lumera 0.1.0__tar.gz
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.
- lumera-0.1.0/PKG-INFO +6 -0
- lumera-0.1.0/lumera.egg-info/PKG-INFO +6 -0
- lumera-0.1.0/lumera.egg-info/SOURCES.txt +7 -0
- lumera-0.1.0/lumera.egg-info/dependency_links.txt +1 -0
- lumera-0.1.0/lumera.egg-info/requires.txt +1 -0
- lumera-0.1.0/lumera.egg-info/top_level.txt +1 -0
- lumera-0.1.0/lumera.py +229 -0
- lumera-0.1.0/pyproject.toml +9 -0
- lumera-0.1.0/setup.cfg +4 -0
lumera-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lumera
|
lumera-0.1.0/lumera.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Minimal client helpers to interact with the Lumera HTTP API.
|
|
2
|
+
|
|
3
|
+
This module focuses on:
|
|
4
|
+
1. Authenticating with the Lumera backend using the personal token kept in the
|
|
5
|
+
LUMERA_TOKEN environment variable (or /root/.env inside the container).
|
|
6
|
+
2. Fetching OAuth / API-key access tokens for any connected provider via the
|
|
7
|
+
`/connections/{provider}/access-token` endpoint (Google, HubSpot, …).
|
|
8
|
+
3. Convenience helpers for uploading local files as documents (existing logic).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import pathlib
|
|
17
|
+
import time as _time
|
|
18
|
+
from typing import Dict, Tuple
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
BASE_URL = "https://app.lumerahq.com/api/documents"
|
|
23
|
+
API_BASE = "https://app.lumerahq.com/api"
|
|
24
|
+
TOKEN_ENV = "LUMERA_TOKEN"
|
|
25
|
+
ENV_PATH = "/root/.env"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Internal helpers
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_token() -> str:
|
|
34
|
+
"""Return the personal Lumera token, loading /root/.env if necessary."""
|
|
35
|
+
|
|
36
|
+
token = os.getenv(TOKEN_ENV)
|
|
37
|
+
if token:
|
|
38
|
+
return token
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with open(ENV_PATH) as fp:
|
|
42
|
+
for line in fp:
|
|
43
|
+
if line.strip().startswith("#") or "=" not in line:
|
|
44
|
+
continue
|
|
45
|
+
k, v = line.strip().split("=", 1)
|
|
46
|
+
if k.strip() == TOKEN_ENV:
|
|
47
|
+
token = v.strip().strip("'\"")
|
|
48
|
+
os.environ[TOKEN_ENV] = token # cache
|
|
49
|
+
return token
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"{TOKEN_ENV} environment variable not set and {ENV_PATH} not found"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Provider-agnostic access-token retrieval
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# _token_cache maps provider → (access_token, expiry_epoch_seconds). For tokens
|
|
64
|
+
# without an explicit expiry (e.g. API keys) we store `float('+inf')` so that
|
|
65
|
+
# they are never considered stale.
|
|
66
|
+
_token_cache: dict[str, Tuple[str, float]] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_expiry(expires_at) -> float:
|
|
70
|
+
"""Convert `expires_at` from the API (may be ISO8601 or epoch) to epoch seconds.
|
|
71
|
+
|
|
72
|
+
Returns +inf if `expires_at` is falsy/None.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if not expires_at:
|
|
76
|
+
return float("inf")
|
|
77
|
+
|
|
78
|
+
if isinstance(expires_at, (int, float)):
|
|
79
|
+
return float(expires_at)
|
|
80
|
+
|
|
81
|
+
# Assume RFC 3339 / ISO 8601 string.
|
|
82
|
+
if isinstance(expires_at, str):
|
|
83
|
+
if expires_at.endswith("Z"):
|
|
84
|
+
expires_at = expires_at[:-1] + "+00:00"
|
|
85
|
+
return _dt.datetime.fromisoformat(expires_at).timestamp()
|
|
86
|
+
|
|
87
|
+
raise TypeError(f"Unsupported expires_at format: {type(expires_at)!r}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fetch_access_token(provider: str) -> Tuple[str, float]:
|
|
91
|
+
"""Call the Lumera API to obtain a valid access token for *provider*."""
|
|
92
|
+
|
|
93
|
+
provider = provider.lower().strip()
|
|
94
|
+
if not provider:
|
|
95
|
+
raise ValueError("provider is required")
|
|
96
|
+
|
|
97
|
+
token = _ensure_token()
|
|
98
|
+
|
|
99
|
+
url = f"{API_BASE}/connections/{provider}/access-token"
|
|
100
|
+
headers = {"Authorization": f"token {token}"}
|
|
101
|
+
|
|
102
|
+
resp = requests.get(url, headers=headers, timeout=30)
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
|
|
105
|
+
data = resp.json()
|
|
106
|
+
access_token = data.get("access_token")
|
|
107
|
+
expires_at = data.get("expires_at")
|
|
108
|
+
|
|
109
|
+
if not access_token:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"Malformed response from Lumera when fetching {provider} access token"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expiry_ts = _parse_expiry(expires_at)
|
|
115
|
+
return access_token, expiry_ts
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_access_token(provider: str, min_valid_seconds: int = 900) -> str:
|
|
119
|
+
"""Return a cached access token for *provider* valid ≥ *min_valid_seconds*.
|
|
120
|
+
|
|
121
|
+
Automatically refreshes tokens via the Lumera API when they are missing or
|
|
122
|
+
close to expiry. For tokens without an expiry (API keys) the first value
|
|
123
|
+
is cached indefinitely.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
global _token_cache
|
|
127
|
+
|
|
128
|
+
provider = provider.lower().strip()
|
|
129
|
+
if not provider:
|
|
130
|
+
raise ValueError("provider is required")
|
|
131
|
+
|
|
132
|
+
now = _time.time()
|
|
133
|
+
|
|
134
|
+
cached = _token_cache.get(provider)
|
|
135
|
+
if cached is not None:
|
|
136
|
+
access_token, expiry_ts = cached
|
|
137
|
+
if (expiry_ts - now) >= min_valid_seconds:
|
|
138
|
+
return access_token
|
|
139
|
+
|
|
140
|
+
# (Re)fetch from server
|
|
141
|
+
access_token, expiry_ts = _fetch_access_token(provider)
|
|
142
|
+
_token_cache[provider] = (access_token, expiry_ts)
|
|
143
|
+
return access_token
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Backwards-compatibility wrapper ------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_google_access_token(min_valid_seconds: int = 900) -> str:
|
|
150
|
+
"""Legacy helper kept for old notebooks – delegates to get_access_token."""
|
|
151
|
+
|
|
152
|
+
return get_access_token("google", min_valid_seconds=min_valid_seconds)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Document upload helper (unchanged apart from minor refactoring)
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pretty_size(size: int) -> str:
|
|
161
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
162
|
+
if size < 1024:
|
|
163
|
+
return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}"
|
|
164
|
+
size /= 1024
|
|
165
|
+
return f"{size:.1f} TB"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def save_to_lumera(file_path: str) -> Dict:
|
|
169
|
+
"""Upload *file_path* to Lumera and return the stored document metadata."""
|
|
170
|
+
|
|
171
|
+
token = _ensure_token()
|
|
172
|
+
|
|
173
|
+
path = pathlib.Path(file_path).expanduser().resolve()
|
|
174
|
+
if not path.is_file():
|
|
175
|
+
raise FileNotFoundError(path)
|
|
176
|
+
|
|
177
|
+
filename = path.name
|
|
178
|
+
size = path.stat().st_size
|
|
179
|
+
mimetype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
180
|
+
pretty = _pretty_size(size)
|
|
181
|
+
|
|
182
|
+
headers = {
|
|
183
|
+
"Authorization": f"token {token}",
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# 1. Create document record -------------------------------------------------
|
|
188
|
+
resp = requests.post(
|
|
189
|
+
BASE_URL,
|
|
190
|
+
json={
|
|
191
|
+
"title": filename,
|
|
192
|
+
"content": f"File to be uploaded: {filename} ({pretty})",
|
|
193
|
+
"type": mimetype.split("/")[-1],
|
|
194
|
+
"status": "uploading",
|
|
195
|
+
},
|
|
196
|
+
headers=headers,
|
|
197
|
+
timeout=30,
|
|
198
|
+
)
|
|
199
|
+
resp.raise_for_status()
|
|
200
|
+
doc = resp.json()
|
|
201
|
+
doc_id = doc["id"]
|
|
202
|
+
|
|
203
|
+
# 2. Obtain signed upload URL ---------------------------------------------
|
|
204
|
+
resp = requests.post(
|
|
205
|
+
f"{BASE_URL}/{doc_id}/upload-url",
|
|
206
|
+
json={"filename": filename, "content_type": mimetype, "size": size},
|
|
207
|
+
headers=headers,
|
|
208
|
+
timeout=30,
|
|
209
|
+
)
|
|
210
|
+
resp.raise_for_status()
|
|
211
|
+
upload_url: str = resp.json()["upload_url"]
|
|
212
|
+
|
|
213
|
+
# 3. PUT bytes to GCS -------------------------------------------------------
|
|
214
|
+
with open(path, "rb") as fp:
|
|
215
|
+
put = requests.put(upload_url, data=fp, headers={"Content-Type": mimetype}, timeout=120)
|
|
216
|
+
put.raise_for_status()
|
|
217
|
+
|
|
218
|
+
# 4. Mark document as uploaded --------------------------------------------
|
|
219
|
+
resp = requests.put(
|
|
220
|
+
f"{BASE_URL}/{doc_id}",
|
|
221
|
+
json={
|
|
222
|
+
"status": "uploaded",
|
|
223
|
+
"content": f"Uploaded file: {filename} ({pretty})\nFile ID: undefined",
|
|
224
|
+
},
|
|
225
|
+
headers=headers,
|
|
226
|
+
timeout=30,
|
|
227
|
+
)
|
|
228
|
+
resp.raise_for_status()
|
|
229
|
+
return resp.json()
|
lumera-0.1.0/setup.cfg
ADDED