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.
Files changed (81) hide show
  1. zipzi/__init__.py +69 -0
  2. zipzi/_backend_types.py +3 -0
  3. zipzi/_calls.py +119 -0
  4. zipzi/_documentation.py +49 -0
  5. zipzi/_dto_support.py +61 -0
  6. zipzi/_endpoint.py +148 -0
  7. zipzi/_errors.py +131 -0
  8. zipzi/_extensions.py +38 -0
  9. zipzi/_facade_support.py +62 -0
  10. zipzi/_generated/__init__.py +2 -0
  11. zipzi/_generated/backend_bindings.py +330 -0
  12. zipzi/_generated/binary.py +4897 -0
  13. zipzi/_generated/binding_values.py +274 -0
  14. zipzi/_generated/catalog.py +36651 -0
  15. zipzi/_generated/discovery.py +92 -0
  16. zipzi/_generated/event_values.py +199 -0
  17. zipzi/_generated/facades.py +140 -0
  18. zipzi/_generated/mesh_ops.py +148 -0
  19. zipzi/_generated/meshes.py +145 -0
  20. zipzi/_generated/modeling.py +17008 -0
  21. zipzi/_generated/parameter_values.py +214 -0
  22. zipzi/_generated/plan_intents.py +54 -0
  23. zipzi/_generated/plan_values.py +40 -0
  24. zipzi/_generated/planning.py +10 -0
  25. zipzi/_generated/protocols.py +8 -0
  26. zipzi/_generated/reference_values.py +65 -0
  27. zipzi/_generated/request_directory.py +103 -0
  28. zipzi/_generated/resource_backends.py +13 -0
  29. zipzi/_generated/resource_ops.py +82 -0
  30. zipzi/_generated/resource_values.py +54 -0
  31. zipzi/_generated/sampling_values.py +304 -0
  32. zipzi/_generated/scene.py +915 -0
  33. zipzi/_jobs.py +151 -0
  34. zipzi/_lifecycle.py +523 -0
  35. zipzi/_observation.py +26 -0
  36. zipzi/_operations.py +44 -0
  37. zipzi/_owned.py +64 -0
  38. zipzi/_planning.py +30 -0
  39. zipzi/_plans.py +49 -0
  40. zipzi/_presentation.py +46 -0
  41. zipzi/_preview.py +115 -0
  42. zipzi/_queries.py +45 -0
  43. zipzi/_recovery.py +43 -0
  44. zipzi/_request_methods.py +2 -0
  45. zipzi/_results.py +45 -0
  46. zipzi/_spatial.py +32 -0
  47. zipzi/_transport.py +19 -0
  48. zipzi/_values.py +195 -0
  49. zipzi/_windows_endpoint.py +95 -0
  50. zipzi/_windows_pipe.py +171 -0
  51. zipzi/animation.py +5 -0
  52. zipzi/backends/__init__.py +82 -0
  53. zipzi/backends/_native_types.py +80 -0
  54. zipzi/backends/_transfers.py +192 -0
  55. zipzi/backends/connected.py +301 -0
  56. zipzi/backends/local.py +71 -0
  57. zipzi/bindings.py +5 -0
  58. zipzi/buffers.py +98 -0
  59. zipzi/docs/editor_handoff.py +52 -0
  60. zipzi/docs/guide.md +290 -0
  61. zipzi/docs/recipes.py +138 -0
  62. zipzi/docs/reference.md +26968 -0
  63. zipzi/events.py +103 -0
  64. zipzi/inspection.py +60 -0
  65. zipzi/mesh_generation.py +83 -0
  66. zipzi/meshes.py +6 -0
  67. zipzi/modeling.py +19 -0
  68. zipzi/observation.py +4 -0
  69. zipzi/ops.py +13 -0
  70. zipzi/parameters.py +5 -0
  71. zipzi/py.typed +0 -0
  72. zipzi/references.py +138 -0
  73. zipzi/resources.py +133 -0
  74. zipzi/sampling.py +20 -0
  75. zipzi/scene.py +4 -0
  76. zipzi/spatial.py +4 -0
  77. zipzi-0.1.0a2.dist-info/METADATA +40 -0
  78. zipzi-0.1.0a2.dist-info/RECORD +81 -0
  79. zipzi-0.1.0a2.dist-info/WHEEL +4 -0
  80. zipzi-0.1.0a2.dist-info/licenses/LICENSE-MIT +21 -0
  81. 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"]
@@ -0,0 +1,3 @@
1
+ """Finite internal task discriminators; no domain imports."""
2
+ from typing import Literal
3
+ JobKind = Literal["published", "saved", "opened", "sampled", "planned", "project", "image", "preview", "spatial", "observation"]
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())
@@ -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')