lliquidlink 1.0.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,9 @@
1
+ """lliquidlink: Python side of the Unity JSON-RPC bridge.
2
+
3
+ Subpackages:
4
+ lliquidlink.core - transport-agnostic JSON-RPC core shared by client and server.
5
+ lliquidlink.client - Client that connects to a Unity Editor server.
6
+ lliquidlink.server - Server middleware bridging external clients to Unity over stdio.
7
+ """
8
+
9
+ __version__ = "1.0.0"
@@ -0,0 +1,51 @@
1
+ """lliquidlink.client - Python client to control the Unity Editor over JSON-RPC.
2
+
3
+ Usage::
4
+
5
+ def on_execute(client):
6
+ go = client.Find("Player")
7
+ go.transform.Rotate(0, 90, 0)
8
+
9
+ client = Client(TcpJsonRpcTransport("localhost", 8700))
10
+ client.on_execute += on_execute
11
+ client.mainloop()
12
+ """
13
+ import logging
14
+
15
+ from ._client import Client, gc_flush
16
+ from ._event import Event
17
+ from ._proxy import ObjectProxy, PropertyProxy
18
+ from ._transports import StdioJsonRpcTransport, TcpJsonRpcTransport
19
+ from ..core import ConnectionClosedError, RpcError
20
+ from . import models
21
+
22
+ logger = logging.getLogger("lliquidlink.client")
23
+
24
+ def setup_logger():
25
+ level = logging.DEBUG
26
+ h = logging.StreamHandler()
27
+ h.setFormatter(logging.Formatter("[Client] %(message)s"))
28
+ h.setLevel(level)
29
+ logger.addHandler(h)
30
+ logger.setLevel(level)
31
+ for name in [
32
+ # "anyio", "websockets"
33
+ ]:
34
+ _logger = logging.getLogger(name)
35
+ for h in logger.handlers:
36
+ _logger.addHandler(h)
37
+ _logger.setLevel(logger.level)
38
+ setup_logger()
39
+
40
+ __all__ = [
41
+ "Client",
42
+ "gc_flush",
43
+ "Event",
44
+ "ObjectProxy",
45
+ "PropertyProxy",
46
+ "StdioJsonRpcTransport",
47
+ "TcpJsonRpcTransport",
48
+ "ConnectionClosedError",
49
+ "RpcError",
50
+ "models",
51
+ ]
@@ -0,0 +1,138 @@
1
+ """Client: anyio runtime, RPC dispatch, object-release batching."""
2
+ from __future__ import annotations
3
+ import functools
4
+ import json
5
+ import threading
6
+ import weakref
7
+ from typing import Any, List, TYPE_CHECKING
8
+ if TYPE_CHECKING:
9
+ from ._transports import StreamTransport
10
+
11
+ import anyio
12
+
13
+ from ._event import Event
14
+ from ._proxy import ObjectProxy, PropertyProxy
15
+ from ._serialization import Serialization
16
+
17
+ import logging
18
+ logger = logging.getLogger(__name__)
19
+
20
+ async def _await(coro):
21
+ """Await a coroutine; used to run it on the event loop from a worker thread."""
22
+ return await coro
23
+
24
+
25
+ def gc_flush(func):
26
+ """Decorator: flush pending Unity object releases after the method returns."""
27
+ @functools.wraps(func)
28
+ def wrapper(self, *args, **kwargs):
29
+ try:
30
+ return func(self, *args, **kwargs)
31
+ finally:
32
+ self.flush_releases()
33
+ return wrapper
34
+
35
+
36
+ class Client:
37
+ """Client that connects to a Unity Editor server and sends RPC commands.
38
+
39
+ Register a handler on the :attr:`on_execute` event with ``+=``, then call
40
+ :meth:`mainloop`.
41
+ """
42
+
43
+ def __init__(self, transport : StreamTransport):
44
+ self._transport = transport
45
+ self._serialization = Serialization(lambda data: ObjectProxy(self, data))
46
+ transport.bind_codec(self._serialization)
47
+ self._pending_releases = set()
48
+ self._release_lock = threading.Lock()
49
+ self.on_execute = Event()
50
+
51
+ # ── RPC dispatch ─────────────────────────────────────────────────────────
52
+
53
+ def __getattr__(self, name: str) -> PropertyProxy:
54
+ """Treat any undefined non-underscore attribute as a Unity RPC method."""
55
+ if name.startswith("_"):
56
+ raise AttributeError(name)
57
+ return PropertyProxy(self, None, [name])
58
+
59
+ async def _call_async(self, method: str, params: list) -> Any:
60
+ return await self._transport.rpc_call(method, params)
61
+
62
+ def _call_sync(self, method: str, params: list) -> Any:
63
+ return self._run(self._call_async(method, params))
64
+
65
+ def _run(self, coro):
66
+ """Run a coroutine synchronously from a worker thread, else return it."""
67
+ try:
68
+ return anyio.from_thread.run(_await, coro)
69
+ except RuntimeError:
70
+ return coro
71
+
72
+ # ── Object release batching ──────────────────────────────────────────────
73
+
74
+ def _track_release(self, proxy, data: dict) -> None:
75
+ instance_id = data.get("instanceId")
76
+ if instance_id is None:
77
+ return
78
+ weakref.finalize(proxy, self._schedule_release, instance_id)
79
+
80
+ def _schedule_release(self, instance_id: int) -> None:
81
+ with self._release_lock:
82
+ self._pending_releases.add(instance_id)
83
+
84
+ async def _flush_releases(self) -> None:
85
+ with self._release_lock:
86
+ if not self._pending_releases:
87
+ return
88
+ ids = sorted(self._pending_releases)
89
+ self._pending_releases.clear()
90
+ if self._transport.closed:
91
+ return
92
+ # await self._transport.rpc_notify("release_objects", [json.dumps(ids)])
93
+
94
+ def flush_releases(self) -> None:
95
+ """Send pending object releases now (callable from a worker thread)."""
96
+ self._run(self._flush_releases())
97
+
98
+ # ── Lifecycle ────────────────────────────────────────────────────────────
99
+
100
+ @property
101
+ def is_running(self) -> bool:
102
+ return not self._transport.closed
103
+
104
+ def mainloop(self) -> None:
105
+ """Connect, run on_execute in a worker thread, flush, then disconnect."""
106
+ anyio.run(self._amain)
107
+
108
+ async def _amain(self) -> None:
109
+ await self._transport.open()
110
+ try:
111
+ await self.execute(self.on_execute)
112
+ await self._flush_releases()
113
+ finally:
114
+ await self._transport.aclose()
115
+
116
+ async def connect(self) -> None:
117
+ """Open the connection to the Unity server."""
118
+ await self._transport.open()
119
+
120
+ async def disconnect(self) -> None:
121
+ """Close the connection to the Unity server."""
122
+ await self._transport.aclose()
123
+
124
+ async def execute(self, on_execute) -> None:
125
+ """Run a callback in a worker thread with this client as its argument."""
126
+ await anyio.to_thread.run_sync(on_execute, self)
127
+
128
+ def add_abbreviated_classes(self, class_names: List[str]) -> None:
129
+ """Register a class name whose methods can be called without namespace prefix."""
130
+ if isinstance(class_names, str):
131
+ class_names = [class_names]
132
+ self._call_sync("add_abbreviated_classes", [class_names])
133
+
134
+ def add_abbreviated_namespaces(self, namespaces: List[str]) -> None:
135
+ """Register namespaces whose types can be referred to by simple name."""
136
+ if isinstance(namespaces, str):
137
+ namespaces = [namespaces]
138
+ self._call_sync("add_abbreviated_namespaces", [namespaces])
@@ -0,0 +1,26 @@
1
+ """Minimal C#-style multicast event supporting += / -= handler registration."""
2
+ from __future__ import annotations
3
+ from typing import Callable, List
4
+
5
+
6
+ class Event:
7
+ """A list of callbacks invoked in registration order when called."""
8
+
9
+ def __init__(self) -> None:
10
+ self._handlers: List[Callable] = []
11
+
12
+ def __iadd__(self, handler: Callable) -> "Event":
13
+ self._handlers.append(handler)
14
+ return self
15
+
16
+ def __isub__(self, handler: Callable) -> "Event":
17
+ try:
18
+ self._handlers.remove(handler)
19
+ except ValueError:
20
+ pass
21
+ return self
22
+
23
+ def __call__(self, *args, **kwargs) -> None:
24
+ # Snapshot so a handler that mutates registration mid-dispatch is safe.
25
+ for handler in list(self._handlers):
26
+ handler(*args, **kwargs)
@@ -0,0 +1,92 @@
1
+ """Lazy proxies for Unity object property/method access over RPC."""
2
+ from __future__ import annotations
3
+ from typing import Any, List
4
+
5
+ from .models import RpcChainStep
6
+
7
+ def _resolve_arg(arg):
8
+ if isinstance(arg, PropertyProxy):
9
+ return arg()
10
+ else:
11
+ return arg
12
+
13
+ def _resolve_args(args: List):
14
+ ret = []
15
+ for arg in args:
16
+ ret.append(_resolve_arg(arg))
17
+ return ret
18
+
19
+ class ObjectProxy:
20
+ """Proxy for a live Unity object; attribute access builds RPC chains.
21
+
22
+ The raw server descriptor is kept on ``data``; garbage collection of this
23
+ proxy schedules a batched release of the underlying Unity object.
24
+ """
25
+
26
+ def __init__(self, client, data: dict):
27
+ object.__setattr__(self, "_client", client)
28
+ object.__setattr__(self, "data", data)
29
+ client._track_release(self, data)
30
+
31
+ def __getattr__(self, name: str) -> Any:
32
+ if name.startswith("_"):
33
+ raise AttributeError(name)
34
+ return PropertyProxy(self._client, self.data, [name])
35
+
36
+ def __setattr__(self, name: str, value: Any) -> None:
37
+ if name.startswith("_") or name == "data":
38
+ object.__setattr__(self, name, value)
39
+ return
40
+ value = _resolve_arg(value)
41
+ self._client._call_sync("JsonRpc_ResolveChainSet", [self.data, [], name, value])
42
+
43
+ def __repr__(self) -> str:
44
+ return "ObjectProxy(%r)" % (self.data,)
45
+
46
+
47
+ class PropertyProxy:
48
+ """Accumulates a property/method chain, resolved server-side when called.
49
+
50
+ Examples::
51
+
52
+ obj.prop # proxy
53
+ obj.prop() # resolved value
54
+ obj.parent.child # chained getter
55
+ obj.GetComponent('Foo') # chained method call
56
+ """
57
+
58
+ def __init__(self, client, obj, chain: List[str]):
59
+ object.__setattr__(self, "_client", client)
60
+ object.__setattr__(self, "_obj", obj)
61
+ object.__setattr__(self, "_chain", chain)
62
+
63
+ def __getattr__(self, name: str) -> "PropertyProxy":
64
+ if name.startswith("_"):
65
+ raise AttributeError(name)
66
+ return PropertyProxy(self._client, self._obj, self._chain + [name])
67
+
68
+ def __setattr__(self, name: str, value: Any) -> None:
69
+ if name.startswith("_"):
70
+ object.__setattr__(self, name, value)
71
+ return
72
+ value = _resolve_arg(value)
73
+ steps = [RpcChainStep(p) for p in self._chain]
74
+ self._client._call_sync("JsonRpc_ResolveChainSet", [self._obj, steps, name, value])
75
+
76
+ async def _resolve(self, *args) -> Any:
77
+ """Send the accumulated chain to Unity and return the resolved value."""
78
+ method = self._chain[-1]
79
+ params = list(args)
80
+ if (self._obj is None) and (len(self._chain) <= 1):
81
+ return await self._client._call_async(method, params)
82
+ steps = [RpcChainStep(p) for p in self._chain[:-1]]
83
+ return await self._client._call_async(
84
+ "JsonRpc_ResolveChain", [self._obj, steps, method, params])
85
+
86
+ def __call__(self, *args) -> Any:
87
+ """Resolve synchronously (worker thread) or return a coroutine (async)."""
88
+ args = _resolve_args(args)
89
+ return self._client._run(self._resolve(*args))
90
+
91
+ def __await__(self):
92
+ return self._resolve().__await__()
@@ -0,0 +1,40 @@
1
+ """RPC wire-protocol type definitions.
2
+
3
+ Auto-generated by tools/generate_schema.py.
4
+ Do not edit manually. Edit tools/schema_def.py and re-run the generator.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+
12
+ @dataclass
13
+ class RpcType:
14
+ """Represents a .NET Type reference transmitted as a JSON-RPC parameter."""
15
+
16
+ value: str
17
+ rpcType: int = 1
18
+
19
+
20
+ @dataclass
21
+ class RpcChainStep:
22
+ """Single step in a property/method chain resolved server-side."""
23
+
24
+ name: str
25
+
26
+
27
+ @dataclass
28
+ class ReleaseRequest:
29
+ """Batch request to release Unity object references held by the server."""
30
+
31
+ data_list: list[dict]
32
+ action: Literal['release_objects'] = 'release_objects'
33
+
34
+
35
+ @dataclass
36
+ class RpcEnum:
37
+ """Represents a .NET Type reference transmitted as a JSON-RPC parameter."""
38
+
39
+ value: str
40
+ rpcEnum: int = 1
@@ -0,0 +1,26 @@
1
+ """JSON encode/decode hooks bridging Python values and the JSON-RPC wire format."""
2
+ from __future__ import annotations
3
+ import dataclasses
4
+ from typing import Any
5
+
6
+ from ._proxy import ObjectProxy
7
+
8
+ class Serialization:
9
+ """Namespace for the json.dumps default and json.loads object_hook factories."""
10
+ def __init__(self, make_object_proxy):
11
+ self._make_object_proxy = make_object_proxy
12
+
13
+ @staticmethod
14
+ def encode_default(obj: Any) -> Any:
15
+ """json.dumps default: ObjectProxy -> raw dict; dataclass -> dict."""
16
+ if isinstance(obj, ObjectProxy):
17
+ return obj.data
18
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
19
+ return dataclasses.asdict(obj)
20
+ raise TypeError("Object of type %s is not JSON serializable" % type(obj).__name__)
21
+
22
+ def object_hook(self, d: dict):
23
+ """Build a json.loads object_hook: InstanceObject dict -> ObjectProxy."""
24
+ if "instanceObjectAttr" in d.keys():
25
+ return self._make_object_proxy(d)
26
+ return d
@@ -0,0 +1,97 @@
1
+ """Transport implementations: byte-stream openers wrapping the JSON-RPC core.
2
+
3
+ Currently TCP and stdio. Unix-domain-socket can be added later by
4
+ subclassing ``StreamTransport`` and implementing ``_open()`` with
5
+ ``anyio.connect_unix``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ import anyio
12
+
13
+ from ..core import JsonRpcPeer, StdioByteStream
14
+ from typing import Any, TYPE_CHECKING
15
+ if TYPE_CHECKING:
16
+ from ._serialization import Serialization
17
+
18
+ import logging
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class StreamTransport:
22
+ """Base transport: opens a byte stream and runs a JsonRpcPeer over it.
23
+
24
+ The serve loop runs as a plain asyncio Task so open/aclose can be called
25
+ from different asyncio tasks without triggering anyio cancel-scope affinity
26
+ errors.
27
+ """
28
+
29
+ def __init__(self):
30
+ self._peer = None
31
+ self._serve_task = None
32
+ self._default = None
33
+ self._object_hook = None
34
+
35
+ def bind_codec(self, serialization : Serialization) -> None:
36
+ """Set the json.dumps default / json.loads object_hook used by the peer."""
37
+ self._default = serialization.encode_default
38
+ self._object_hook = serialization.object_hook
39
+
40
+ async def _open(self):
41
+ """Open and return the underlying byte stream. Override in subclasses."""
42
+ raise NotImplementedError
43
+
44
+ async def open(self) -> None:
45
+ stream = await self._open()
46
+ self._peer = JsonRpcPeer(stream, self._default, self._object_hook)
47
+ self._serve_task = asyncio.ensure_future(self._peer.serve())
48
+
49
+ async def aclose(self) -> None:
50
+ if self._peer is not None:
51
+ await self._peer.aclose()
52
+ self._peer = None
53
+ if self._serve_task is not None:
54
+ task = self._serve_task
55
+ self._serve_task = None
56
+ if not task.done():
57
+ task.cancel()
58
+ try:
59
+ await task
60
+ except (asyncio.CancelledError, Exception):
61
+ pass
62
+
63
+ @property
64
+ def closed(self) -> bool:
65
+ return self._peer is None or self._peer.closed
66
+
67
+ async def rpc_call(self, method, params):
68
+ logger.debug("rpc_call %s(%s)", method, params)
69
+ ret = await self._peer.request(method, params)
70
+ logger.debug("rpc_call ret: %s", ret)
71
+ return ret
72
+
73
+ async def rpc_notify(self, method, params):
74
+ await self._peer.notify(method, params)
75
+
76
+
77
+ class StdioJsonRpcTransport(StreamTransport):
78
+ """JSON-RPC transport over this process's stdin/stdout."""
79
+
80
+ async def _open(self):
81
+ return StdioByteStream()
82
+
83
+
84
+ class TcpJsonRpcTransport(StreamTransport):
85
+ """JSON-RPC transport over a raw TCP socket.
86
+
87
+ anyio's SocketStream already implements the send/receive/aclose
88
+ byte-stream interface JsonRpcPeer expects, so no adapter is needed.
89
+ """
90
+
91
+ def __init__(self, host: str, port: int):
92
+ super().__init__()
93
+ self._host = host
94
+ self._port = port
95
+
96
+ async def _open(self):
97
+ return await anyio.connect_tcp(self._host, self._port)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from ._schema import *
4
+
5
+ def type_(value: str):
6
+ """Create an RpcType parameter for passing a .NET Type to a Unity RPC method.
7
+
8
+ Args:
9
+ value: Fully qualified .NET type name, e.g. ``"UnityEngine.GameObject"``.
10
+
11
+ Returns:
12
+ An RpcType instance wrapping the given type name.
13
+ """
14
+ return RpcType(value)
15
+
16
+ def enum(value: str):
17
+ return RpcEnum(value)
@@ -0,0 +1,20 @@
1
+ """Role-agnostic JSON-RPC core shared by lliquidlink.client and lliquidlink.server."""
2
+ from __future__ import annotations
3
+
4
+ from ._rpc import (
5
+ JsonRpcPeer,
6
+ StdioByteStream,
7
+ ConnectionClosedError,
8
+ RpcError,
9
+ encode_frame,
10
+ decode_frame,
11
+ )
12
+
13
+ __all__ = [
14
+ "JsonRpcPeer",
15
+ "StdioByteStream",
16
+ "ConnectionClosedError",
17
+ "RpcError",
18
+ "encode_frame",
19
+ "decode_frame",
20
+ ]