py-easysync 0.1.0__tar.gz
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.
- py_easysync-0.1.0/PKG-INFO +123 -0
- py_easysync-0.1.0/README.md +102 -0
- py_easysync-0.1.0/easysync/__init__.py +10 -0
- py_easysync-0.1.0/easysync/syncclient.py +103 -0
- py_easysync-0.1.0/easysync/syncedobject.py +303 -0
- py_easysync-0.1.0/easysync/syncserver.py +119 -0
- py_easysync-0.1.0/py_easysync.egg-info/PKG-INFO +123 -0
- py_easysync-0.1.0/py_easysync.egg-info/SOURCES.txt +10 -0
- py_easysync-0.1.0/py_easysync.egg-info/dependency_links.txt +1 -0
- py_easysync-0.1.0/py_easysync.egg-info/top_level.txt +1 -0
- py_easysync-0.1.0/setup.cfg +4 -0
- py_easysync-0.1.0/setup.py +20 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-easysync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal real-time state synchronization for Python.
|
|
5
|
+
Home-page: https://github.com/GalTechDev/easysync
|
|
6
|
+
Author: GalTechDev
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: System :: Networking
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: classifier
|
|
16
|
+
Dynamic: description
|
|
17
|
+
Dynamic: description-content-type
|
|
18
|
+
Dynamic: home-page
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# EasySync
|
|
23
|
+
|
|
24
|
+
Universal real-time state synchronization for Python.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
Manipulate your Python objects as if the network didn't exist. EasySync intercepts attribute mutations through a transparent proxy and propagates them instantly to all machines connected to the server.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install easysync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
**Server** (hosts the shared state):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from easysync import SyncedObject, SyncServer, connect
|
|
43
|
+
|
|
44
|
+
server = SyncServer(port=5000)
|
|
45
|
+
server.start_thread()
|
|
46
|
+
|
|
47
|
+
client = connect("127.0.0.1", 5000)
|
|
48
|
+
|
|
49
|
+
@SyncedObject(client)
|
|
50
|
+
class GameState:
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.score = 0
|
|
53
|
+
self.players = []
|
|
54
|
+
|
|
55
|
+
state = GameState()
|
|
56
|
+
state.score = 42 # propagated to all clients
|
|
57
|
+
state.players.append("A") # propagated too
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Client** (joins the server):
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from easysync import SyncedObject, connect
|
|
64
|
+
|
|
65
|
+
client = connect("192.168.1.10", 5000)
|
|
66
|
+
|
|
67
|
+
@SyncedObject(client)
|
|
68
|
+
class GameState:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.score = 0
|
|
71
|
+
self.players = []
|
|
72
|
+
|
|
73
|
+
state = GameState()
|
|
74
|
+
print(state.score) # 42, updated in real time
|
|
75
|
+
print(state.players) # ['A']
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
The server uses `asyncio` for non-blocking connection handling, allowing it to support a large number of simultaneous clients without CPU overhead. The client uses a dedicated receive thread to remain compatible with standard application loops (Pygame, Matplotlib, etc.).
|
|
81
|
+
|
|
82
|
+
The wire protocol relies on binary Pickle serialization framed by a 4-byte header (payload size). This ensures measured latency under 20ms.
|
|
83
|
+
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **Zero configuration**: a single `@SyncedObject` decorator is all you need.
|
|
87
|
+
- **Transparent proxy**: automatic interception of `__setattr__`, `__setitem__`, `append`, `pop`, etc.
|
|
88
|
+
- **Asyncio server**: built on `asyncio.start_server` for maximum scalability.
|
|
89
|
+
- **Binary serialization**: Pickle + TCP framing protocol, latency < 20ms.
|
|
90
|
+
- **Zero dependencies**: only uses the Python standard library.
|
|
91
|
+
- **Data Science ready**: optimized handling of NumPy, Pandas and Scikit-Learn objects via the copy-and-reassign pattern.
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
The `examples/` folder contains several demos:
|
|
96
|
+
|
|
97
|
+
| File | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `pygame_example.py` | Synchronized square between two Pygame windows |
|
|
100
|
+
| `pygame_hanoi.py` | Collaborative Tower of Hanoi |
|
|
101
|
+
| `numpy_matplotlib_example.py` | NumPy data streaming with Matplotlib chart |
|
|
102
|
+
| `pandas_example.py` | Collaborative Pandas spreadsheet |
|
|
103
|
+
| `sklearn_live_training.py` | Live Scikit-Learn training visualization |
|
|
104
|
+
| `federated_learning_example.py` | Distributed federated learning |
|
|
105
|
+
| `genetic_island_example.py` | Distributed genetic algorithm (island model) |
|
|
106
|
+
| `tetris_ai_example.py` | Distributed Tetris AI via genetic algorithm |
|
|
107
|
+
|
|
108
|
+
To run the examples, install the additional dependencies:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install -r requirements_examples.txt
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then launch a server and one or more clients:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
python examples/pygame_example.py server # Terminal 1
|
|
118
|
+
python examples/pygame_example.py # Terminal 2
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# EasySync
|
|
2
|
+
|
|
3
|
+
Universal real-time state synchronization for Python.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Manipulate your Python objects as if the network didn't exist. EasySync intercepts attribute mutations through a transparent proxy and propagates them instantly to all machines connected to the server.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install easysync
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
**Server** (hosts the shared state):
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from easysync import SyncedObject, SyncServer, connect
|
|
22
|
+
|
|
23
|
+
server = SyncServer(port=5000)
|
|
24
|
+
server.start_thread()
|
|
25
|
+
|
|
26
|
+
client = connect("127.0.0.1", 5000)
|
|
27
|
+
|
|
28
|
+
@SyncedObject(client)
|
|
29
|
+
class GameState:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.score = 0
|
|
32
|
+
self.players = []
|
|
33
|
+
|
|
34
|
+
state = GameState()
|
|
35
|
+
state.score = 42 # propagated to all clients
|
|
36
|
+
state.players.append("A") # propagated too
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Client** (joins the server):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from easysync import SyncedObject, connect
|
|
43
|
+
|
|
44
|
+
client = connect("192.168.1.10", 5000)
|
|
45
|
+
|
|
46
|
+
@SyncedObject(client)
|
|
47
|
+
class GameState:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self.score = 0
|
|
50
|
+
self.players = []
|
|
51
|
+
|
|
52
|
+
state = GameState()
|
|
53
|
+
print(state.score) # 42, updated in real time
|
|
54
|
+
print(state.players) # ['A']
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Architecture
|
|
58
|
+
|
|
59
|
+
The server uses `asyncio` for non-blocking connection handling, allowing it to support a large number of simultaneous clients without CPU overhead. The client uses a dedicated receive thread to remain compatible with standard application loops (Pygame, Matplotlib, etc.).
|
|
60
|
+
|
|
61
|
+
The wire protocol relies on binary Pickle serialization framed by a 4-byte header (payload size). This ensures measured latency under 20ms.
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Zero configuration**: a single `@SyncedObject` decorator is all you need.
|
|
66
|
+
- **Transparent proxy**: automatic interception of `__setattr__`, `__setitem__`, `append`, `pop`, etc.
|
|
67
|
+
- **Asyncio server**: built on `asyncio.start_server` for maximum scalability.
|
|
68
|
+
- **Binary serialization**: Pickle + TCP framing protocol, latency < 20ms.
|
|
69
|
+
- **Zero dependencies**: only uses the Python standard library.
|
|
70
|
+
- **Data Science ready**: optimized handling of NumPy, Pandas and Scikit-Learn objects via the copy-and-reassign pattern.
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
The `examples/` folder contains several demos:
|
|
75
|
+
|
|
76
|
+
| File | Description |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `pygame_example.py` | Synchronized square between two Pygame windows |
|
|
79
|
+
| `pygame_hanoi.py` | Collaborative Tower of Hanoi |
|
|
80
|
+
| `numpy_matplotlib_example.py` | NumPy data streaming with Matplotlib chart |
|
|
81
|
+
| `pandas_example.py` | Collaborative Pandas spreadsheet |
|
|
82
|
+
| `sklearn_live_training.py` | Live Scikit-Learn training visualization |
|
|
83
|
+
| `federated_learning_example.py` | Distributed federated learning |
|
|
84
|
+
| `genetic_island_example.py` | Distributed genetic algorithm (island model) |
|
|
85
|
+
| `tetris_ai_example.py` | Distributed Tetris AI via genetic algorithm |
|
|
86
|
+
|
|
87
|
+
To run the examples, install the additional dependencies:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install -r requirements_examples.txt
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then launch a server and one or more clients:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python examples/pygame_example.py server # Terminal 1
|
|
97
|
+
python examples/pygame_example.py # Terminal 2
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from easysync.syncedobject import SyncedObject, SyncedVar, SyncedProxy, connect, get_client
|
|
2
|
+
from easysync.syncserver import SyncServer
|
|
3
|
+
from easysync.syncclient import SyncClient
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SyncedObject", "SyncedVar", "SyncedProxy",
|
|
8
|
+
"SyncServer", "SyncClient",
|
|
9
|
+
"connect", "get_client",
|
|
10
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
import pickle
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SyncClient:
|
|
8
|
+
"""TCP client that connects to a SyncServer to synchronize objects."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, host="localhost", port=5000, auth_payload=None):
|
|
11
|
+
self.host = host
|
|
12
|
+
self.port = port
|
|
13
|
+
self.auth_payload = auth_payload
|
|
14
|
+
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
15
|
+
self.connected = False
|
|
16
|
+
self.callbacks = {}
|
|
17
|
+
self.on_sync_request_callback = None
|
|
18
|
+
|
|
19
|
+
def connect(self):
|
|
20
|
+
try:
|
|
21
|
+
self.client_socket.connect((self.host, self.port))
|
|
22
|
+
|
|
23
|
+
self._send_packet({"type": "auth", "payload": self.auth_payload})
|
|
24
|
+
resp = self._recv_packet()
|
|
25
|
+
if not resp:
|
|
26
|
+
print("[EasySync] Connection lost during authentication")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
if resp.get("type") == "auth_reject":
|
|
30
|
+
print("[EasySync] Authentication rejected by server")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
self.connected = True
|
|
34
|
+
print(f"[EasySync] Connected to {self.host}:{self.port}")
|
|
35
|
+
self._send_packet({"type": "request_sync"})
|
|
36
|
+
|
|
37
|
+
t = threading.Thread(target=self.receive_loop, daemon=True)
|
|
38
|
+
t.start()
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"[EasySync] Connection failed: {e}")
|
|
41
|
+
|
|
42
|
+
def _send_packet(self, data):
|
|
43
|
+
if self.client_socket:
|
|
44
|
+
raw = pickle.dumps(data)
|
|
45
|
+
self.client_socket.sendall(struct.pack(">I", len(raw)) + raw)
|
|
46
|
+
|
|
47
|
+
def _recv_n_bytes(self, n):
|
|
48
|
+
buf = bytearray()
|
|
49
|
+
while len(buf) < n:
|
|
50
|
+
chunk = self.client_socket.recv(n - len(buf))
|
|
51
|
+
if not chunk:
|
|
52
|
+
return None
|
|
53
|
+
buf.extend(chunk)
|
|
54
|
+
return bytes(buf)
|
|
55
|
+
|
|
56
|
+
def _recv_packet(self):
|
|
57
|
+
header = self._recv_n_bytes(4)
|
|
58
|
+
if not header:
|
|
59
|
+
return None
|
|
60
|
+
length = struct.unpack(">I", header)[0]
|
|
61
|
+
data = self._recv_n_bytes(length)
|
|
62
|
+
if not data:
|
|
63
|
+
return None
|
|
64
|
+
return pickle.loads(data)
|
|
65
|
+
|
|
66
|
+
def send_update(self, object_id, attr_name, value):
|
|
67
|
+
if not self.connected:
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
self._send_packet({
|
|
71
|
+
"type": "update",
|
|
72
|
+
"object_id": object_id,
|
|
73
|
+
"attr_name": attr_name,
|
|
74
|
+
"value": value,
|
|
75
|
+
})
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f"[EasySync] Send error: {e}")
|
|
78
|
+
|
|
79
|
+
def register_callback(self, object_id, callback):
|
|
80
|
+
self.callbacks[object_id] = callback
|
|
81
|
+
|
|
82
|
+
def receive_loop(self):
|
|
83
|
+
while self.connected:
|
|
84
|
+
try:
|
|
85
|
+
message = self._recv_packet()
|
|
86
|
+
if message is None:
|
|
87
|
+
print("[EasySync] Disconnected from server")
|
|
88
|
+
self.connected = False
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
msg_type = message.get("type")
|
|
92
|
+
if msg_type == "update":
|
|
93
|
+
oid = message.get("object_id")
|
|
94
|
+
if oid and oid in self.callbacks:
|
|
95
|
+
self.callbacks[oid](message)
|
|
96
|
+
elif msg_type == "request_sync":
|
|
97
|
+
if self.on_sync_request_callback:
|
|
98
|
+
self.on_sync_request_callback()
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"[EasySync] Network error: {e}")
|
|
102
|
+
self.connected = False
|
|
103
|
+
break
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import weakref
|
|
5
|
+
|
|
6
|
+
from easysync.syncclient import SyncClient
|
|
7
|
+
|
|
8
|
+
_default_client = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncedProxy:
|
|
12
|
+
"""Transparent proxy that intercepts mutations and triggers the network callback."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, target, callback):
|
|
15
|
+
object.__setattr__(self, "_target", target)
|
|
16
|
+
object.__setattr__(self, "_callback", callback)
|
|
17
|
+
|
|
18
|
+
def __getattribute__(self, name):
|
|
19
|
+
if name in ("_target", "_callback", "_trigger", "__class__",
|
|
20
|
+
"__array__", "__array_struct__", "__array_interface__"):
|
|
21
|
+
if name in ("__array__", "__array_struct__", "__array_interface__"):
|
|
22
|
+
return getattr(object.__getattribute__(self, "_target"), name)
|
|
23
|
+
return object.__getattribute__(self, name)
|
|
24
|
+
|
|
25
|
+
target = object.__getattribute__(self, "_target")
|
|
26
|
+
attr = getattr(target, name)
|
|
27
|
+
callback = object.__getattribute__(self, "_callback")
|
|
28
|
+
return _deep_wrap(attr, callback)
|
|
29
|
+
|
|
30
|
+
def __call__(self, *args, **kwargs):
|
|
31
|
+
target = object.__getattribute__(self, "_target")
|
|
32
|
+
callback = object.__getattribute__(self, "_callback")
|
|
33
|
+
ret = target(*args, **kwargs)
|
|
34
|
+
object.__getattribute__(self, "_trigger")()
|
|
35
|
+
return _deep_wrap(ret, callback)
|
|
36
|
+
|
|
37
|
+
def __setattr__(self, name, value):
|
|
38
|
+
setattr(object.__getattribute__(self, "_target"), name, value)
|
|
39
|
+
object.__getattribute__(self, "_trigger")()
|
|
40
|
+
|
|
41
|
+
def _trigger(self):
|
|
42
|
+
cb = object.__getattribute__(self, "_callback")
|
|
43
|
+
if cb:
|
|
44
|
+
cb()
|
|
45
|
+
|
|
46
|
+
def __getitem__(self, key):
|
|
47
|
+
res = object.__getattribute__(self, "_target")[key]
|
|
48
|
+
return _deep_wrap(res, object.__getattribute__(self, "_callback"))
|
|
49
|
+
|
|
50
|
+
def __setitem__(self, key, value):
|
|
51
|
+
object.__getattribute__(self, "_target")[key] = value
|
|
52
|
+
object.__getattribute__(self, "_trigger")()
|
|
53
|
+
|
|
54
|
+
def __delitem__(self, key):
|
|
55
|
+
del object.__getattribute__(self, "_target")[key]
|
|
56
|
+
object.__getattribute__(self, "_trigger")()
|
|
57
|
+
|
|
58
|
+
def __iter__(self):
|
|
59
|
+
target = object.__getattribute__(self, "_target")
|
|
60
|
+
callback = object.__getattribute__(self, "_callback")
|
|
61
|
+
for item in target:
|
|
62
|
+
yield _deep_wrap(item, callback)
|
|
63
|
+
|
|
64
|
+
def __len__(self):
|
|
65
|
+
return len(object.__getattribute__(self, "_target"))
|
|
66
|
+
|
|
67
|
+
def __repr__(self):
|
|
68
|
+
return repr(object.__getattribute__(self, "_target"))
|
|
69
|
+
|
|
70
|
+
def __str__(self):
|
|
71
|
+
return str(object.__getattribute__(self, "_target"))
|
|
72
|
+
|
|
73
|
+
def __array__(self, *args, **kwargs):
|
|
74
|
+
target = object.__getattribute__(self, "_target")
|
|
75
|
+
if hasattr(target, "__array__"):
|
|
76
|
+
return target.__array__(*args, **kwargs)
|
|
77
|
+
return NotImplemented
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _deep_wrap(value, callback):
|
|
81
|
+
"""Recursively wraps an object in a SyncedProxy.
|
|
82
|
+
Immutable types and C-extensions (numpy, pandas) are excluded."""
|
|
83
|
+
import numbers
|
|
84
|
+
if value is None or isinstance(value, (numbers.Number, str, bool, tuple, bytes)):
|
|
85
|
+
return value
|
|
86
|
+
if type(value).__name__ in ("ndarray", "DataFrame", "Series"):
|
|
87
|
+
return value
|
|
88
|
+
if isinstance(value, SyncedProxy):
|
|
89
|
+
object.__setattr__(value, "_callback", callback)
|
|
90
|
+
return value
|
|
91
|
+
return SyncedProxy(value, callback)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unproxy(value):
|
|
95
|
+
"""Recursively strips proxy layers to get the raw object."""
|
|
96
|
+
if isinstance(value, SyncedProxy):
|
|
97
|
+
return _unproxy(object.__getattribute__(value, "_target"))
|
|
98
|
+
if type(value) is list:
|
|
99
|
+
return [_unproxy(x) for x in value]
|
|
100
|
+
if type(value) is dict:
|
|
101
|
+
return {k: _unproxy(v) for k, v in value.items()}
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------- Global registry and polling loop ----------
|
|
106
|
+
|
|
107
|
+
_master_synced_vars = []
|
|
108
|
+
_master_synced_objects = weakref.WeakSet()
|
|
109
|
+
_master_poller_thread = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _master_poller_loop():
|
|
113
|
+
"""Single thread that watches for unintercepted local mutations."""
|
|
114
|
+
while True:
|
|
115
|
+
time.sleep(0.05)
|
|
116
|
+
for var in list(_master_synced_vars):
|
|
117
|
+
try:
|
|
118
|
+
if var._updating:
|
|
119
|
+
continue
|
|
120
|
+
current_val = var.namespace.get(var.var_name)
|
|
121
|
+
if current_val != var.last_val:
|
|
122
|
+
var.last_val = current_val
|
|
123
|
+
if var._client:
|
|
124
|
+
var._client.send_update(var.var_name, "value", current_val)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _handle_sync_request():
|
|
130
|
+
"""Responds to a resync request from a newly connected client."""
|
|
131
|
+
for var in _master_synced_vars:
|
|
132
|
+
if var._client:
|
|
133
|
+
var._client.send_update(var.var_name, "value", var.last_val)
|
|
134
|
+
|
|
135
|
+
for obj in list(_master_synced_objects):
|
|
136
|
+
try:
|
|
137
|
+
c = object.__getattribute__(obj, "_sync_client")
|
|
138
|
+
oid = object.__getattribute__(obj, "_sync_object_id")
|
|
139
|
+
if c:
|
|
140
|
+
import types
|
|
141
|
+
for attr_name in dir(obj):
|
|
142
|
+
if not attr_name.startswith("_"):
|
|
143
|
+
val = getattr(obj, attr_name)
|
|
144
|
+
if not isinstance(val, types.MethodType):
|
|
145
|
+
c.send_update(oid, attr_name, _unproxy(val))
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------- Public API ----------
|
|
151
|
+
|
|
152
|
+
def connect(host="localhost", port=5000):
|
|
153
|
+
"""Connect to a remote SyncServer and return the client instance."""
|
|
154
|
+
global _default_client
|
|
155
|
+
_default_client = SyncClient(host=host, port=port)
|
|
156
|
+
_default_client.on_sync_request_callback = _handle_sync_request
|
|
157
|
+
_default_client.connect()
|
|
158
|
+
return _default_client
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_client():
|
|
162
|
+
"""Return the default client."""
|
|
163
|
+
return _default_client
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def SyncedObject(client=None):
|
|
167
|
+
"""Class decorator: synchronizes public attributes over the network."""
|
|
168
|
+
def decorator(cls):
|
|
169
|
+
_object_id = cls.__qualname__
|
|
170
|
+
original_init = cls.__init__ if hasattr(cls, "__init__") else None
|
|
171
|
+
|
|
172
|
+
def new_init(self, *args, _sync_client=None, **kwargs):
|
|
173
|
+
resolved_client = _sync_client or client or _default_client
|
|
174
|
+
object.__setattr__(self, "_sync_client", resolved_client)
|
|
175
|
+
object.__setattr__(self, "_sync_object_id", _object_id)
|
|
176
|
+
object.__setattr__(self, "_sync_updating", False)
|
|
177
|
+
|
|
178
|
+
if resolved_client:
|
|
179
|
+
resolved_client.register_callback(
|
|
180
|
+
_object_id, lambda msg, obj=self: _apply_update(obj, msg)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if original_init:
|
|
184
|
+
original_init(self, *args, **kwargs)
|
|
185
|
+
|
|
186
|
+
_master_synced_objects.add(self)
|
|
187
|
+
|
|
188
|
+
def new_setattr(self, name, value):
|
|
189
|
+
if name.startswith("_"):
|
|
190
|
+
object.__setattr__(self, name, value)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
def trigger_sync():
|
|
194
|
+
try:
|
|
195
|
+
if object.__getattribute__(self, "_sync_updating"):
|
|
196
|
+
return
|
|
197
|
+
except AttributeError:
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
c = object.__getattribute__(self, "_sync_client")
|
|
201
|
+
oid = object.__getattribute__(self, "_sync_object_id")
|
|
202
|
+
if c:
|
|
203
|
+
unproxied = _unproxy(object.__getattribute__(self, name))
|
|
204
|
+
c.send_update(oid, name, unproxied)
|
|
205
|
+
except AttributeError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
wrapped_value = _deep_wrap(value, trigger_sync)
|
|
209
|
+
object.__setattr__(self, name, wrapped_value)
|
|
210
|
+
trigger_sync()
|
|
211
|
+
|
|
212
|
+
cls.__init__ = new_init
|
|
213
|
+
cls.__setattr__ = new_setattr
|
|
214
|
+
return cls
|
|
215
|
+
return decorator
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _apply_update(obj, message):
|
|
219
|
+
"""Apply a network update to the local object."""
|
|
220
|
+
attr_name = message.get("attr_name")
|
|
221
|
+
value = message.get("value")
|
|
222
|
+
|
|
223
|
+
if attr_name:
|
|
224
|
+
if attr_name.startswith("_"):
|
|
225
|
+
return
|
|
226
|
+
if not hasattr(obj, attr_name):
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
object.__setattr__(obj, "_sync_updating", True)
|
|
230
|
+
try:
|
|
231
|
+
setattr(obj, attr_name, value)
|
|
232
|
+
finally:
|
|
233
|
+
object.__setattr__(obj, "_sync_updating", False)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SyncedVar:
|
|
237
|
+
"""Simple network-synchronized variable."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, value=None, client=None):
|
|
240
|
+
global _master_poller_thread
|
|
241
|
+
|
|
242
|
+
self._client = client or _default_client
|
|
243
|
+
self.frame = inspect.currentframe().f_back
|
|
244
|
+
self.namespace = self.frame.f_globals
|
|
245
|
+
self.var_name = None
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
import dis
|
|
249
|
+
instructions = list(dis.get_instructions(self.frame.f_code))
|
|
250
|
+
for i, inst in enumerate(instructions):
|
|
251
|
+
if inst.offset == self.frame.f_lasti:
|
|
252
|
+
for j in range(i - 1, -1, -1):
|
|
253
|
+
prev_inst = instructions[j]
|
|
254
|
+
if prev_inst.opname in ("LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL", "LOAD_DEREF"):
|
|
255
|
+
self.var_name = prev_inst.argval
|
|
256
|
+
break
|
|
257
|
+
elif prev_inst.opname.startswith("CALL") or prev_inst.opname.startswith("PRECALL"):
|
|
258
|
+
break
|
|
259
|
+
break
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
if not self.var_name:
|
|
264
|
+
raise ValueError("Could not resolve variable name.")
|
|
265
|
+
|
|
266
|
+
self.last_val = self.namespace.get(self.var_name, value)
|
|
267
|
+
self._updating = False
|
|
268
|
+
|
|
269
|
+
if self._client:
|
|
270
|
+
self._client.register_callback(self.var_name, self._apply_net_update)
|
|
271
|
+
|
|
272
|
+
_master_synced_vars.append(self)
|
|
273
|
+
|
|
274
|
+
if _master_poller_thread is None:
|
|
275
|
+
_master_poller_thread = threading.Thread(target=_master_poller_loop, daemon=True)
|
|
276
|
+
_master_poller_thread.start()
|
|
277
|
+
|
|
278
|
+
def _apply_net_update(self, message):
|
|
279
|
+
val = message.get("value")
|
|
280
|
+
if val is not None:
|
|
281
|
+
self._updating = True
|
|
282
|
+
try:
|
|
283
|
+
self.namespace[self.var_name] = val
|
|
284
|
+
self.last_val = val
|
|
285
|
+
finally:
|
|
286
|
+
self._updating = False
|
|
287
|
+
|
|
288
|
+
def get(self):
|
|
289
|
+
return self.last_val
|
|
290
|
+
|
|
291
|
+
def set(self, value):
|
|
292
|
+
self.namespace[self.var_name] = value
|
|
293
|
+
|
|
294
|
+
def set_client(self, client):
|
|
295
|
+
self._client = client
|
|
296
|
+
if self._client:
|
|
297
|
+
self._client.register_callback(self.var_name, self._apply_net_update)
|
|
298
|
+
|
|
299
|
+
def __repr__(self):
|
|
300
|
+
return f"SyncedVar({self.var_name!r}, {self.last_val!r})"
|
|
301
|
+
|
|
302
|
+
def __str__(self):
|
|
303
|
+
return str(self.last_val)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pickle
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SyncServer:
|
|
7
|
+
"""Async TCP server that relays state updates between all connected clients.
|
|
8
|
+
|
|
9
|
+
Uses asyncio.start_server for non-blocking connection handling.
|
|
10
|
+
Wire protocol is identical to the legacy threaded version.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, host="0.0.0.0", port=5000):
|
|
14
|
+
self.host = host
|
|
15
|
+
self.port = port
|
|
16
|
+
self.clients: list[asyncio.StreamWriter] = []
|
|
17
|
+
self.auth_handler = None
|
|
18
|
+
self._server = None
|
|
19
|
+
|
|
20
|
+
def on_auth(self, func):
|
|
21
|
+
self.auth_handler = func
|
|
22
|
+
return func
|
|
23
|
+
|
|
24
|
+
async def _send_packet(self, writer: asyncio.StreamWriter, data: dict):
|
|
25
|
+
raw = pickle.dumps(data)
|
|
26
|
+
writer.write(struct.pack(">I", len(raw)) + raw)
|
|
27
|
+
await writer.drain()
|
|
28
|
+
|
|
29
|
+
async def _recv_packet(self, reader: asyncio.StreamReader) -> dict | None:
|
|
30
|
+
header = await reader.readexactly(4)
|
|
31
|
+
length = struct.unpack(">I", header)[0]
|
|
32
|
+
raw = await reader.readexactly(length)
|
|
33
|
+
return pickle.loads(raw)
|
|
34
|
+
|
|
35
|
+
async def _broadcast(self, message: dict, sender: asyncio.StreamWriter = None):
|
|
36
|
+
raw = pickle.dumps(message)
|
|
37
|
+
frame = struct.pack(">I", len(raw)) + raw
|
|
38
|
+
|
|
39
|
+
dead = []
|
|
40
|
+
for client in self.clients:
|
|
41
|
+
if client is sender:
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
client.write(frame)
|
|
45
|
+
await client.drain()
|
|
46
|
+
except (ConnectionError, OSError):
|
|
47
|
+
dead.append(client)
|
|
48
|
+
|
|
49
|
+
for client in dead:
|
|
50
|
+
self.clients.remove(client)
|
|
51
|
+
client.close()
|
|
52
|
+
|
|
53
|
+
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
54
|
+
addr = writer.get_extra_info("peername")
|
|
55
|
+
print(f"[EasySync] New connection: {addr}")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
message = await self._recv_packet(reader)
|
|
59
|
+
if not message:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if message.get("type") == "auth":
|
|
63
|
+
if self.auth_handler:
|
|
64
|
+
if not self.auth_handler(addr, message.get("payload")):
|
|
65
|
+
await self._send_packet(writer, {"type": "auth_reject"})
|
|
66
|
+
return
|
|
67
|
+
await self._send_packet(writer, {"type": "auth_ok"})
|
|
68
|
+
else:
|
|
69
|
+
if self.auth_handler:
|
|
70
|
+
return
|
|
71
|
+
await self._broadcast(message, sender=writer)
|
|
72
|
+
|
|
73
|
+
self.clients.append(writer)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
message = await self._recv_packet(reader)
|
|
77
|
+
if not message:
|
|
78
|
+
break
|
|
79
|
+
await self._broadcast(message, sender=writer)
|
|
80
|
+
|
|
81
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
82
|
+
pass
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"[EasySync] Client error {addr}: {e}")
|
|
85
|
+
finally:
|
|
86
|
+
print(f"[EasySync] Disconnected: {addr}")
|
|
87
|
+
if writer in self.clients:
|
|
88
|
+
self.clients.remove(writer)
|
|
89
|
+
writer.close()
|
|
90
|
+
|
|
91
|
+
async def start(self):
|
|
92
|
+
self._server = await asyncio.start_server(
|
|
93
|
+
self._handle_client, self.host, self.port
|
|
94
|
+
)
|
|
95
|
+
print(f"[EasySync] Server started on {self.host}:{self.port}")
|
|
96
|
+
async with self._server:
|
|
97
|
+
await self._server.serve_forever()
|
|
98
|
+
|
|
99
|
+
def start_thread(self):
|
|
100
|
+
"""Run the async server in a dedicated thread with its own event loop."""
|
|
101
|
+
import threading
|
|
102
|
+
|
|
103
|
+
def _run():
|
|
104
|
+
loop = asyncio.new_event_loop()
|
|
105
|
+
asyncio.set_event_loop(loop)
|
|
106
|
+
loop.run_until_complete(self.start())
|
|
107
|
+
|
|
108
|
+
t = threading.Thread(target=_run, daemon=True)
|
|
109
|
+
t.start()
|
|
110
|
+
|
|
111
|
+
async def stop(self):
|
|
112
|
+
if self._server:
|
|
113
|
+
self._server.close()
|
|
114
|
+
await self._server.wait_closed()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
server = SyncServer()
|
|
119
|
+
asyncio.run(server.start())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-easysync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal real-time state synchronization for Python.
|
|
5
|
+
Home-page: https://github.com/GalTechDev/easysync
|
|
6
|
+
Author: GalTechDev
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: System :: Networking
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: classifier
|
|
16
|
+
Dynamic: description
|
|
17
|
+
Dynamic: description-content-type
|
|
18
|
+
Dynamic: home-page
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# EasySync
|
|
23
|
+
|
|
24
|
+
Universal real-time state synchronization for Python.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
Manipulate your Python objects as if the network didn't exist. EasySync intercepts attribute mutations through a transparent proxy and propagates them instantly to all machines connected to the server.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install easysync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
**Server** (hosts the shared state):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from easysync import SyncedObject, SyncServer, connect
|
|
43
|
+
|
|
44
|
+
server = SyncServer(port=5000)
|
|
45
|
+
server.start_thread()
|
|
46
|
+
|
|
47
|
+
client = connect("127.0.0.1", 5000)
|
|
48
|
+
|
|
49
|
+
@SyncedObject(client)
|
|
50
|
+
class GameState:
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.score = 0
|
|
53
|
+
self.players = []
|
|
54
|
+
|
|
55
|
+
state = GameState()
|
|
56
|
+
state.score = 42 # propagated to all clients
|
|
57
|
+
state.players.append("A") # propagated too
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Client** (joins the server):
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from easysync import SyncedObject, connect
|
|
64
|
+
|
|
65
|
+
client = connect("192.168.1.10", 5000)
|
|
66
|
+
|
|
67
|
+
@SyncedObject(client)
|
|
68
|
+
class GameState:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.score = 0
|
|
71
|
+
self.players = []
|
|
72
|
+
|
|
73
|
+
state = GameState()
|
|
74
|
+
print(state.score) # 42, updated in real time
|
|
75
|
+
print(state.players) # ['A']
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
The server uses `asyncio` for non-blocking connection handling, allowing it to support a large number of simultaneous clients without CPU overhead. The client uses a dedicated receive thread to remain compatible with standard application loops (Pygame, Matplotlib, etc.).
|
|
81
|
+
|
|
82
|
+
The wire protocol relies on binary Pickle serialization framed by a 4-byte header (payload size). This ensures measured latency under 20ms.
|
|
83
|
+
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **Zero configuration**: a single `@SyncedObject` decorator is all you need.
|
|
87
|
+
- **Transparent proxy**: automatic interception of `__setattr__`, `__setitem__`, `append`, `pop`, etc.
|
|
88
|
+
- **Asyncio server**: built on `asyncio.start_server` for maximum scalability.
|
|
89
|
+
- **Binary serialization**: Pickle + TCP framing protocol, latency < 20ms.
|
|
90
|
+
- **Zero dependencies**: only uses the Python standard library.
|
|
91
|
+
- **Data Science ready**: optimized handling of NumPy, Pandas and Scikit-Learn objects via the copy-and-reassign pattern.
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
The `examples/` folder contains several demos:
|
|
96
|
+
|
|
97
|
+
| File | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `pygame_example.py` | Synchronized square between two Pygame windows |
|
|
100
|
+
| `pygame_hanoi.py` | Collaborative Tower of Hanoi |
|
|
101
|
+
| `numpy_matplotlib_example.py` | NumPy data streaming with Matplotlib chart |
|
|
102
|
+
| `pandas_example.py` | Collaborative Pandas spreadsheet |
|
|
103
|
+
| `sklearn_live_training.py` | Live Scikit-Learn training visualization |
|
|
104
|
+
| `federated_learning_example.py` | Distributed federated learning |
|
|
105
|
+
| `genetic_island_example.py` | Distributed genetic algorithm (island model) |
|
|
106
|
+
| `tetris_ai_example.py` | Distributed Tetris AI via genetic algorithm |
|
|
107
|
+
|
|
108
|
+
To run the examples, install the additional dependencies:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install -r requirements_examples.txt
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then launch a server and one or more clients:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
python examples/pygame_example.py server # Terminal 1
|
|
118
|
+
python examples/pygame_example.py # Terminal 2
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
easysync/__init__.py
|
|
4
|
+
easysync/syncclient.py
|
|
5
|
+
easysync/syncedobject.py
|
|
6
|
+
easysync/syncserver.py
|
|
7
|
+
py_easysync.egg-info/PKG-INFO
|
|
8
|
+
py_easysync.egg-info/SOURCES.txt
|
|
9
|
+
py_easysync.egg-info/dependency_links.txt
|
|
10
|
+
py_easysync.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
easysync
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="py-easysync",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
author="GalTechDev",
|
|
7
|
+
description="Universal real-time state synchronization for Python.",
|
|
8
|
+
long_description=open("README.md", encoding="utf-8").read(),
|
|
9
|
+
long_description_content_type="text/markdown",
|
|
10
|
+
url="https://github.com/GalTechDev/easysync",
|
|
11
|
+
packages=find_packages(),
|
|
12
|
+
python_requires=">=3.10",
|
|
13
|
+
classifiers=[
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: System :: Networking",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
],
|
|
20
|
+
)
|