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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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