novence 0.2.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.
novence/__init__.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.error
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_BASE_URL",
|
|
15
|
+
"MIME_BY_EXT",
|
|
16
|
+
"Novence",
|
|
17
|
+
"bootstrap_account",
|
|
18
|
+
"verify_email",
|
|
19
|
+
"resend_verification",
|
|
20
|
+
"reissue_api_key",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
#: Public control plane.
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.novence.ai"
|
|
25
|
+
|
|
26
|
+
#: Extensions the platform accepts, mirroring MIME_BY_EXT in @novence/sdk.
|
|
27
|
+
#: upload_dir skips anything not listed here.
|
|
28
|
+
MIME_BY_EXT = {
|
|
29
|
+
".html": "text/html; charset=utf-8",
|
|
30
|
+
".css": "text/css; charset=utf-8",
|
|
31
|
+
".js": "text/javascript; charset=utf-8",
|
|
32
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
33
|
+
".map": "application/json",
|
|
34
|
+
".json": "application/json",
|
|
35
|
+
".svg": "image/svg+xml",
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".ico": "image/x-icon",
|
|
42
|
+
".woff": "font/woff",
|
|
43
|
+
".woff2": "font/woff2",
|
|
44
|
+
".ttf": "font/ttf",
|
|
45
|
+
".txt": "text/plain; charset=utf-8",
|
|
46
|
+
".xml": "application/xml",
|
|
47
|
+
".webmanifest": "application/manifest+json",
|
|
48
|
+
".mp4": "video/mp4",
|
|
49
|
+
".webm": "video/webm",
|
|
50
|
+
".mov": "video/quicktime",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_base_url(explicit: Optional[str] = None) -> str:
|
|
55
|
+
"""Resolve the control plane URL.
|
|
56
|
+
|
|
57
|
+
Order: explicit argument, then the NOVENCE_BASE_URL environment variable,
|
|
58
|
+
then the public API. Read at call time rather than import time, so setting
|
|
59
|
+
the variable after import still takes effect.
|
|
60
|
+
"""
|
|
61
|
+
raw = explicit or os.environ.get("NOVENCE_BASE_URL") or DEFAULT_BASE_URL
|
|
62
|
+
return raw.rstrip("/")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _post_json(url: str, body: dict) -> dict:
|
|
66
|
+
"""POST JSON and translate HTTP errors into RuntimeError with the API's message."""
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
url,
|
|
69
|
+
data=json.dumps(body).encode("utf-8"),
|
|
70
|
+
method="POST",
|
|
71
|
+
headers={"Content-Type": "application/json"},
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req) as resp:
|
|
75
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
raise RuntimeError(_error_message(exc)) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _error_message(exc: urllib.error.HTTPError) -> str:
|
|
81
|
+
payload = exc.read().decode("utf-8")
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(payload).get("error", payload)
|
|
84
|
+
except Exception:
|
|
85
|
+
return payload or f"HTTP {exc.code}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Novence:
|
|
90
|
+
"""Client for the Novence control plane.
|
|
91
|
+
|
|
92
|
+
``base_url`` defaults to https://api.novence.ai. Pass it explicitly, or set
|
|
93
|
+
NOVENCE_BASE_URL, to target a different control plane (e.g. a local API).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
api_key: str
|
|
97
|
+
base_url: str = field(default_factory=_resolve_base_url)
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
# Normalise whatever the caller passed, including a trailing slash, and
|
|
101
|
+
# fall back to the environment when given an empty value.
|
|
102
|
+
self.base_url = _resolve_base_url(self.base_url)
|
|
103
|
+
|
|
104
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
105
|
+
url = self.base_url + path
|
|
106
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
107
|
+
req = urllib.request.Request(
|
|
108
|
+
url,
|
|
109
|
+
data=data,
|
|
110
|
+
method=method,
|
|
111
|
+
headers={
|
|
112
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(req) as resp:
|
|
118
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
raise RuntimeError(_error_message(exc)) from exc
|
|
121
|
+
|
|
122
|
+
def create_project(self, name: str, description: Optional[str] = None) -> dict:
|
|
123
|
+
body: dict[str, Any] = {"name": name}
|
|
124
|
+
if description:
|
|
125
|
+
body["description"] = description
|
|
126
|
+
return self._request("POST", "/v1/projects", body)
|
|
127
|
+
|
|
128
|
+
def list_projects(self) -> dict:
|
|
129
|
+
return self._request("GET", "/v1/projects")
|
|
130
|
+
|
|
131
|
+
def get_project(self, project_id: str) -> dict:
|
|
132
|
+
return self._request("GET", f"/v1/projects/{project_id}")
|
|
133
|
+
|
|
134
|
+
def update_project(
|
|
135
|
+
self,
|
|
136
|
+
project_id: str,
|
|
137
|
+
name: Optional[str] = None,
|
|
138
|
+
description: Optional[str] = None,
|
|
139
|
+
check_thresholds: Optional[dict] = None,
|
|
140
|
+
analytics_enabled: Optional[bool] = None,
|
|
141
|
+
) -> dict:
|
|
142
|
+
body: dict[str, Any] = {}
|
|
143
|
+
if name is not None:
|
|
144
|
+
body["name"] = name
|
|
145
|
+
if description is not None:
|
|
146
|
+
body["description"] = description
|
|
147
|
+
if check_thresholds is not None:
|
|
148
|
+
body["checkThresholds"] = check_thresholds
|
|
149
|
+
if analytics_enabled is not None:
|
|
150
|
+
body["analyticsEnabled"] = analytics_enabled
|
|
151
|
+
return self._request("PATCH", f"/v1/projects/{project_id}", body)
|
|
152
|
+
|
|
153
|
+
def project_usage(self, project_id: str) -> dict:
|
|
154
|
+
"""Storage, deploys, check minutes, bandwidth for one project.
|
|
155
|
+
|
|
156
|
+
Prefer usage["storage_bytes"] over the rounded storage_gb.
|
|
157
|
+
"""
|
|
158
|
+
return self._request("GET", f"/v1/projects/{project_id}/usage")
|
|
159
|
+
|
|
160
|
+
def project_analytics(
|
|
161
|
+
self, project_id: str, from_: Optional[str] = None, to: Optional[str] = None
|
|
162
|
+
) -> dict:
|
|
163
|
+
"""Cookieless traffic. Off by default — enable with
|
|
164
|
+
update_project(analytics_enabled=True)."""
|
|
165
|
+
query = urllib.parse.urlencode(
|
|
166
|
+
{k: v for k, v in (("from", from_), ("to", to)) if v}
|
|
167
|
+
)
|
|
168
|
+
suffix = f"?{query}" if query else ""
|
|
169
|
+
return self._request("GET", f"/v1/projects/{project_id}/analytics{suffix}")
|
|
170
|
+
|
|
171
|
+
def get_upload_url(self, project_id: str, path: str, content_type: str) -> dict:
|
|
172
|
+
return self._request(
|
|
173
|
+
"POST",
|
|
174
|
+
f"/v1/projects/{project_id}/uploads",
|
|
175
|
+
{"path": path, "contentType": content_type},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def confirm_upload(self, project_id: str, path: str, content_type: str) -> dict:
|
|
179
|
+
return self._request(
|
|
180
|
+
"POST",
|
|
181
|
+
f"/v1/projects/{project_id}/uploads/confirm",
|
|
182
|
+
{"path": path, "contentType": content_type},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def get_upload_urls_batch(self, project_id: str, files: list) -> dict:
|
|
186
|
+
"""Presign many uploads at once. Each entry: {"path", "contentType"}."""
|
|
187
|
+
return self._request(
|
|
188
|
+
"POST", f"/v1/projects/{project_id}/uploads/batch", {"files": files}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def confirm_batch(self, project_id: str, files: list) -> dict:
|
|
192
|
+
"""Confirm many uploads. Check the "errors" list — retry only those paths."""
|
|
193
|
+
return self._request(
|
|
194
|
+
"POST", f"/v1/projects/{project_id}/uploads/confirm-batch", {"files": files}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def upload_bytes(
|
|
198
|
+
self, project_id: str, path: str, content_type: Optional[str], content: bytes
|
|
199
|
+
) -> dict:
|
|
200
|
+
resolved = content_type or MIME_BY_EXT.get(
|
|
201
|
+
Path(path).suffix.lower(), "application/octet-stream"
|
|
202
|
+
)
|
|
203
|
+
upload = self.get_upload_url(project_id, path, resolved)
|
|
204
|
+
req = urllib.request.Request(
|
|
205
|
+
upload["uploadUrl"],
|
|
206
|
+
data=content,
|
|
207
|
+
method="PUT",
|
|
208
|
+
headers={"Content-Type": resolved},
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
with urllib.request.urlopen(req):
|
|
212
|
+
pass
|
|
213
|
+
except urllib.error.HTTPError as exc:
|
|
214
|
+
raise RuntimeError(f"Upload failed for {path}: HTTP {exc.code}") from exc
|
|
215
|
+
return self.confirm_upload(project_id, path, resolved)
|
|
216
|
+
|
|
217
|
+
def upload_file(
|
|
218
|
+
self, project_id: str, local_path: str, site_path: Optional[str] = None
|
|
219
|
+
) -> dict:
|
|
220
|
+
"""Upload one file from disk. site_path defaults to /<filename>."""
|
|
221
|
+
data = Path(local_path).read_bytes()
|
|
222
|
+
target = site_path or "/" + Path(local_path).name
|
|
223
|
+
return self.upload_bytes(project_id, target, None, data)
|
|
224
|
+
|
|
225
|
+
def upload_dir(self, project_id: str, directory: str) -> dict:
|
|
226
|
+
"""Upload a directory tree.
|
|
227
|
+
|
|
228
|
+
Mirrors uploadDir in @novence/sdk: dotfiles and dot-directories are
|
|
229
|
+
skipped, and so is any extension not in MIME_BY_EXT (the platform would
|
|
230
|
+
reject it). Returns {"uploaded": [site paths], "skipped": [local paths]}.
|
|
231
|
+
"""
|
|
232
|
+
root = Path(directory)
|
|
233
|
+
if not root.is_dir():
|
|
234
|
+
raise RuntimeError(f"Not a directory: {directory}")
|
|
235
|
+
|
|
236
|
+
uploaded: list[str] = []
|
|
237
|
+
skipped: list[str] = []
|
|
238
|
+
for full in sorted(root.rglob("*")):
|
|
239
|
+
if not full.is_file():
|
|
240
|
+
continue
|
|
241
|
+
rel_parts = full.relative_to(root).parts
|
|
242
|
+
if any(part.startswith(".") for part in rel_parts):
|
|
243
|
+
continue
|
|
244
|
+
if full.suffix.lower() not in MIME_BY_EXT:
|
|
245
|
+
skipped.append(str(full))
|
|
246
|
+
continue
|
|
247
|
+
site_path = "/" + "/".join(rel_parts)
|
|
248
|
+
self.upload_bytes(project_id, site_path, None, full.read_bytes())
|
|
249
|
+
uploaded.append(site_path)
|
|
250
|
+
return {"uploaded": uploaded, "skipped": skipped}
|
|
251
|
+
|
|
252
|
+
def deploy(self, project_id: str, force: bool = False) -> dict:
|
|
253
|
+
"""Queue a deployment.
|
|
254
|
+
|
|
255
|
+
Deploys are asynchronous: this returns as soon as the deployment is
|
|
256
|
+
created, usually with status ``queued``. Use :meth:`wait_for_deployment`
|
|
257
|
+
(or poll :meth:`latest_deployment`) to find out whether it went live.
|
|
258
|
+
"""
|
|
259
|
+
return self._request(
|
|
260
|
+
"POST", f"/v1/projects/{project_id}/deployments", {"force": force}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def latest_deployment(self, project_id: str) -> dict:
|
|
264
|
+
"""Current state of the most recent deployment."""
|
|
265
|
+
return self._request("GET", f"/v1/projects/{project_id}/deployments/latest")
|
|
266
|
+
|
|
267
|
+
def wait_for_deployment(
|
|
268
|
+
self, project_id: str, timeout: float = 300.0, interval: float = 2.0
|
|
269
|
+
) -> dict:
|
|
270
|
+
"""Poll until the deployment is live, and return it.
|
|
271
|
+
|
|
272
|
+
Raises RuntimeError if the deploy fails or ``timeout`` seconds elapse.
|
|
273
|
+
Note that ``checks_results["passed"]`` is None when checks were skipped
|
|
274
|
+
(unverified accounts) — that is not a pass.
|
|
275
|
+
"""
|
|
276
|
+
deadline = time.monotonic() + timeout
|
|
277
|
+
seen = False
|
|
278
|
+
while time.monotonic() < deadline:
|
|
279
|
+
try:
|
|
280
|
+
deployment = self.latest_deployment(project_id).get("deployment", {})
|
|
281
|
+
except RuntimeError as exc:
|
|
282
|
+
# A deployment created moments ago may not be readable yet, so a
|
|
283
|
+
# not-found is "not yet" rather than fatal. The timeout still
|
|
284
|
+
# bounds the wait.
|
|
285
|
+
if "not found" not in str(exc).lower():
|
|
286
|
+
raise
|
|
287
|
+
time.sleep(interval)
|
|
288
|
+
continue
|
|
289
|
+
seen = True
|
|
290
|
+
status = deployment.get("status")
|
|
291
|
+
if status == "live":
|
|
292
|
+
return deployment
|
|
293
|
+
if status == "failed":
|
|
294
|
+
raise RuntimeError(deployment.get("errorMessage") or "Deploy failed")
|
|
295
|
+
time.sleep(interval)
|
|
296
|
+
|
|
297
|
+
if not seen:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
f"No deployment found for project {project_id} after {timeout:.0f}s "
|
|
300
|
+
"— did you call deploy()?"
|
|
301
|
+
)
|
|
302
|
+
raise RuntimeError(f"Deploy did not go live within {timeout:.0f}s")
|
|
303
|
+
|
|
304
|
+
def configure_domain(
|
|
305
|
+
self,
|
|
306
|
+
project_id: str,
|
|
307
|
+
hostname: str,
|
|
308
|
+
primary: Optional[str] = None,
|
|
309
|
+
redirect: Optional[bool] = None,
|
|
310
|
+
) -> dict:
|
|
311
|
+
"""Attach a custom domain. Returns DNS records to create.
|
|
312
|
+
|
|
313
|
+
primary is "www" or "apex" (default www).
|
|
314
|
+
"""
|
|
315
|
+
body: dict[str, Any] = {"hostname": hostname}
|
|
316
|
+
if primary is not None:
|
|
317
|
+
body["primary"] = primary
|
|
318
|
+
if redirect is not None:
|
|
319
|
+
body["redirect"] = redirect
|
|
320
|
+
return self._request("POST", f"/v1/projects/{project_id}/domains", body)
|
|
321
|
+
|
|
322
|
+
def domain_status(self, project_id: str) -> dict:
|
|
323
|
+
return self._request("GET", f"/v1/projects/{project_id}/domains")
|
|
324
|
+
|
|
325
|
+
def create_form(
|
|
326
|
+
self,
|
|
327
|
+
project_id: str,
|
|
328
|
+
name: str,
|
|
329
|
+
fields: Optional[list] = None,
|
|
330
|
+
slug: Optional[str] = None,
|
|
331
|
+
notify_email: Optional[str] = None,
|
|
332
|
+
) -> dict:
|
|
333
|
+
body: dict[str, Any] = {"name": name, "fields": fields or []}
|
|
334
|
+
if slug:
|
|
335
|
+
body["slug"] = slug
|
|
336
|
+
if notify_email is not None:
|
|
337
|
+
body["notifyEmail"] = notify_email
|
|
338
|
+
return self._request("POST", f"/v1/projects/{project_id}/forms", body)
|
|
339
|
+
|
|
340
|
+
def list_forms(self, project_id: str) -> dict:
|
|
341
|
+
return self._request("GET", f"/v1/projects/{project_id}/forms")
|
|
342
|
+
|
|
343
|
+
def get_form(self, project_id: str, form_id: str) -> dict:
|
|
344
|
+
return self._request("GET", f"/v1/projects/{project_id}/forms/{form_id}")
|
|
345
|
+
|
|
346
|
+
def update_form(
|
|
347
|
+
self,
|
|
348
|
+
project_id: str,
|
|
349
|
+
form_id: str,
|
|
350
|
+
name: Optional[str] = None,
|
|
351
|
+
slug: Optional[str] = None,
|
|
352
|
+
fields: Optional[list] = None,
|
|
353
|
+
notify_email: Optional[str] = None,
|
|
354
|
+
honeypot_field: Optional[str] = None,
|
|
355
|
+
status: Optional[str] = None,
|
|
356
|
+
) -> dict:
|
|
357
|
+
body: dict[str, Any] = {}
|
|
358
|
+
if name is not None:
|
|
359
|
+
body["name"] = name
|
|
360
|
+
if slug is not None:
|
|
361
|
+
body["slug"] = slug
|
|
362
|
+
if fields is not None:
|
|
363
|
+
body["fields"] = fields
|
|
364
|
+
if notify_email is not None:
|
|
365
|
+
body["notifyEmail"] = notify_email
|
|
366
|
+
if honeypot_field is not None:
|
|
367
|
+
body["honeypotField"] = honeypot_field
|
|
368
|
+
if status is not None:
|
|
369
|
+
body["status"] = status
|
|
370
|
+
return self._request(
|
|
371
|
+
"PATCH", f"/v1/projects/{project_id}/forms/{form_id}", body
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def delete_form(self, project_id: str, form_id: str) -> dict:
|
|
375
|
+
"""Soft-delete a form."""
|
|
376
|
+
return self._request("DELETE", f"/v1/projects/{project_id}/forms/{form_id}")
|
|
377
|
+
|
|
378
|
+
def list_form_submissions(
|
|
379
|
+
self, project_id: str, form_id: str, limit: int = 50, offset: int = 0
|
|
380
|
+
) -> dict:
|
|
381
|
+
return self._request(
|
|
382
|
+
"GET",
|
|
383
|
+
f"/v1/projects/{project_id}/forms/{form_id}/submissions?limit={limit}&offset={offset}",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def delete_form_submission(
|
|
387
|
+
self, project_id: str, form_id: str, submission_id: str
|
|
388
|
+
) -> dict:
|
|
389
|
+
return self._request(
|
|
390
|
+
"DELETE",
|
|
391
|
+
f"/v1/projects/{project_id}/forms/{form_id}/submissions/{submission_id}",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def quotas(self) -> dict:
|
|
395
|
+
"""Plan caps and current usage."""
|
|
396
|
+
return self._request("GET", "/v1/billing/quotas")
|
|
397
|
+
|
|
398
|
+
def checkout(
|
|
399
|
+
self,
|
|
400
|
+
plan: str = "pro",
|
|
401
|
+
success_url: Optional[str] = None,
|
|
402
|
+
cancel_url: Optional[str] = None,
|
|
403
|
+
) -> dict:
|
|
404
|
+
"""Stripe Checkout session for a human with a card."""
|
|
405
|
+
body: dict[str, Any] = {"plan": plan}
|
|
406
|
+
if success_url:
|
|
407
|
+
body["successUrl"] = success_url
|
|
408
|
+
if cancel_url:
|
|
409
|
+
body["cancelUrl"] = cancel_url
|
|
410
|
+
return self._request("POST", "/v1/billing/checkout", body)
|
|
411
|
+
|
|
412
|
+
def billing_portal(self, return_url: Optional[str] = None) -> dict:
|
|
413
|
+
"""Stripe Customer Portal link."""
|
|
414
|
+
body: dict[str, Any] = {}
|
|
415
|
+
if return_url:
|
|
416
|
+
body["returnUrl"] = return_url
|
|
417
|
+
return self._request("POST", "/v1/billing/portal", body)
|
|
418
|
+
|
|
419
|
+
def mpp_upgrade(self, plan: str = "pro") -> dict:
|
|
420
|
+
"""Agent subscription via MPP/SPT.
|
|
421
|
+
|
|
422
|
+
Unlike the other methods this does not raise on a non-2xx: the 402 with
|
|
423
|
+
a WWW-Authenticate challenge is the expected path for agents. Returns
|
|
424
|
+
{"status", "wwwAuthenticate", "data"}.
|
|
425
|
+
"""
|
|
426
|
+
req = urllib.request.Request(
|
|
427
|
+
self.base_url + "/v1/billing/mpp",
|
|
428
|
+
data=json.dumps({"plan": plan}).encode("utf-8"),
|
|
429
|
+
method="POST",
|
|
430
|
+
headers={
|
|
431
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
432
|
+
"Content-Type": "application/json",
|
|
433
|
+
"Accept": "application/json",
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
with urllib.request.urlopen(req) as resp:
|
|
438
|
+
status, headers, payload = resp.status, resp.headers, resp.read()
|
|
439
|
+
except urllib.error.HTTPError as exc:
|
|
440
|
+
status, headers, payload = exc.code, exc.headers, exc.read()
|
|
441
|
+
try:
|
|
442
|
+
data = json.loads(payload.decode("utf-8"))
|
|
443
|
+
except Exception:
|
|
444
|
+
data = {}
|
|
445
|
+
return {
|
|
446
|
+
"status": status,
|
|
447
|
+
"wwwAuthenticate": headers.get("WWW-Authenticate"),
|
|
448
|
+
"data": data,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def get_account(self) -> dict:
|
|
452
|
+
"""Plan, verification, quotas, and projects across the account."""
|
|
453
|
+
return self._request("GET", "/v1/account")
|
|
454
|
+
|
|
455
|
+
def account_console_kit(self) -> dict:
|
|
456
|
+
"""Local HTML console kit. Serve on 127.0.0.1 — never inject an nv_ key."""
|
|
457
|
+
return self._request("GET", "/v1/account/console-kit")
|
|
458
|
+
|
|
459
|
+
def create_session(self) -> dict:
|
|
460
|
+
"""Mint a short-lived mgmt_ token for a browser console.
|
|
461
|
+
|
|
462
|
+
Call this with an ``nv_`` key. Never put the ``nv_`` key itself in HTML —
|
|
463
|
+
inject only the returned mgmt_ token.
|
|
464
|
+
"""
|
|
465
|
+
return self._request("POST", "/v1/account/sessions", {})
|
|
466
|
+
|
|
467
|
+
def revoke_session(self) -> dict:
|
|
468
|
+
"""Revoke the mgmt_ session this client is authenticated with.
|
|
469
|
+
|
|
470
|
+
Requires a client constructed with a ``mgmt_`` token — calling it with an
|
|
471
|
+
``nv_`` API key raises "Only management sessions can be revoked via this
|
|
472
|
+
endpoint". To end a console session:
|
|
473
|
+
|
|
474
|
+
session = Novence(api_key=key).create_session()
|
|
475
|
+
Novence(api_key=session["token"]).revoke_session()
|
|
476
|
+
"""
|
|
477
|
+
return self._request("DELETE", "/v1/account/sessions/current")
|
|
478
|
+
|
|
479
|
+
def revoke_api_key(self, api_key_id: str) -> dict:
|
|
480
|
+
return self._request("DELETE", f"/v1/api-keys/{api_key_id}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def bootstrap_account(email: str, base_url: Optional[str] = None) -> dict:
|
|
484
|
+
return _post_json(_resolve_base_url(base_url) + "/v1/bootstrap", {"email": email})
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def verify_email(email: str, code: str, base_url: Optional[str] = None) -> dict:
|
|
488
|
+
return _post_json(
|
|
489
|
+
_resolve_base_url(base_url) + "/v1/auth/verify-email",
|
|
490
|
+
{"email": email, "code": code},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def resend_verification(email: str, base_url: Optional[str] = None) -> dict:
|
|
495
|
+
"""Re-send the email OTP. Returns {"sent": true} even if already verified."""
|
|
496
|
+
return _post_json(
|
|
497
|
+
_resolve_base_url(base_url) + "/v1/auth/resend-verification", {"email": email}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def reissue_api_key(email: str, code: str, base_url: Optional[str] = None) -> dict:
|
|
502
|
+
"""Recover a lost key with an OTP.
|
|
503
|
+
|
|
504
|
+
This also verifies the email — do not spend a second OTP on verify_email.
|
|
505
|
+
"""
|
|
506
|
+
return _post_json(
|
|
507
|
+
_resolve_base_url(base_url) + "/v1/auth/reissue-key",
|
|
508
|
+
{"email": email, "code": code},
|
|
509
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: novence
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for Novence — static site hosting for AI agents
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://novence.ai
|
|
7
|
+
Project-URL: Documentation, https://novence.ai/docs
|
|
8
|
+
Project-URL: Issues, https://novence.ai/support
|
|
9
|
+
Keywords: novence,static-site-hosting,deploy,hosting,edge,ai-agents
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# novence (Python)
|
|
26
|
+
|
|
27
|
+
Python SDK for [Novence](https://novence.ai) — static site hosting built for AI agents.
|
|
28
|
+
|
|
29
|
+
> Not published to PyPI yet. Use it from the repo for now.
|
|
30
|
+
|
|
31
|
+
Requires Python 3.10+. No third-party dependencies — standard library only.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from novence import Novence, bootstrap_account, verify_email
|
|
37
|
+
|
|
38
|
+
boot = bootstrap_account("you@example.com") # emails a 6-digit OTP
|
|
39
|
+
verify_email("you@example.com", "123456")
|
|
40
|
+
|
|
41
|
+
client = Novence(api_key=boot["apiKey"])
|
|
42
|
+
project = client.create_project("launch")
|
|
43
|
+
pid = project["project"]["id"]
|
|
44
|
+
|
|
45
|
+
client.upload_bytes(pid, "index.html", "text/html; charset=utf-8", b"<h1>hi</h1>")
|
|
46
|
+
client.deploy(pid)
|
|
47
|
+
|
|
48
|
+
deployment = client.wait_for_deployment(pid) # polls until live or failed
|
|
49
|
+
print(deployment["liveUrl"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
`base_url` defaults to `https://api.novence.ai`. Override per client, or set
|
|
55
|
+
`NOVENCE_BASE_URL` to point everything at a different control plane:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
client = Novence(api_key=key, base_url="http://127.0.0.1:8080")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The variable is read at call time, so setting it after import still works.
|
|
62
|
+
|
|
63
|
+
## Deploys are asynchronous
|
|
64
|
+
|
|
65
|
+
`deploy()` returns as soon as the deployment is queued. Use
|
|
66
|
+
`wait_for_deployment(project_id, timeout=300, interval=2)` to block until it is
|
|
67
|
+
live, or poll `latest_deployment(project_id)` yourself.
|
|
68
|
+
|
|
69
|
+
`checks_results["passed"]` is `None` when checks were **skipped** (unverified
|
|
70
|
+
accounts). That is not a pass.
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
| Area | Methods |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| Projects | `create_project` `list_projects` `get_project` `update_project` `project_usage` `project_analytics` |
|
|
77
|
+
| Uploads | `upload_dir` `upload_file` `upload_bytes` `get_upload_url` `get_upload_urls_batch` `confirm_upload` `confirm_batch` |
|
|
78
|
+
| Deploys | `deploy` `latest_deployment` `wait_for_deployment` |
|
|
79
|
+
| Domains | `configure_domain` `domain_status` |
|
|
80
|
+
| Forms | `create_form` `list_forms` `get_form` `update_form` `delete_form` `list_form_submissions` `delete_form_submission` |
|
|
81
|
+
| Billing | `quotas` `checkout` `billing_portal` `mpp_upgrade` |
|
|
82
|
+
| Account | `get_account` `account_console_kit` `create_session` `revoke_session` `revoke_api_key` |
|
|
83
|
+
|
|
84
|
+
Module-level: `bootstrap_account` `verify_email` `resend_verification` `reissue_api_key`.
|
|
85
|
+
|
|
86
|
+
`reissue_api_key` issues a new key and revokes the old one — rebuild your client
|
|
87
|
+
with the returned key.
|
|
88
|
+
|
|
89
|
+
## Uploading a directory
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
result = client.upload_dir(pid, "./dist")
|
|
93
|
+
result["uploaded"] # site paths that went up
|
|
94
|
+
result["skipped"] # local files whose extension the platform does not accept
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Dotfiles and dot-directories (`.git`, `.env`) are skipped, matching the
|
|
98
|
+
TypeScript SDK.
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
Every API error raises `RuntimeError` carrying the API's message — including
|
|
103
|
+
rate limits and quota rejections.
|
|
104
|
+
|
|
105
|
+
## Security
|
|
106
|
+
|
|
107
|
+
`nv_` keys are server-side credentials. Read them from the environment; never
|
|
108
|
+
commit one or put one in client-side code.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
novence/__init__.py,sha256=E1LxbXgT4PcLagMtXVPCB7GSYQAKzvrYVRVH3glzdSM,18834
|
|
2
|
+
novence-0.2.0.dist-info/licenses/LICENSE,sha256=abe2-16akQ6tE777rsJ7SQ3QB7Ya2A4c0yuVwgHTg0k,1064
|
|
3
|
+
novence-0.2.0.dist-info/METADATA,sha256=haIGsLcvqGB10INiBvAY5rA3g-SLN_Vo2S28l74Cp8A,3879
|
|
4
|
+
novence-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
novence-0.2.0.dist-info/top_level.txt,sha256=6DYz264fYi7FPoRN-Qeypglpap69OAXjuytYtaiaHoo,8
|
|
6
|
+
novence-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Novence
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
novence
|