zipzi 0.1.0a2__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.
- zipzi/__init__.py +69 -0
- zipzi/_backend_types.py +3 -0
- zipzi/_calls.py +119 -0
- zipzi/_documentation.py +49 -0
- zipzi/_dto_support.py +61 -0
- zipzi/_endpoint.py +148 -0
- zipzi/_errors.py +131 -0
- zipzi/_extensions.py +38 -0
- zipzi/_facade_support.py +62 -0
- zipzi/_generated/__init__.py +2 -0
- zipzi/_generated/backend_bindings.py +330 -0
- zipzi/_generated/binary.py +4897 -0
- zipzi/_generated/binding_values.py +274 -0
- zipzi/_generated/catalog.py +36651 -0
- zipzi/_generated/discovery.py +92 -0
- zipzi/_generated/event_values.py +199 -0
- zipzi/_generated/facades.py +140 -0
- zipzi/_generated/mesh_ops.py +148 -0
- zipzi/_generated/meshes.py +145 -0
- zipzi/_generated/modeling.py +17008 -0
- zipzi/_generated/parameter_values.py +214 -0
- zipzi/_generated/plan_intents.py +54 -0
- zipzi/_generated/plan_values.py +40 -0
- zipzi/_generated/planning.py +10 -0
- zipzi/_generated/protocols.py +8 -0
- zipzi/_generated/reference_values.py +65 -0
- zipzi/_generated/request_directory.py +103 -0
- zipzi/_generated/resource_backends.py +13 -0
- zipzi/_generated/resource_ops.py +82 -0
- zipzi/_generated/resource_values.py +54 -0
- zipzi/_generated/sampling_values.py +304 -0
- zipzi/_generated/scene.py +915 -0
- zipzi/_jobs.py +151 -0
- zipzi/_lifecycle.py +523 -0
- zipzi/_observation.py +26 -0
- zipzi/_operations.py +44 -0
- zipzi/_owned.py +64 -0
- zipzi/_planning.py +30 -0
- zipzi/_plans.py +49 -0
- zipzi/_presentation.py +46 -0
- zipzi/_preview.py +115 -0
- zipzi/_queries.py +45 -0
- zipzi/_recovery.py +43 -0
- zipzi/_request_methods.py +2 -0
- zipzi/_results.py +45 -0
- zipzi/_spatial.py +32 -0
- zipzi/_transport.py +19 -0
- zipzi/_values.py +195 -0
- zipzi/_windows_endpoint.py +95 -0
- zipzi/_windows_pipe.py +171 -0
- zipzi/animation.py +5 -0
- zipzi/backends/__init__.py +82 -0
- zipzi/backends/_native_types.py +80 -0
- zipzi/backends/_transfers.py +192 -0
- zipzi/backends/connected.py +301 -0
- zipzi/backends/local.py +71 -0
- zipzi/bindings.py +5 -0
- zipzi/buffers.py +98 -0
- zipzi/docs/editor_handoff.py +52 -0
- zipzi/docs/guide.md +290 -0
- zipzi/docs/recipes.py +138 -0
- zipzi/docs/reference.md +26968 -0
- zipzi/events.py +103 -0
- zipzi/inspection.py +60 -0
- zipzi/mesh_generation.py +83 -0
- zipzi/meshes.py +6 -0
- zipzi/modeling.py +19 -0
- zipzi/observation.py +4 -0
- zipzi/ops.py +13 -0
- zipzi/parameters.py +5 -0
- zipzi/py.typed +0 -0
- zipzi/references.py +138 -0
- zipzi/resources.py +133 -0
- zipzi/sampling.py +20 -0
- zipzi/scene.py +4 -0
- zipzi/spatial.py +4 -0
- zipzi-0.1.0a2.dist-info/METADATA +40 -0
- zipzi-0.1.0a2.dist-info/RECORD +81 -0
- zipzi-0.1.0a2.dist-info/WHEEL +4 -0
- zipzi-0.1.0a2.dist-info/licenses/LICENSE-MIT +21 -0
- zipzi-0.1.0a2.dist-info/licenses/THIRD_PARTY_NOTICES.md +33 -0
zipzi/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Typed Python authoring API backed by the Zipzi Runtime."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from . import modeling as modeling, bindings, sampling, events, meshes, ops, parameters, resources, scene, references, animation, spatial, observation, inspection
|
|
4
|
+
from ._plans import Plan
|
|
5
|
+
from ._generated.plan_values import PlanCost, PlanReport
|
|
6
|
+
from .resources import PreparedImage
|
|
7
|
+
from .buffers import Buffer
|
|
8
|
+
from ._errors import Busy, JobTimeout, OutcomeUnknown, PendingRequest, RecoveryAction, SdkFailure
|
|
9
|
+
from ._lifecycle import Edit, EditState, Job, JobState, Runtime, Session
|
|
10
|
+
from ._calls import recover_call as recover_call
|
|
11
|
+
from ._results import Batch, Page, PageCursor, Snapshot
|
|
12
|
+
from ._values import ConnectionInfo, ConnectionLimits, Receipt, SaveReceipt, SessionId, SessionInfo, Source, Version, WriteResult
|
|
13
|
+
|
|
14
|
+
def local() -> Runtime:
|
|
15
|
+
'Explicitly create an owned in-process Runtime with the paired optional native wheel. Importing zipzi alone does not start it. Use as a context manager; close releases its sessions without saving or connecting to another host.'
|
|
16
|
+
from .backends.local import LocalBackend
|
|
17
|
+
return Runtime(LocalBackend())
|
|
18
|
+
|
|
19
|
+
def connect(*, endpoint: Path | None = None, start: bool = False, host: Path | None = None,
|
|
20
|
+
timeout: float = 10., startup_timeout: float = 15., sdk_build: str | None = None) -> Runtime:
|
|
21
|
+
"""Connect to an explicit/discovered host and return an owned Runtime client.
|
|
22
|
+
|
|
23
|
+
endpoint is an absolute private Unix socket path or a local Windows Named
|
|
24
|
+
Pipe name; None discovers it from ZIPZI_RUNTIME_DIR/ZIPZI_RUNTIME_PROFILE.
|
|
25
|
+
On Windows, start=True requires the discovered endpoint (or None).
|
|
26
|
+
start=False never launches a
|
|
27
|
+
process. With start=True, host is an absolute executable, or discovery uses
|
|
28
|
+
ZIPZI_EDITOR_HOST and the supported app locations. host requires start=True.
|
|
29
|
+
timeout is the positive finite per-request transport timeout in seconds;
|
|
30
|
+
startup_timeout is the positive finite total startup/handshake budget.
|
|
31
|
+
sdk_build=None accepts the compatible host build; an explicit string also
|
|
32
|
+
requires an exact build match. Protocol and required capabilities always
|
|
33
|
+
match strictly; installing native never changes the selected execution host.
|
|
34
|
+
|
|
35
|
+
This blocks for connection/startup, raising ValueError for invalid options,
|
|
36
|
+
PermissionError for unsafe endpoint ownership, OS connection errors when
|
|
37
|
+
no host is listening, or SdkFailure for pairing/startup refusal. Runtime
|
|
38
|
+
resources are bounded by host limits. Close the returned client or use with;
|
|
39
|
+
this releases its leases while formal Connected sessions remain resident.
|
|
40
|
+
A request that later becomes unknown must recover on this original Runtime;
|
|
41
|
+
calling connect again does not replay or resolve that request.
|
|
42
|
+
|
|
43
|
+
Example: with zipzi.connect(endpoint=absolute_socket_path) as runtime: ...
|
|
44
|
+
"""
|
|
45
|
+
from ._endpoint import connect_backend
|
|
46
|
+
return Runtime(connect_backend(endpoint, start=start, host=host, timeout=timeout,
|
|
47
|
+
startup_timeout=startup_timeout, sdk_build=sdk_build))
|
|
48
|
+
|
|
49
|
+
__all__ = ["local", "connect", "Runtime", "Session", "Edit", "Job", "SdkFailure", "Busy", "OutcomeUnknown",
|
|
50
|
+
"PendingRequest", "Version", "Source", "Receipt", "SaveReceipt", "SessionId", "SessionInfo", "ConnectionInfo", "ConnectionLimits", "WriteResult", "modeling", "bindings", "sampling", "events", "meshes", "ops", "parameters", "resources", "scene", "references", "Plan", "PreparedImage", "Buffer"]
|
|
51
|
+
__all__ += ["animation", "spatial", "observation", "inspection"]
|
|
52
|
+
|
|
53
|
+
__all__ += ["recover_call", "EditState", "JobState", "JobTimeout", "RecoveryAction"]
|
|
54
|
+
|
|
55
|
+
from ._request_methods import register as _register_requests
|
|
56
|
+
_register_requests()
|
|
57
|
+
|
|
58
|
+
__all__ += ["Snapshot", "Batch", "Page", "PageCursor"]
|
|
59
|
+
|
|
60
|
+
__all__ += ["PlanCost", "PlanReport"]
|
|
61
|
+
|
|
62
|
+
from ._generated.discovery import RuntimeCapabilities, RuntimeLimits, ObjectSummary
|
|
63
|
+
__all__ += ["RuntimeCapabilities", "RuntimeLimits", "ObjectSummary"]
|
|
64
|
+
|
|
65
|
+
from ._documentation import ApiDescription, describe
|
|
66
|
+
__all__ += ["ApiDescription", "describe"]
|
|
67
|
+
|
|
68
|
+
from . import mesh_generation as mesh_generation
|
|
69
|
+
__all__ += ["mesh_generation"]
|
zipzi/_backend_types.py
ADDED
zipzi/_calls.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""One complete public return contract across request and transfer recovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from types import MethodType
|
|
6
|
+
from typing import Generic, Never, ParamSpec, TypeVar, cast
|
|
7
|
+
from ._errors import OutcomeUnknown, PendingRequest, SdkFailure
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", covariant=True)
|
|
10
|
+
U = TypeVar("U")
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolved_call(*_: object) -> Never:
|
|
15
|
+
raise SdkFailure("NO_PENDING_REQUEST", "This public call has already been resolved")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Call(Generic[T]):
|
|
19
|
+
"""Retain owned inputs, raw continuation and the complete return projection.
|
|
20
|
+
|
|
21
|
+
The backend owns the immutable wire bytes and transfer stage. A Call never
|
|
22
|
+
invokes its original execute closure during recovery. The same decoder is
|
|
23
|
+
applied once after either normal execution or raw continuation succeeds.
|
|
24
|
+
"""
|
|
25
|
+
def __init__(self, operation: str, execute: Callable[[], object], decode: Callable[[object], T], *,
|
|
26
|
+
resume: Callable[[], object] | None = None, inputs: tuple[object, ...] = (),
|
|
27
|
+
on_unknown: Callable[[OutcomeUnknown], None] | None = None,
|
|
28
|
+
on_error: Callable[[BaseException], None] | None = None):
|
|
29
|
+
self.operation, self.inputs = operation, inputs
|
|
30
|
+
self._execute, self._decode, self._resume = execute, decode, resume
|
|
31
|
+
self._explicit_resume = resume is not None
|
|
32
|
+
self._on_unknown, self._on_error = on_unknown, on_error
|
|
33
|
+
self.phase = "prepared"
|
|
34
|
+
self.pending_request: PendingRequest | None = None
|
|
35
|
+
self._bound_resume: Callable[[], object] | None = None
|
|
36
|
+
|
|
37
|
+
def run(self) -> T:
|
|
38
|
+
if self.phase != "prepared":
|
|
39
|
+
raise SdkFailure("NO_PENDING_REQUEST", "This public call has already started")
|
|
40
|
+
return self._invoke(self._execute)
|
|
41
|
+
|
|
42
|
+
def recover(self, expected: PendingRequest) -> T:
|
|
43
|
+
if self.phase != "unknown" or self.pending_request != expected or self._resume is None:
|
|
44
|
+
raise SdkFailure("NO_PENDING_REQUEST", "This public call has already been resolved")
|
|
45
|
+
return self._invoke(self._resume)
|
|
46
|
+
|
|
47
|
+
def _finish(self, phase: str) -> None:
|
|
48
|
+
self.phase, self.pending_request = phase, None
|
|
49
|
+
self._execute, self._resume, self._decode = _resolved_call, None, _resolved_call
|
|
50
|
+
self._bound_resume = self._on_unknown = self._on_error = None
|
|
51
|
+
self.inputs = ()
|
|
52
|
+
|
|
53
|
+
def _invoke(self, action: Callable[[], object]) -> T:
|
|
54
|
+
self.phase = "request"
|
|
55
|
+
try:
|
|
56
|
+
value = self._decode(action())
|
|
57
|
+
except OutcomeUnknown as error:
|
|
58
|
+
# An outer public projection captures the inner Call continuation
|
|
59
|
+
# before replacing the exception callback with its own full result.
|
|
60
|
+
if not self._explicit_resume and error._resume is not self._bound_resume and error._resume is not None:
|
|
61
|
+
self._resume = error._resume
|
|
62
|
+
self.phase, self.pending_request = "unknown", error.pending
|
|
63
|
+
expected = error.pending
|
|
64
|
+
self._bound_resume = lambda: self.recover(expected)
|
|
65
|
+
error._resume = self._bound_resume
|
|
66
|
+
if self._on_unknown is not None:
|
|
67
|
+
self._on_unknown(error)
|
|
68
|
+
raise
|
|
69
|
+
except BaseException as error:
|
|
70
|
+
callback = self._on_error
|
|
71
|
+
self._finish("failed")
|
|
72
|
+
if callback is not None:
|
|
73
|
+
callback(error)
|
|
74
|
+
raise
|
|
75
|
+
self._finish("resolved")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def known(execute: Callable[[], U], resume: Callable[[], U]) -> U:
|
|
80
|
+
"""Adapter for existing complete decoders while facades move to generation."""
|
|
81
|
+
operation = execute.__qualname__.split(".<locals>", 1)[0]
|
|
82
|
+
return Call(operation, execute, lambda value: cast(U, value), resume=resume).run()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def project(execute: Callable[[], U], projection: Callable[[U], T], *, operation: str,
|
|
86
|
+
inputs: tuple[object, ...] = ()) -> T:
|
|
87
|
+
return Call(operation, execute, lambda value: projection(cast(U, value)), inputs=inputs).run()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def single_id(result: object) -> str:
|
|
91
|
+
from ._values import WriteResult
|
|
92
|
+
if not isinstance(result, WriteResult) or len(result.ids) != 1:
|
|
93
|
+
raise SdkFailure("INVALID_RESULT", "A create operation must return exactly one ID")
|
|
94
|
+
return result.ids[0]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_REQUEST_METHODS: set[object] = set()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def register_request(method: object) -> None:
|
|
101
|
+
"""Only library-owned public method implementations enter this directory."""
|
|
102
|
+
_REQUEST_METHODS.add(getattr(method, "__func__", method))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def recover_call(method: Callable[P, U], /, *args: P.args, **kwargs: P.kwargs) -> U:
|
|
106
|
+
"""Call a registered SDK request method once and recover its Call once.
|
|
107
|
+
|
|
108
|
+
Parameters exactly match the bound SDK method, preserving its return type.
|
|
109
|
+
Arbitrary functions, lambdas and unregistered wrappers raise TypeError before
|
|
110
|
+
execution. Busy, ordinary failures, budgets and JobTimeout are propagated.
|
|
111
|
+
If recovery is still unknown, retain that exception and its owner to resume
|
|
112
|
+
later; this helper never replays a user function or loops on recovery.
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(method, MethodType) or method.__func__ not in _REQUEST_METHODS:
|
|
115
|
+
raise TypeError("recover_call requires a registered bound SDK request method")
|
|
116
|
+
try:
|
|
117
|
+
return method(*args, **kwargs)
|
|
118
|
+
except OutcomeUnknown as error:
|
|
119
|
+
return cast(U, error.recover())
|
zipzi/_documentation.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Offline public API descriptions; importing or describing never starts a host."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from ._presentation import CompactValue
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True,repr=False)
|
|
6
|
+
class ApiDescription(CompactValue):
|
|
7
|
+
'One installed offline API entry; it describes the client package rather than probing host support.\n\nFields:\npath: Canonical public API path under zipzi.\nkind: Directory entry category such as module, type, callable or value.\nsignature: Rendered public signature; empty for entries without a callable/type signature.\ndocumentation: Semantic docstring for this installed entry.\nfields: Ordered pairs of public field name and semantic description; empty when no value fields are declared.\nchildren: Canonical child entry paths for further bounded describe queries.\nexample: Package-relative workflow/example reference; empty when no example is assigned.'
|
|
8
|
+
__module__='zipzi'
|
|
9
|
+
path:str
|
|
10
|
+
kind:str
|
|
11
|
+
signature:str
|
|
12
|
+
documentation:str
|
|
13
|
+
fields:tuple[tuple[str,str],...]=()
|
|
14
|
+
children:tuple[str,...]=()
|
|
15
|
+
example:str=''
|
|
16
|
+
|
|
17
|
+
def format(self) -> str:
|
|
18
|
+
"""Return complete help text without truncation or IO.
|
|
19
|
+
|
|
20
|
+
Use print(description.format()) for full help. repr, str and summary
|
|
21
|
+
remain bounded previews; formatting does not recursively query children.
|
|
22
|
+
"""
|
|
23
|
+
lines=[self.path+' ('+self.kind+')']
|
|
24
|
+
if self.signature:lines.extend(('',self.signature))
|
|
25
|
+
if self.documentation:lines.extend(('',self.documentation))
|
|
26
|
+
if self.fields:
|
|
27
|
+
lines.extend(('', 'Fields:'))
|
|
28
|
+
lines.extend(name+': '+text for name,text in self.fields)
|
|
29
|
+
if self.children:
|
|
30
|
+
lines.extend(('', 'Children:', *self.children))
|
|
31
|
+
if self.example:lines.extend(('', 'Example: '+self.example))
|
|
32
|
+
return '\n'.join(lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def describe(path:str)->ApiDescription:
|
|
36
|
+
"""Look up an exact public path, e.g. Session.meshes.list, without IO or eval.
|
|
37
|
+
|
|
38
|
+
Unknown paths raise KeyError. Short paths are normalized under zipzi.
|
|
39
|
+
The root description links the bundled short guide and executable recipes.
|
|
40
|
+
Use print(describe(path).format()) for complete help; direct printing is a
|
|
41
|
+
bounded preview. Fields and child paths are also available individually.
|
|
42
|
+
This describes the installed API, while runtime.capabilities() describes
|
|
43
|
+
the connected host. No resource is constructed or RPC sent by this query.
|
|
44
|
+
"""
|
|
45
|
+
from ._generated.catalog import CATALOG
|
|
46
|
+
if not isinstance(path,str):raise TypeError('Expected a public API path')
|
|
47
|
+
key=path if path=='zipzi' or path.startswith('zipzi.') else 'zipzi.'+path
|
|
48
|
+
raw=CATALOG[key]
|
|
49
|
+
return ApiDescription(key,raw['kind'],raw.get('signature',''),raw.get('documentation',''),tuple(raw.get('fields',())),tuple(raw.get('children',())),raw.get('example',''))
|
zipzi/_dto_support.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Primitive ownership/shape conversions shared by generated DTO projections."""
|
|
2
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
3
|
+
from typing import TypeVar, cast
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
import math
|
|
6
|
+
from ._values import SessionId
|
|
7
|
+
|
|
8
|
+
T = TypeVar('T')
|
|
9
|
+
|
|
10
|
+
def get(value: object, key: str) -> object:
|
|
11
|
+
if isinstance(value, Mapping): return cast(Mapping[str, object], value)[key]
|
|
12
|
+
return cast(object, getattr(value, key))
|
|
13
|
+
|
|
14
|
+
def text(value: object) -> str:
|
|
15
|
+
if not isinstance(value, str): raise TypeError('Expected text')
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
def numeric(value: object, kind: str = 'f32') -> float:
|
|
19
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError('Expected a real number')
|
|
20
|
+
if not math.isfinite(value) or kind == 'f32' and abs(value) > 3.4028234663852886e38: raise ValueError('Expected finite '+kind)
|
|
21
|
+
return float(value)
|
|
22
|
+
|
|
23
|
+
def whole(value: object, kind: str) -> int:
|
|
24
|
+
if type(value) is not int: raise ValueError('Expected '+kind)
|
|
25
|
+
bits = {'u8':8, 'u16':16, 'u32':32, 'u64':64, 'i32':32, 'i64':64, 'usize':64}[kind]
|
|
26
|
+
low, high = (-(1 << (bits-1)), 1 << (bits-1)) if kind.startswith('i') else (0, 1 << bits)
|
|
27
|
+
if not low <= value < high: raise ValueError('Expected '+kind)
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
def flag(value: object) -> bool:
|
|
31
|
+
if type(value) is not bool: raise TypeError('Expected bool')
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
def point(value: object) -> tuple[float, float]:
|
|
35
|
+
if isinstance(value, (tuple, list)):
|
|
36
|
+
values=cast(Sequence[object], value)
|
|
37
|
+
if len(values)!=2: raise ValueError('Expected point pair')
|
|
38
|
+
return numeric(values[0]), numeric(values[1])
|
|
39
|
+
return numeric(get(value,'x')), numeric(get(value,'y'))
|
|
40
|
+
|
|
41
|
+
def items(value: object) -> Sequence[object]:
|
|
42
|
+
if not isinstance(value, (list, tuple)): raise TypeError('Expected immutable array input')
|
|
43
|
+
return cast(Sequence[object], value)
|
|
44
|
+
|
|
45
|
+
def array(value: Sequence[T], length: int) -> tuple[T, ...]:
|
|
46
|
+
if len(value)!=length: raise ValueError('Invalid fixed array length')
|
|
47
|
+
return tuple(value)
|
|
48
|
+
|
|
49
|
+
def mapping(value: object) -> Mapping[str, object]:
|
|
50
|
+
if not isinstance(value, Mapping): raise TypeError('Expected mapping')
|
|
51
|
+
result=cast(Mapping[object, object], value)
|
|
52
|
+
if any(not isinstance(key,str) for key in result): raise TypeError('Expected text keys')
|
|
53
|
+
return cast(Mapping[str, object], result)
|
|
54
|
+
|
|
55
|
+
def cursor_guard(runtime: str, session: SessionId | None, sequence: int) -> None:
|
|
56
|
+
if str(UUID(runtime))!=runtime or type(sequence) is not int or not 0<=sequence<1<<64: raise ValueError('Invalid event cursor')
|
|
57
|
+
if session is not None and session.runtime!=runtime: raise ValueError('Cursor mixes Runtime identities')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def external(value: object, cls: type[T], decode: Callable[[object], T]) -> T:
|
|
61
|
+
return value if isinstance(value, cls) else decode(value)
|
zipzi/_endpoint.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Unix endpoint discovery and ownership transfer to the desktop host."""
|
|
2
|
+
import errno
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from ._errors import SdkFailure
|
|
13
|
+
from .backends.connected import ConnectedBackend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _private(metadata: os.stat_result, directory: bool) -> None:
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
raise RuntimeError("Unix permission check on Windows")
|
|
19
|
+
valid_kind = stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode)
|
|
20
|
+
if not valid_kind or metadata.st_uid != os.getuid() or metadata.st_mode & 0o077:
|
|
21
|
+
raise PermissionError("Runtime directory/lock must be private, owned by this user and not a symlink")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _endpoint(path: Path | None, start: bool) -> Path:
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
raise SdkFailure("HOST_UNAVAILABLE", "Local host connections currently require Unix")
|
|
27
|
+
if path is None:
|
|
28
|
+
directory = Path(os.environ.get("ZIPZI_RUNTIME_DIR", f"/tmp/zipzi-runtime-{os.getuid()}"))
|
|
29
|
+
profile = os.environ.get("ZIPZI_RUNTIME_PROFILE", "default")
|
|
30
|
+
path = directory / f"{profile}.sock"
|
|
31
|
+
else:
|
|
32
|
+
directory = path.parent
|
|
33
|
+
profile = path.stem
|
|
34
|
+
if not path.is_absolute() or path.suffix != ".sock" or not re.fullmatch(r"[a-z0-9_-]{1,24}", profile):
|
|
35
|
+
raise ValueError("Endpoint must be an absolute <profile>.sock path with a valid profile")
|
|
36
|
+
if len(os.fsencode(path)) >= 104:
|
|
37
|
+
raise ValueError("Runtime socket path is too long")
|
|
38
|
+
if start:
|
|
39
|
+
try:
|
|
40
|
+
directory.mkdir(mode=0o700)
|
|
41
|
+
except FileExistsError:
|
|
42
|
+
pass
|
|
43
|
+
_private(directory.lstat(), True)
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _socket(path: Path) -> None:
|
|
48
|
+
if sys.platform == "win32":
|
|
49
|
+
raise RuntimeError("Unix socket check on Windows")
|
|
50
|
+
try:
|
|
51
|
+
metadata = path.lstat()
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return
|
|
54
|
+
if not stat.S_ISSOCK(metadata.st_mode) or metadata.st_uid != os.getuid() or metadata.st_mode & 0o077:
|
|
55
|
+
raise PermissionError("Runtime endpoint must be a private socket owned by this user")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _host(explicit: Path | None) -> Path:
|
|
59
|
+
configured = os.environ.get("ZIPZI_EDITOR_HOST")
|
|
60
|
+
if explicit is not None or configured:
|
|
61
|
+
candidates = [explicit if explicit is not None else Path(configured or "")]
|
|
62
|
+
elif sys.platform == "darwin":
|
|
63
|
+
candidates = [base / "Zipzi Editor.app/Contents/MacOS/zipzi-editor"
|
|
64
|
+
for base in (Path.home() / "Applications", Path("/Applications"))]
|
|
65
|
+
else:
|
|
66
|
+
candidates = []
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
if not candidate.is_absolute():
|
|
69
|
+
raise ValueError("Host executable must be absolute")
|
|
70
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
71
|
+
return candidate
|
|
72
|
+
raise SdkFailure("HOST_NOT_FOUND", "Set ZIPZI_EDITOR_HOST or pass an absolute host executable")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _claim(path: Path) -> int | None:
|
|
76
|
+
if sys.platform == "win32":
|
|
77
|
+
raise RuntimeError("Unix ownership claim on Windows")
|
|
78
|
+
import fcntl
|
|
79
|
+
descriptor = os.open(path.with_suffix(".lock"), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
|
80
|
+
try:
|
|
81
|
+
_private(os.fstat(descriptor), False)
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
84
|
+
except OSError as error:
|
|
85
|
+
if error.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
86
|
+
os.close(descriptor)
|
|
87
|
+
return None
|
|
88
|
+
raise
|
|
89
|
+
return descriptor
|
|
90
|
+
except BaseException:
|
|
91
|
+
os.close(descriptor)
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def connect_backend(endpoint: Path | None, *, start: bool, host: Path | None,
|
|
96
|
+
timeout: float, startup_timeout: float, sdk_build: str | None) -> ConnectedBackend:
|
|
97
|
+
for value in (timeout, startup_timeout):
|
|
98
|
+
if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
|
|
99
|
+
raise ValueError("Connection and startup timeouts must be finite and positive")
|
|
100
|
+
if host is not None and not start:
|
|
101
|
+
raise ValueError("A host executable requires start=True")
|
|
102
|
+
if os.name == "nt":
|
|
103
|
+
from ._windows_endpoint import connect
|
|
104
|
+
return connect(endpoint, start=start, host=host, timeout=timeout,
|
|
105
|
+
startup_timeout=startup_timeout, sdk_build=sdk_build)
|
|
106
|
+
path = _endpoint(endpoint, start)
|
|
107
|
+
deadline = time.monotonic() + startup_timeout
|
|
108
|
+
child: subprocess.Popen[bytes] | None = None
|
|
109
|
+
|
|
110
|
+
def attempt() -> ConnectedBackend:
|
|
111
|
+
_socket(path)
|
|
112
|
+
remaining = max(.001, deadline - time.monotonic()) if start else timeout
|
|
113
|
+
return ConnectedBackend(path, timeout, sdk_build=sdk_build, handshake_timeout=min(timeout, remaining))
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
return attempt()
|
|
118
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
119
|
+
if not start:
|
|
120
|
+
raise
|
|
121
|
+
if child is not None and child.poll() is not None:
|
|
122
|
+
raise SdkFailure("HOST_START_FAILED", f"Host exited with status {child.returncode} before handshake")
|
|
123
|
+
if time.monotonic() >= deadline:
|
|
124
|
+
raise TimeoutError("Host startup timed out; endpoint ownership is retained by any running host")
|
|
125
|
+
if child is None:
|
|
126
|
+
descriptor = _claim(path)
|
|
127
|
+
if descriptor is not None:
|
|
128
|
+
try:
|
|
129
|
+
# Recheck after winning arbitration, before choosing a host.
|
|
130
|
+
try:
|
|
131
|
+
return attempt()
|
|
132
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
133
|
+
pass
|
|
134
|
+
executable = _host(host)
|
|
135
|
+
environment = dict(os.environ, ZIPZI_RUNTIME_DIR=str(path.parent), ZIPZI_RUNTIME_PROFILE=path.stem,
|
|
136
|
+
ZIPZI_RUNTIME_OWNER_FD=str(descriptor))
|
|
137
|
+
# Development session/readiness files belong to their launcher.
|
|
138
|
+
for name in ("ZIPZI_DEV_SESSION_DIR", "ZIPZI_DEV_READY_FILE", "ZIPZI_DEV_PID_FILE"):
|
|
139
|
+
environment.pop(name, None)
|
|
140
|
+
child = subprocess.Popen([str(executable), "--no-window"], env=environment,
|
|
141
|
+
pass_fds=(descriptor,), start_new_session=True, stdin=subprocess.DEVNULL,
|
|
142
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
143
|
+
# Reap without tying the host's lifetime to this client.
|
|
144
|
+
threading.Thread(target=child.wait, name="zipzi-host-reaper", daemon=True).start()
|
|
145
|
+
finally:
|
|
146
|
+
# Never LOCK_UN: the child inherits the same open description.
|
|
147
|
+
os.close(descriptor)
|
|
148
|
+
time.sleep(min(.025, max(0., deadline - time.monotonic())))
|
zipzi/_errors.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Backend-independent exceptions. Importing these never imports PyO3."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from ._values import Version, record, sequence, string
|
|
7
|
+
from ._generated.modeling import ProjectReport
|
|
8
|
+
|
|
9
|
+
RecoveryAction = Literal["retry", "reduce_request", "release_resources", "recover_original",
|
|
10
|
+
"restart_edit", "refresh_source", "resync", "wait_job", "stop"]
|
|
11
|
+
|
|
12
|
+
def _recovery_action(code: str, edit_state: str | None, limit_name: str | None) -> RecoveryAction:
|
|
13
|
+
if edit_state in {"aborted", "failed"}:
|
|
14
|
+
return "restart_edit"
|
|
15
|
+
if code == "OUTCOME_UNKNOWN":
|
|
16
|
+
return "recover_original"
|
|
17
|
+
if code == "JOB_TIMEOUT":
|
|
18
|
+
return "wait_job"
|
|
19
|
+
if code == "BUSY":
|
|
20
|
+
return "retry"
|
|
21
|
+
if code in {"BUDGET_EXCEEDED", "RESOURCE_BUDGET_EXCEEDED"}:
|
|
22
|
+
return "release_resources" if limit_name in {"jobs_per_client", "jobs_total", "results_per_client", "plans_per_client", "prepared_images_per_client", "references_per_client", "blobs_per_client", "jobs and retained results", "plans", "prepared images", "object references"} else "reduce_request"
|
|
23
|
+
if code in {"STALE_SOURCE", "STALE_PLAN"}:
|
|
24
|
+
return "refresh_source"
|
|
25
|
+
if code == "RESYNC_REQUIRED":
|
|
26
|
+
return "resync"
|
|
27
|
+
return "stop"
|
|
28
|
+
|
|
29
|
+
class SdkFailure(Exception):
|
|
30
|
+
"""Confirmed SDK/host refusal with structured recovery and ownership facts.
|
|
31
|
+
|
|
32
|
+
code is the stable discriminator; message is human-readable. operation and
|
|
33
|
+
object_ids identify the attempted domain action. expected_version,
|
|
34
|
+
actual_version, field_path and referrers explain stale/invalid references.
|
|
35
|
+
edit_state distinguishes aborted candidates from confirmed admission refusal.
|
|
36
|
+
requested/allowed are counts or bytes named by limit_name; None means absent.
|
|
37
|
+
project_report retains a failed IO report when available. recovery_action
|
|
38
|
+
recommends a finite next action; it is not permission to replay unknown work.
|
|
39
|
+
OutcomeUnknown has a separate retained request owner and recover method.
|
|
40
|
+
This value alone performs no IO and owns no resource lease. Catch outside a
|
|
41
|
+
failed Edit, inspect code/recovery_action, and start a fresh candidate when
|
|
42
|
+
required. See docs/guide.md for the error/recovery workflow.
|
|
43
|
+
"""
|
|
44
|
+
def __init__(self, code: str, message: str, *, operation: str = "", object_ids: tuple[str, ...] = (),
|
|
45
|
+
expected_version: Version | None = None, actual_version: Version | None = None,
|
|
46
|
+
field_path: str | None = None, referrers: tuple[str, ...] = (), edit_state: str | None = None, project_report: ProjectReport | None = None,
|
|
47
|
+
recovery_action: RecoveryAction | None = None, requested: int | None = None,
|
|
48
|
+
allowed: int | None = None, limit_name: str | None = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code, self.operation, self.object_ids = code, operation, object_ids
|
|
51
|
+
self.expected_version, self.actual_version = expected_version, actual_version
|
|
52
|
+
self.field_path, self.referrers, self.edit_state = field_path, referrers, edit_state
|
|
53
|
+
self.project_report = project_report
|
|
54
|
+
self.requested, self.allowed, self.limit_name = requested, allowed, limit_name
|
|
55
|
+
self.recovery_action = recovery_action or _recovery_action(code, edit_state, limit_name)
|
|
56
|
+
self.cleanup: OutcomeUnknown | None = None
|
|
57
|
+
|
|
58
|
+
class Busy(SdkFailure):
|
|
59
|
+
"""Confirmed refusal before execution; the caller may submit a new request."""
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PendingRequest:
|
|
63
|
+
'Immutable identity of the original request whose outcome is unknown; recover through its owning call.\n\nFields:\nrequest_id: Original client request number; do not allocate a fresh write request to probe the outcome.\ndigest: Digest binding the request identity to its original payload; not the payload itself.\noperation: Original protocol operation name used for diagnostics and recovery ownership.'
|
|
64
|
+
request_id: int
|
|
65
|
+
digest: str
|
|
66
|
+
operation: str
|
|
67
|
+
|
|
68
|
+
class OutcomeUnknown(SdkFailure):
|
|
69
|
+
"""The original public call has an unconfirmed outcome; retain this exception.
|
|
70
|
+
|
|
71
|
+
pending identifies its immutable request, while the owning SDK wrapper keeps
|
|
72
|
+
the complete result projection. recover returns that original runtime type;
|
|
73
|
+
use recover_call for exact static typing. Further uncertainty raises another
|
|
74
|
+
recoverable OutcomeUnknown. Never repeat a write under a new request ID.
|
|
75
|
+
Recovery is synchronous, bounded by host retention/timeouts; expiration is a
|
|
76
|
+
structured failure rather than proof the write did not happen.
|
|
77
|
+
"""
|
|
78
|
+
def __init__(self, pending: PendingRequest, message: str):
|
|
79
|
+
super().__init__("OUTCOME_UNKNOWN", message, operation=pending.operation)
|
|
80
|
+
self.pending = pending
|
|
81
|
+
self._resume: Callable[[], object] | None = None
|
|
82
|
+
|
|
83
|
+
def recover(self) -> object:
|
|
84
|
+
"""Recover this complete public call using its original request identity.
|
|
85
|
+
|
|
86
|
+
The result has the original method's runtime type. The exception cannot
|
|
87
|
+
express that method's static type; use zipzi.recover_call for typed code.
|
|
88
|
+
This never invokes the original method again. Repeated recovery after
|
|
89
|
+
resolution raises NO_PENDING_REQUEST.
|
|
90
|
+
"""
|
|
91
|
+
if self._resume is None:
|
|
92
|
+
raise SdkFailure("NO_RECOVERY_OWNER", "No owning operation is available")
|
|
93
|
+
return self._resume()
|
|
94
|
+
|
|
95
|
+
class JobTimeout(SdkFailure):
|
|
96
|
+
"""The accepted Job remains pending; retain it and wait again or cancel.
|
|
97
|
+
|
|
98
|
+
This confirmed nonterminal status has no PendingRequest and is distinct
|
|
99
|
+
from a lost transport reply. A timeout does not cancel the task.
|
|
100
|
+
"""
|
|
101
|
+
def __init__(self, message: str = "Accepted Job is still pending"):
|
|
102
|
+
super().__init__("JOB_TIMEOUT", message, operation="job.wait", recovery_action="wait_job")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def unwrap(value: object) -> object:
|
|
106
|
+
r = record(value)
|
|
107
|
+
if "error" not in r:
|
|
108
|
+
return r["ok"]
|
|
109
|
+
e = record(r["error"])
|
|
110
|
+
code = string(e["code"])
|
|
111
|
+
def optional(key: str) -> Version | None:
|
|
112
|
+
return None if e.get(key) is None else Version._read(e[key])
|
|
113
|
+
report=None
|
|
114
|
+
if e.get("project_report") is not None:
|
|
115
|
+
from ._generated.modeling import read_ProjectReport
|
|
116
|
+
report=read_ProjectReport(e["project_report"])
|
|
117
|
+
raise (Busy if code == "BUSY" else SdkFailure)(code, string(e["message"]),
|
|
118
|
+
operation=string(e.get("operation", "")), object_ids=tuple(string(v) for v in sequence(e.get("object_ids", ()))),
|
|
119
|
+
expected_version=optional("expected_version"), actual_version=optional("actual_version"),
|
|
120
|
+
field_path=None if e.get("field_path") is None else string(e["field_path"]),
|
|
121
|
+
referrers=tuple(string(v) for v in sequence(e.get("referrers", ()))),
|
|
122
|
+
edit_state=None if e.get("edit_state") is None else string(e["edit_state"]), project_report=report,
|
|
123
|
+
requested=None if e.get("requested") is None else _limit_integer(e["requested"]),
|
|
124
|
+
allowed=None if e.get("allowed") is None else _limit_integer(e["allowed"]),
|
|
125
|
+
limit_name=None if e.get("limit_name") is None else string(e["limit_name"]))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _limit_integer(value: object) -> int:
|
|
129
|
+
if type(value) is not int or value < 0:
|
|
130
|
+
raise TypeError("Host budget details must be nonnegative integers")
|
|
131
|
+
return value
|
zipzi/_extensions.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Immutable JSON extensions in canonical document fields only."""
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from types import MappingProxyType
|
|
4
|
+
from typing import TypeAlias, cast
|
|
5
|
+
import math
|
|
6
|
+
ExtensionValue: TypeAlias = None | bool | int | float | str | tuple['ExtensionValue', ...] | Mapping[str, 'ExtensionValue']
|
|
7
|
+
def freeze(value: ExtensionValue) -> ExtensionValue:
|
|
8
|
+
if isinstance(value, Mapping):
|
|
9
|
+
return MappingProxyType({k: freeze(v) for k, v in value.items()})
|
|
10
|
+
if isinstance(value, (tuple, list)):
|
|
11
|
+
return tuple(freeze(v) for v in cast(tuple[ExtensionValue, ...], value))
|
|
12
|
+
return value
|
|
13
|
+
def validate(value: ExtensionValue) -> None:
|
|
14
|
+
if value is None or isinstance(value, (bool, str)): return
|
|
15
|
+
if isinstance(value, int):
|
|
16
|
+
if not -(1 << 63) <= value < (1 << 64): raise ValueError('Extension integer exceeds JSON range')
|
|
17
|
+
return
|
|
18
|
+
if isinstance(value, float):
|
|
19
|
+
if not math.isfinite(value): raise ValueError('Extension number must be finite')
|
|
20
|
+
return
|
|
21
|
+
if isinstance(value, Mapping):
|
|
22
|
+
for k,v in value.items():
|
|
23
|
+
if not isinstance(k,str): raise TypeError('Extension key must be text')
|
|
24
|
+
validate(v)
|
|
25
|
+
return
|
|
26
|
+
if isinstance(value, tuple):
|
|
27
|
+
for v in value: validate(v)
|
|
28
|
+
return
|
|
29
|
+
raise TypeError('Invalid extension value')
|
|
30
|
+
def encode(value: ExtensionValue) -> object:
|
|
31
|
+
if isinstance(value, Mapping): return {k: encode(v) for k,v in value.items()}
|
|
32
|
+
if isinstance(value, tuple): return [encode(v) for v in value]
|
|
33
|
+
return value
|
|
34
|
+
def read(value: object) -> ExtensionValue:
|
|
35
|
+
if value is None or isinstance(value, (bool,int,float,str)): return value
|
|
36
|
+
if isinstance(value, dict): return MappingProxyType({str(k): read(v) for k,v in cast(dict[str,object],value).items()})
|
|
37
|
+
if isinstance(value, (list,tuple)): return tuple(read(v) for v in cast(tuple[object,...],value))
|
|
38
|
+
raise TypeError('Invalid JSON extension response')
|