Skip to content

Commit c0d2181

Browse files
committed
add property
1 parent 884da80 commit c0d2181

File tree

8 files changed

+506
-18
lines changed

8 files changed

+506
-18
lines changed

docs/api.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,45 @@ except AttributeError as e:
250250
except TypeError as e:
251251
logging.error(f"Invalid slot: {e}")
252252
```
253+
254+
## Slot Execution Behavior
255+
256+
### Direct Slot Calls vs Signal Emissions
257+
258+
Slots can be executed in two ways:
259+
260+
#### Direct Calls
261+
```python
262+
# Synchronous slot direct call
263+
receiver.some_slot(value) # Blocks until execution is complete
264+
265+
# Asynchronous slot direct call
266+
await receiver.async_slot(value) # Can wait for completion with await
267+
```
268+
269+
- Behaves like a regular function call
270+
- The calling thread blocks until execution is complete
271+
- For asynchronous slots, it can wait for completion using await
272+
- The same blocking behavior occurs when called from another thread
273+
274+
#### Calls via Signal (emit)
275+
```python
276+
# Call via emit
277+
signal.emit(value) # Returns immediately, executes asynchronously
278+
```
279+
280+
- Executes asynchronously (returns immediately)
281+
- The slot is queued in the receiver's event loop
282+
- Does not wait for execution to complete
283+
- Disconnecting does not affect already queued slot executions
284+
285+
Understanding these differences is particularly important in a multithreaded environment:
286+
```python
287+
# Direct call - waits for completion
288+
async def direct_call():
289+
await receiver.async_slot() # Blocks until complete
290+
291+
# Emit - returns immediately
292+
def emit_call():
293+
signal.emit() # Returns immediately, slot executes in the background
294+
```

src/tsignal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
t_signal,
88
t_slot,
99
TConnectionType,
10+
TSignalConstants,
1011
)
1112
from .contrib.patterns.worker.decorators import t_with_worker
1213

@@ -18,4 +19,5 @@
1819
"t_slot",
1920
"t_with_worker",
2021
"TConnectionType",
22+
"TSignalConstants",
2123
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .property import t_property
2+
3+
__all__ = ["t_property"]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import asyncio
2+
import threading
3+
import logging
4+
from tsignal.core import TSignalConstants
5+
6+
# Initialize logger
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class TProperty(property):
12+
def __init__(self, fget=None, fset=None, fdel=None, doc=None, notify=None):
13+
super().__init__(fget, fset, fdel, doc)
14+
self.notify = notify
15+
self._private_name = None
16+
17+
def __set_name__(self, owner, name):
18+
self._private_name = f"_{name}"
19+
20+
def __set__(self, obj, value):
21+
if self.fset is None:
22+
raise AttributeError("can't set attribute")
23+
24+
# DEBUG: Thread safety verification logs
25+
# logger.debug(f"Object thread: {obj._thread}")
26+
# logger.debug(f"Current thread: {threading.current_thread()}")
27+
# logger.debug(f"Object loop: {obj._loop}")
28+
29+
if (
30+
hasattr(obj, TSignalConstants.THREAD)
31+
and threading.current_thread() != obj._thread
32+
):
33+
# Queue the setter call in the object's event loop
34+
future = asyncio.run_coroutine_threadsafe(
35+
self._set_value(obj, value), obj._loop
36+
)
37+
# Wait for completion like slot direct calls
38+
return future.result()
39+
else:
40+
return self._set_value_sync(obj, value)
41+
42+
def _set_value_sync(self, obj, value):
43+
old_value = self.__get__(obj, type(obj))
44+
result = self.fset(obj, value)
45+
46+
if self.notify is not None and old_value != value:
47+
try:
48+
signal_name = getattr(self.notify, "signal_name", None)
49+
50+
if signal_name:
51+
signal = getattr(obj, signal_name)
52+
signal.emit(value)
53+
else:
54+
raise AttributeError(f"No signal_name found in {self.notify}")
55+
56+
except AttributeError as e:
57+
logger.warning(
58+
f"Property {self._private_name} notify attribute not found. Error: {str(e)}"
59+
)
60+
pass
61+
62+
return result
63+
64+
async def _set_value(self, obj, value):
65+
return self._set_value_sync(obj, value)
66+
67+
def setter(self, fset):
68+
return type(self)(self.fget, fset, self.fdel, self.__doc__, self.notify)
69+
70+
71+
def t_property(notify=None):
72+
logger.debug(f"t_property: {notify}")
73+
74+
def decorator(func):
75+
return TProperty(fget=func, notify=notify)
76+
77+
return decorator

src/tsignal/core.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
import threading
1212
from enum import Enum
1313
from typing import Callable, List, Tuple, Optional, Union
14+
import concurrent.futures
1415

1516

1617
class TConnectionType(Enum):
1718
"""Connection type for signal-slot connections."""
19+
1820
DIRECT_CONNECTION = 1
1921
QUEUED_CONNECTION = 2
2022

2123

22-
class _SignalConstants:
24+
class TSignalConstants:
2325
"""Constants for signal-slot communication."""
26+
2427
FROM_EMIT = "_from_emit"
2528
THREAD = "_thread"
2629
LOOP = "_loop"
@@ -38,7 +41,7 @@ def _wrap_direct_function(func):
3841
def wrapper(*args, **kwargs):
3942
"""Wrapper for directly connected functions"""
4043
# Remove FROM_EMIT
41-
kwargs.pop(_SignalConstants.FROM_EMIT, False)
44+
kwargs.pop(TSignalConstants.FROM_EMIT, False)
4245

4346
# DIRECT_CONNECTION executes immediately regardless of thread
4447
if is_coroutine:
@@ -55,6 +58,7 @@ def wrapper(*args, **kwargs):
5558

5659
class TSignal:
5760
"""Signal class for tsignal."""
61+
5862
def __init__(self):
5963
self.connections: List[Tuple[Optional[object], Callable, TConnectionType]] = []
6064

@@ -74,8 +78,8 @@ def connect(
7478

7579
if hasattr(receiver_or_slot, "__self__"):
7680
obj = receiver_or_slot.__self__
77-
if hasattr(obj, _SignalConstants.THREAD) and hasattr(
78-
obj, _SignalConstants.LOOP
81+
if hasattr(obj, TSignalConstants.THREAD) and hasattr(
82+
obj, TSignalConstants.LOOP
7983
):
8084
receiver = obj
8185
slot = receiver_or_slot
@@ -161,17 +165,24 @@ def call_wrapper(s=slot):
161165
logger.error("Error in signal emission: %s", e, exc_info=True)
162166

163167

168+
class TSignalProperty(property):
169+
def __init__(self, fget, signal_name):
170+
super().__init__(fget)
171+
self.signal_name = signal_name
172+
173+
164174
def t_signal(func):
165175
"""Signal decorator"""
166176
sig_name = func.__name__
167177

168-
@property
178+
# Using @property for lazy initialization of the signal.
179+
# The signal object is created only when first accessed, and a cached object is returned thereafter.
169180
def wrapper(self):
170181
if not hasattr(self, f"_{sig_name}"):
171182
setattr(self, f"_{sig_name}", TSignal())
172183
return getattr(self, f"_{sig_name}")
173184

174-
return wrapper
185+
return TSignalProperty(wrapper, sig_name)
175186

176187

177188
def t_slot(func):
@@ -183,12 +194,12 @@ def t_slot(func):
183194
@functools.wraps(func)
184195
async def wrapper(self, *args, **kwargs):
185196
"""Wrapper for coroutine slots"""
186-
from_emit = kwargs.pop(_SignalConstants.FROM_EMIT, False)
197+
from_emit = kwargs.pop(TSignalConstants.FROM_EMIT, False)
187198

188-
if not hasattr(self, _SignalConstants.THREAD):
199+
if not hasattr(self, TSignalConstants.THREAD):
189200
self._thread = threading.current_thread()
190201

191-
if not hasattr(self, _SignalConstants.LOOP):
202+
if not hasattr(self, TSignalConstants.LOOP):
192203
try:
193204
self._loop = asyncio.get_running_loop()
194205
except RuntimeError:
@@ -211,12 +222,12 @@ async def wrapper(self, *args, **kwargs):
211222
@functools.wraps(func)
212223
def wrapper(self, *args, **kwargs):
213224
"""Wrapper for regular slots"""
214-
from_emit = kwargs.pop(_SignalConstants.FROM_EMIT, False)
225+
from_emit = kwargs.pop(TSignalConstants.FROM_EMIT, False)
215226

216-
if not hasattr(self, _SignalConstants.THREAD):
227+
if not hasattr(self, TSignalConstants.THREAD):
217228
self._thread = threading.current_thread()
218229

219-
if not hasattr(self, _SignalConstants.LOOP):
230+
if not hasattr(self, TSignalConstants.LOOP):
220231
try:
221232
self._loop = asyncio.get_running_loop()
222233
except RuntimeError:
@@ -227,8 +238,18 @@ def wrapper(self, *args, **kwargs):
227238
current_thread = threading.current_thread()
228239
if current_thread != self._thread:
229240
logger.debug("Executing regular slot from different thread")
230-
self._loop.call_soon_threadsafe(lambda: func(self, *args, **kwargs))
231-
return None
241+
# 동기 함수는 loop.call_soon_threadsafe와 Future를 사용
242+
future = concurrent.futures.Future()
243+
244+
def callback():
245+
try:
246+
result = func(self, *args, **kwargs)
247+
future.set_result(result)
248+
except Exception as e:
249+
future.set_exception(e)
250+
251+
self._loop.call_soon_threadsafe(callback)
252+
return future.result()
232253

233254
return func(self, *args, **kwargs)
234255

@@ -240,6 +261,7 @@ def t_with_signals(cls):
240261
original_init = cls.__init__
241262

242263
def __init__(self, *args, **kwargs):
264+
logger.debug("t_with_signals __init__")
243265
# Set thread and event loop
244266
self._thread = threading.current_thread()
245267
try:

tests/conftest.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
logger = logging.getLogger(__name__)
2121

2222

23+
def log_debug(message):
24+
"""Log a debug message if DEBUG level is enabled."""
25+
if logger.isEnabledFor(logging.DEBUG):
26+
logger.debug(message)
27+
28+
2329
@pytest.fixture(scope="function")
2430
def event_loop():
2531
"""Create an event loop"""
@@ -28,9 +34,11 @@ def event_loop():
2834
yield loop
2935
loop.close()
3036

37+
3138
@t_with_signals
3239
class Sender:
3340
"""Sender class"""
41+
3442
@t_signal
3543
def value_changed(self, value):
3644
"""Signal for value changes"""
@@ -39,9 +47,11 @@ def emit_value(self, value):
3947
"""Emit a value change signal"""
4048
self.value_changed.emit(value)
4149

50+
4251
@t_with_signals
4352
class Receiver:
4453
"""Receiver class"""
54+
4555
def __init__(self):
4656
super().__init__()
4757
self.received_value = None
@@ -52,25 +62,33 @@ def __init__(self):
5262
@t_slot
5363
async def on_value_changed(self, value: int):
5464
"""Slot for value changes"""
55-
logger.info("Receiver[%d] on_value_changed called with value: %d", self.id, value)
65+
logger.info(
66+
"Receiver[%d] on_value_changed called with value: %d", self.id, value
67+
)
5668
logger.info("Current thread: %s", threading.current_thread().name)
5769
logger.info("Current event loop: %s", asyncio.get_running_loop())
5870
self.received_value = value
5971
self.received_count += 1
6072
logger.info(
6173
"Receiver[%d] updated: value=%d, count=%d",
62-
self.id, self.received_value, self.received_count
74+
self.id,
75+
self.received_value,
76+
self.received_count,
6377
)
6478

6579
@t_slot
6680
def on_value_changed_sync(self, value: int):
6781
"""Sync slot for value changes"""
68-
logger.info("Receiver[%d] on_value_changed_sync called with value: %d", self.id, value)
82+
logger.info(
83+
"Receiver[%d] on_value_changed_sync called with value: %d", self.id, value
84+
)
6985
self.received_value = value
7086
self.received_count += 1
7187
logger.info(
7288
"Receiver[%d] updated (sync): value=%d, count=%d",
73-
self.id, self.received_value, self.received_count
89+
self.id,
90+
self.received_value,
91+
self.received_count,
7492
)
7593

7694

@@ -101,6 +119,7 @@ def setup_logging():
101119
default_level = logging.DEBUG
102120

103121
root.setLevel(default_level)
122+
logger.debug("Logging level set to: %s", default_level)
104123

105124
# Removing existing handlers
106125
for handler in root.handlers:

0 commit comments

Comments
 (0)