loopcut 0.1.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.
@@ -0,0 +1,256 @@
1
+ """The service contract in one place, independent of Blender's UI and worker threads.
2
+
3
+ Transports have urlopen's signature: transport(Request, timeout=seconds) returns a context
4
+ manager with status, headers and read(). Tests inject one; production uses urllib with
5
+ redirects disabled so a service redirect cannot forward the account key to another host.
6
+ """
7
+
8
+ import errno
9
+ import json
10
+ import http.client
11
+ import re
12
+ import socket
13
+ import ssl
14
+ import threading
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+
21
+ from . import bl_info, config, credentials
22
+
23
+ CONTRACT = "0.5"
24
+ MAX_RESPONSE = 4 * 1024 * 1024
25
+
26
+
27
+ class CloudError(RuntimeError):
28
+ """A contract error, retaining fields callers need for credits, retry and upgrades."""
29
+
30
+ def __init__(self, message, code, status=None, extra=None, error_type=None, detail=None):
31
+ super().__init__(message)
32
+ self.code = code
33
+ self.status = status
34
+ self.extra = dict(extra or {})
35
+ self.error_type = error_type
36
+ self.detail = detail # A transport failure's describe() text; never a URL, header or key.
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Response:
41
+ data: dict | None
42
+ status: int
43
+ headers: dict
44
+
45
+
46
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
47
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
48
+ return None
49
+
50
+
51
+ def urllib_transport(request, *, timeout):
52
+ return urllib.request.build_opener(_NoRedirect()).open(request, timeout=timeout)
53
+
54
+
55
+ def validate_url(url):
56
+ """TLS for credentials and blobs; plaintext is only for loopback development."""
57
+ if not isinstance(url, str) or any(ord(c) <= 32 or ord(c) == 127 for c in url):
58
+ raise ValueError("Invalid HTTP URL")
59
+ parts = urllib.parse.urlsplit(url)
60
+ if (parts.scheme not in {"http", "https"} or not parts.hostname or parts.username is not None
61
+ or parts.password is not None or parts.fragment or parts.port == 0
62
+ or parts.scheme == "http" and parts.hostname not in {"localhost", "127.0.0.1", "::1"}):
63
+ raise ValueError("Invalid HTTP URL")
64
+ return parts
65
+
66
+
67
+ def contract_headers():
68
+ if threading.current_thread() is not threading.main_thread():
69
+ from . import mainthread
70
+ return mainthread.run_on_main(contract_headers).result(timeout=30)
71
+ import bpy
72
+ version = getattr(getattr(bpy, "app", None), "version", ())
73
+ blender = ".".join(map(str, version)) if version else "unknown"
74
+ addon = ".".join(map(str, bl_info["version"]))
75
+ return {"x-loopcut-contract": CONTRACT,
76
+ "x-loopcut-client": f"loopcut-addon/{addon} blender/{blender}"}
77
+
78
+
79
+ def describe(error):
80
+ """A transport failure as class plus errno or SSL reason, e.g. BrokenPipeError [Errno 32 EPIPE].
81
+
82
+ Never the exception's text: socket and HTTP messages can carry keys or signed URLs.
83
+ """
84
+ if isinstance(error, urllib.error.URLError) and isinstance(error.reason, BaseException):
85
+ error = error.reason
86
+ name = type(error).__name__
87
+ if isinstance(error, ssl.SSLError):
88
+ reason = getattr(error, "reason", None)
89
+ if not isinstance(reason, str):
90
+ found = re.search(r"\[SSL: ([A-Z0-9_]{1,64})\]", str(error))
91
+ reason = found and found[1]
92
+ return f"{name} {reason}" if isinstance(reason, str) and re.fullmatch(r"[A-Z0-9_]{1,64}", reason) else name
93
+ if isinstance(error, TimeoutError):
94
+ return f"{name} (timed out)"
95
+ if isinstance(error, socket.gaierror):
96
+ return f"{name} (name lookup failed)"
97
+ if isinstance(error, OSError) and isinstance(error.errno, int):
98
+ symbol = errno.errorcode.get(error.errno)
99
+ return f"{name} [Errno {error.errno}{' ' + symbol if symbol else ''}]"
100
+ return name
101
+
102
+
103
+ def _unreachable(error, request):
104
+ cause = describe(error)
105
+ print(f"Loopcut: {request.get_method()} request failed in transport: {cause}")
106
+ return CloudError(f"Could not reach the Loopcut service ({cause})", "connection_failed", detail=cause)
107
+
108
+
109
+ def _read(response):
110
+ body = response.read(MAX_RESPONSE + 1)
111
+ if len(body) > MAX_RESPONSE:
112
+ raise CloudError("Loopcut response is too large", "invalid_response", response.status)
113
+ return body
114
+
115
+
116
+ def _redact(value, secrets):
117
+ if isinstance(value, str):
118
+ for secret in secrets:
119
+ value = value.replace(secret, "[redacted]")
120
+ elif isinstance(value, dict):
121
+ value = {_redact(k, secrets): _redact(v, secrets) for k, v in value.items()}
122
+ elif isinstance(value, list):
123
+ value = [_redact(v, secrets) for v in value]
124
+ return value
125
+
126
+
127
+ def _error(status, body, request):
128
+ try:
129
+ payload = json.loads(body)
130
+ except (ValueError, UnicodeError, RecursionError):
131
+ payload = None
132
+ # Error envelopes are untrusted, even when they echo the Authorization header.
133
+ secrets = [value.removeprefix("Bearer ") for name, value in request.header_items()
134
+ if name.lower() == "authorization" and value]
135
+ query = urllib.parse.urlsplit(request.full_url).query
136
+ if query:
137
+ secrets.extend([request.full_url, query, *[v for _, v in urllib.parse.parse_qsl(query) if v]])
138
+ try:
139
+ payload = _redact(payload, sorted(set(secrets), key=len, reverse=True))
140
+ except RecursionError:
141
+ return CloudError(f"HTTP {status}", "http_error", status)
142
+ error = payload.get("error") if isinstance(payload, dict) else None
143
+ if not isinstance(error, dict):
144
+ return CloudError(f"HTTP {status}", "http_error", status)
145
+ message = error.get("message")
146
+ code = error.get("code")
147
+ extra = {k: v for k, v in error.items() if k not in {"message", "code", "type"}}
148
+ return CloudError(message if isinstance(message, str) else f"HTTP {status}",
149
+ code if isinstance(code, str) else "http_error", status, extra, error.get("type"))
150
+
151
+
152
+ def send(request, transport_fn, timeout):
153
+ """Raw HTTP also serves presigned PUTs; error bodies never include request secrets."""
154
+ try:
155
+ try:
156
+ response = transport_fn(request, timeout=timeout)
157
+ except urllib.error.HTTPError as ex:
158
+ response = ex
159
+ with response:
160
+ body = _read(response)
161
+ status, headers = response.status, dict(response.headers)
162
+ except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as ex:
163
+ # Transport messages and HTTP reason strings can contain keys or signed URLs: describe() only.
164
+ raise _unreachable(ex, request) from None
165
+ if not (200 <= status < 300 or status == 304):
166
+ raise _error(status, body, request) from None
167
+ return body, status, headers
168
+
169
+
170
+ @contextmanager
171
+ def open_response(request, transport_fn, timeout):
172
+ """Keep a successful response open for SSE or large signed downloads."""
173
+ try:
174
+ with transport_fn(request, timeout=timeout) as response:
175
+ if not 200 <= response.status < 300:
176
+ raise _error(response.status, _read(response), request) from None
177
+ yield response
178
+ except urllib.error.HTTPError as ex:
179
+ with ex:
180
+ body = _read(ex)
181
+ raise _error(ex.code, body, request) from None
182
+ except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as ex:
183
+ # As in send(): transport messages and reasons can carry keys or signed URLs.
184
+ raise _unreachable(ex, request) from None
185
+
186
+
187
+ class Client:
188
+ def __init__(self, *, base_url=None, api_key=None, transport=None, timeout=30, client_headers=None):
189
+ self.base_url = (base_url if base_url is not None else config.SERVICE_URL).rstrip("/")
190
+ parts = validate_url(self.base_url)
191
+ if parts.query:
192
+ raise ValueError("Service base URL cannot contain a query")
193
+ self.api_key = api_key
194
+ self.transport = transport or urllib_transport
195
+ self.timeout = timeout
196
+ # A main-thread snapshot lets job workers avoid reading Blender or its preferences.
197
+ if client_headers is not None and set(k.lower() for k in client_headers) - {"x-loopcut-contract", "x-loopcut-client"}:
198
+ raise ValueError("Client header snapshots may contain only contract/version headers")
199
+ self.client_headers = dict(client_headers) if client_headers is not None else None
200
+
201
+ def version_headers(self):
202
+ return dict(self.client_headers) if self.client_headers is not None else contract_headers()
203
+
204
+ def _request(self, method, path, payload=None, *, headers=None, auth=True, accept="application/json"):
205
+ parts = urllib.parse.urlsplit(path)
206
+ if (not path.startswith("/") or path.startswith("//") or parts.scheme or parts.netloc
207
+ or parts.fragment or "\\" in path or any(ord(c) < 32 for c in path)):
208
+ raise ValueError("Service requests need a relative endpoint path")
209
+ supplied = dict(headers or {})
210
+ reserved = {"authorization", "x-loopcut-contract", "x-loopcut-client", "content-type"}
211
+ if reserved.intersection(k.lower() for k in supplied):
212
+ raise ValueError("Contract headers cannot be overridden")
213
+ supplied.update(self.version_headers())
214
+ supplied["Accept"] = accept
215
+ if auth:
216
+ key = self.api_key if self.api_key is not None else credentials.api_key(f"{self.base_url}/v1")
217
+ if (not isinstance(key, str) or not key.startswith("lc_") or len(key) <= 3
218
+ or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in key)):
219
+ raise CloudError("Sign in to Loopcut first", "sign_in_required")
220
+ supplied["Authorization"] = f"Bearer {key}"
221
+ body = None
222
+ if payload is not None:
223
+ body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
224
+ supplied["Content-Type"] = "application/json"
225
+ return urllib.request.Request(self.base_url + path, data=body, headers=supplied, method=method)
226
+
227
+ @contextmanager
228
+ def stream(self, path, *, headers=None, timeout=None):
229
+ """SSE uses the same endpoint, auth, error and redirect rules as JSON requests."""
230
+ request = self._request("GET", path, headers=headers, accept="text/event-stream")
231
+ with open_response(request, self.transport, self.timeout if timeout is None else timeout) as response:
232
+ yield response
233
+
234
+ @contextmanager
235
+ def download(self, url):
236
+ """Presigned storage GETs never carry the Loopcut account key."""
237
+ validate_url(url)
238
+ request = urllib.request.Request(url, headers=self.version_headers(), method="GET")
239
+ with open_response(request, self.transport, self.timeout) as response:
240
+ yield response
241
+
242
+ def request_response(self, method, path, payload=None, *, headers=None, auth=True):
243
+ request = self._request(method, path, payload, headers=headers, auth=auth)
244
+ body, status, response_headers = send(request, self.transport, self.timeout)
245
+ if status in {204, 304}:
246
+ return Response(None, status, response_headers)
247
+ try:
248
+ data = json.loads(body)
249
+ except (ValueError, UnicodeError, RecursionError):
250
+ raise CloudError("Invalid JSON from Loopcut", "invalid_response", status) from None
251
+ if not isinstance(data, dict):
252
+ raise CloudError("Expected an object from Loopcut", "invalid_response", status)
253
+ return Response(data, status, response_headers)
254
+
255
+ def request(self, method, path, payload=None, **options):
256
+ return self.request_response(method, path, payload, **options).data
@@ -0,0 +1,5 @@
1
+ """Stand-in for the add-on's config module: only the service URL that cloud_http reads."""
2
+
3
+ import os
4
+
5
+ SERVICE_URL = os.environ.get("LOOPCUT_SERVICE_URL", "https://loopcut.org").rstrip("/")
@@ -0,0 +1,6 @@
1
+ """Stand-in for the add-on's credentials module. The loopcut package passes keys explicitly, so a
2
+ Client built without one finds none here and raises sign_in_required."""
3
+
4
+
5
+ def api_key(base_url):
6
+ return None
@@ -0,0 +1,37 @@
1
+ {
2
+ "about": "How each Loopcut cloud tool appears to the agent: its MCP tool name and a short description. Input schemas come from the server's catalog. A catalog tool missing here keeps its own name and the server's description.",
3
+ "tools": {
4
+ "render": {
5
+ "name": "render_quote",
6
+ "description": "Price a cloud render of a saved .blend on an RTX 4090. Packs the scene and every texture, library and cache it uses with a local headless Blender and uploads what the cloud lacks. While the result says status \"preparing\", call again with the same arguments. Frames default to frame 1 only. Check warnings for missing files (they render pink)."
7
+ },
8
+ "asset_search": {
9
+ "name": "asset_search",
10
+ "description": "Search free CC0 libraries (Poly Haven, ambientCG, Physically Based) for models, materials, HDRIs and textures, with licence, size, preview and download details. Try it before generating a model or texture."
11
+ },
12
+ "image_to_3d": {
13
+ "name": "image_to_3d",
14
+ "description": "Make a textured 3D model (GLB) from a prompt, a local reference image, or both. For new props and concept meshes, not exact CAD. Takes 1-5 minutes."
15
+ },
16
+ "retopology": {
17
+ "name": "remesh",
18
+ "description": "Cloud remesh: rebuild a mesh with clean quads or triangles at a target face count, with its textures baked onto the new mesh. Export the object to a .glb first. Quads come back as FBX, triangles as GLB."
19
+ },
20
+ "sfx": {
21
+ "name": "sound_effect",
22
+ "description": "Make a sound effect (WAV, 0.5-30 s) from a description: impacts, ambience, foley. Not speech or music."
23
+ },
24
+ "voice_line": {
25
+ "name": "voice_line",
26
+ "description": "Speak a line of text as WAV audio with a chosen voice and direction, for characters or narration."
27
+ },
28
+ "image_generate": {
29
+ "name": "image_generate",
30
+ "description": "Make a PNG image from a prompt, optionally guided by a local reference image: concept art, backplates, references."
31
+ },
32
+ "image_edit": {
33
+ "name": "image_edit",
34
+ "description": "Change an existing image (a local PNG, JPEG or WebP) from a text instruction, with an optional PNG mask, e.g. to restyle a render."
35
+ }
36
+ }
37
+ }