├── .gitattribuites ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENCSE.md ├── README.md ├── docs ├── api.md ├── examples.md ├── images │ ├── stock_monitor_console.png │ └── stock_monitor_ui.png ├── logging.md ├── testing.md ├── usage.md └── windows-asyncio-iocp-termination-issue.md ├── examples ├── signal_async.py ├── signal_basic.py ├── signal_function_slots.py ├── signal_lamba_slots.py ├── stock_core.py ├── stock_monitor_console.py ├── stock_monitor_simple.py ├── stock_monitor_ui.py ├── thread_basic.py ├── thread_worker.py └── utils.py ├── pyproject.toml ├── src └── tsignal │ ├── __init__.py │ ├── contrib │ ├── extensions │ │ ├── __init__.py │ │ └── property.py │ └── patterns │ │ ├── __init__.py │ │ └── worker │ │ ├── __init__.py │ │ └── decorators.py │ ├── core.py │ └── utils.py └── tests ├── __init__.py ├── conftest.py ├── integration ├── __init__.py ├── test_async.py ├── test_thread_safety.py ├── test_threading.py ├── test_with_signal.py ├── test_worker.py ├── test_worker_queue.py └── test_worker_signal.py ├── performance ├── test_memory.py └── test_stress.py └── unit ├── __init__.py ├── test_property.py ├── test_signal.py ├── test_slot.py ├── test_utils.py └── test_weak.py /.gitattribuites: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Python files 5 | *.py text diff=python eol=lf 6 | *.pyi text diff=python eol=lf 7 | 8 | # Documentation 9 | *.md text eol=lf 10 | *.rst text eol=lf 11 | docs/* text eol=lf 12 | 13 | # Scripts 14 | *.sh text eol=lf 15 | *.bat text eol=crlf 16 | *.cmd text eol=crlf 17 | *.ps1 text eol=crlf 18 | 19 | # Configuration 20 | *.yml text eol=lf 21 | *.yaml text eol=lf 22 | *.json text eol=lf 23 | *.toml text eol=lf 24 | .gitattributes text eol=lf 25 | .gitignore text eol=lf 26 | requirements.txt text eol=lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/ci.yml 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.10', '3.11', '3.12'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -e ".[dev]" 29 | pip install pylint mypy 30 | 31 | - name: Run tests 32 | run: | 33 | pytest --cov=tsignal 34 | 35 | - name: Code quality checks 36 | run: | 37 | pylint src/tsignal 38 | mypy src/tsignal 39 | 40 | - name: Performance tests 41 | run: | 42 | pytest -v -m performance -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | 7 | # Distribution / packaging 8 | dist/ 9 | build/ 10 | *.egg-info/ 11 | *.egg 12 | 13 | # Virtual environments 14 | venv/ 15 | env/ 16 | .env/ 17 | .venv/ 18 | ENV/ 19 | 20 | # Testing 21 | .pytest_cache/ 22 | .coverage 23 | htmlcov/ 24 | .tox/ 25 | 26 | # IDE settings 27 | .idea/ 28 | .vscode/ 29 | *.swp 30 | *.swo 31 | *~ 32 | 33 | # Logs 34 | *.log 35 | 36 | # Environment variables 37 | .env 38 | .env.local 39 | 40 | # macOS 41 | .DS_Store 42 | 43 | # Jupyter Notebook 44 | .ipynb_checkpoints 45 | 46 | # mypy 47 | .mypy_cache/ 48 | 49 | # Documentation builds 50 | docs/_build/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.5.0] - 2024-12-28 8 | 9 | ### Important Notice 10 | - **Package Deprecation**: TSignal is being rebranded to Pynnex. This version marks the beginning of the deprecation period. 11 | - All future development will continue at [github.com/nexconnectio/pynnex](https://github.com/nexconnectio/pynnex) 12 | - Users are encouraged to migrate to the new package: `pip install pynnex` 13 | 14 | ### Changed 15 | - Updated all repository URLs to point to the new Pynnex repository 16 | - Added deprecation warnings when importing the package 17 | - Updated package metadata to reflect deprecated status 18 | 19 | ## [0.4.0] - 2024-12-21 20 | 21 | ### Added 22 | - **Weak Reference Support**: Introduced `weak=True` for signal connections to allow automatic disconnection when the receiver is garbage-collected. 23 | - **One-Shot Connections**: Added `one_shot=True` in `connect(...)` to enable automatically disconnecting a slot after its first successful emission call. 24 | - Extended integration tests to cover new `weak` and `one_shot` functionality. 25 | 26 | ### Improved 27 | - **Thread Safety**: Strengthened internal locking and concurrency patterns to reduce race conditions in high-load or multi-threaded environments. 28 | - **Documentation**: Updated `readme.md`, `api.md`, and example code sections to explain weak references, one-shot usage, and improved thread-safety details. 29 | 30 | ## [0.3.0] - 2024-12-19 31 | 32 | ### Changed 33 | - Removed `initialize` and `finalize` methods from the worker thread. 34 | 35 | ### Added 36 | - Added the following examples: 37 | - `signal_function_slots.py` for demonstrating signal/slot connection with a regular function 38 | - `signal_lambda_slots.py` for demonstrating signal/slot connection with a lambda function 39 | - `stock_core.py` for demonstrating how to configure and communicate with a threaded backend using signal/slot and event queue 40 | - `stock_monitor_console.py` for demonstrating how to configure and communicate with a threaded backend in a command line interface 41 | - `stock_monitor_ui.py` for demonstrating how to configure and communicate with a threaded backend in a GUI. 42 | 43 | ### Fixed 44 | - Fixed issues with regular function and lambda function connections. 45 | - Fixed issues with the worker thread's event queue and graceful shutdown. 46 | 47 | ### Note 48 | Next steps before 1.0.0: 49 | - Strengthening Stability 50 | - Resource Cleanup Mechanism/Weak Reference Support: 51 | - Consider supporting weak references (weakref) that automatically release signal/slot connections when the object is GC'd. 52 | - Handling Slot Return Values in Async/Await Flow: 53 | - A mechanism may be needed to handle the values or exceptions returned by async slots. 54 | Example: If a slot call fails, the emit side can detect and return a callback or future. 55 | - Strengthening Type Hint-Based Verification: 56 | - The functionality of comparing the slot signature and the type of the passed argument when emitting can be further extended. 57 | - Read the type hint for the slot function, and if the number or type of the arguments does not match when emitting, raise a warning or exception. 58 | - Consider Additional Features 59 | - One-shot or Limited Connection Functionality: 60 | A "one-shot" slot feature that listens to a specific event only once and then disconnects can be provided. 61 | Example: Add the connect_one_shot method. 62 | Once the event is received, it will automatically disconnect. 63 | 64 | ## [0.2.0] - 2024-12-6 65 | 66 | ### Changed 67 | - Updated minimum Python version requirement to 3.10 68 | - This change was necessary to ensure reliable worker thread functionality 69 | - Python 3.10+ provides improved async features and type handling 70 | - Better support for async context management and error handling 71 | - Updated documentation to reflect new Python version requirement 72 | - Enhanced worker thread implementation with Python 3.10+ features 73 | 74 | ### Added 75 | - Performance tests for stress testing and memory usage analysis 76 | - Includes `test_stress.py` for heavy signal load testing 77 | - Includes `test_memory.py` for memory profiling 78 | 79 | ### Removed 80 | - Support for Python versions below 3.10 81 | 82 | ### Note 83 | Core features are now implemented and stable: 84 | - Robust signal-slot mechanism 85 | - Thread-safe operations 86 | - Async/await support 87 | - Worker thread pattern 88 | - Comprehensive documentation 89 | - Full test coverage 90 | 91 | Next steps before 1.0.0: 92 | - Additional stress testing 93 | - Memory leak verification 94 | - Production environment validation 95 | - Enhanced CI/CD pipeline 96 | - Extended documentation 97 | 98 | ## [0.1.1] - 2024-12-01 99 | 100 | ### Changed 101 | - Refactored signal connection logic to support direct function connections 102 | - Improved error handling for invalid connections 103 | - Enhanced logging for signal emissions and connections 104 | 105 | ### Fixed 106 | - Resolved issues with disconnecting slots during signal emissions 107 | - Fixed bugs related to async slot processing and connection management 108 | 109 | ### Removed 110 | - Deprecated unused constants and methods from the core module 111 | 112 | ## [0.1.0] - 2024-01-26 113 | 114 | ### Added 115 | - Initial release 116 | - Basic signal-slot mechanism with decorators 117 | - Support for both synchronous and asynchronous slots 118 | - Thread-safe signal emissions 119 | - Automatic connection type detection 120 | - Comprehensive test suite 121 | - Full documentation 122 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to TSignal 2 | 3 | Thank you for your interest in contributing to TSignal! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Development Setup 6 | 7 | 1. Fork and clone the repository: 8 | ```bash 9 | git clone https://github.com/TSignalDev/tsignal-python.git 10 | cd tsignal-python 11 | ``` 12 | 13 | 2. Create a virtual environment and install development dependencies: 14 | ```bash 15 | python -m venv venv 16 | source venv/bin/activate # On Windows: venv\Scripts\activate 17 | pip install -e ".[dev]" 18 | ``` 19 | 20 | ## Code Style 21 | 22 | We follow these coding conventions: 23 | - PEP 8 style guide 24 | - Maximum line length of 88 characters (Black default) 25 | - Type hints for function arguments and return values 26 | - Docstrings for all public modules, functions, classes, and methods 27 | 28 | ## Testing 29 | 30 | Run the test suite before submitting changes: 31 | ```bash 32 | # Run all tests 33 | pytest 34 | 35 | # Run with coverage 36 | pytest --cov=tsignal 37 | 38 | # Run specific test file 39 | pytest tests/unit/test_signal.py 40 | 41 | # Enable debug logging during tests 42 | TSIGNAL_DEBUG=1 pytest 43 | ``` 44 | 45 | ## Pull Request Process 46 | 47 | 1. Create a new branch for your feature or bugfix: 48 | ```bash 49 | git checkout -b feature-name 50 | ``` 51 | 52 | 2. Make your changes and commit them: 53 | ```bash 54 | git add . 55 | git commit -m "Description of changes" 56 | ``` 57 | 58 | 3. Ensure your changes include: 59 | - Tests for any new functionality 60 | - Documentation updates if needed 61 | - No unnecessary debug prints or commented code 62 | - Type hints for new functions/methods 63 | 64 | 4. Push your changes and create a pull request: 65 | ```bash 66 | git push origin feature-name 67 | ``` 68 | 69 | 5. In your pull request description: 70 | - Describe what the changes do 71 | - Reference any related issues 72 | - Note any breaking changes 73 | - Include examples if applicable 74 | 75 | ## Development Guidelines 76 | 77 | ### Adding New Features 78 | 79 | 1. Start with tests 80 | 2. Implement the feature 81 | 3. Update documentation 82 | 4. Add examples if applicable 83 | 84 | ### Debug Logging 85 | 86 | Use appropriate log levels: 87 | ```python 88 | import logging 89 | 90 | logger = logging.getLogger(__name__) 91 | 92 | # Debug information 93 | logger.debug("Detailed connection info") 94 | 95 | # Important state changes 96 | logger.info("Signal connected successfully") 97 | 98 | # Warning conditions 99 | logger.warning("Multiple connections detected") 100 | 101 | # Errors 102 | logger.error("Failed to emit signal", exc_info=True) 103 | ``` 104 | 105 | ## Code of Conduct 106 | 107 | ### Our Standards 108 | 109 | - Be respectful and inclusive 110 | - Focus on constructive criticism 111 | - Accept feedback gracefully 112 | - Put the project's best interests first 113 | 114 | ### Enforcement 115 | 116 | Violations of the code of conduct may result in: 117 | 1. Warning 118 | 2. Temporary ban 119 | 3. Permanent ban 120 | 121 | Report issues to project maintainers via email. 122 | 123 | ## License 124 | 125 | By contributing, you agree that your contributions will be licensed under the same license as the project (MIT License). 126 | -------------------------------------------------------------------------------- /LICENCSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 San Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSignal (Deprecated - Moving to Pynnex) 2 | 3 | > ⚠️ **Important Notice**: TSignal has been rebranded to Pynnex. This package is deprecated as of version 0.5.x, and all future development will take place at [Pynnex](https://github.com/nexconnectio/pynnex). Please use Pynnex for new projects. 4 | 5 | ```bash 6 | # New package installation: 7 | pip install pynnex 8 | ``` 9 | 10 | TSignal is a lightweight, pure-Python signal/slot library that provides thread-safe, asyncio-compatible event handling inspired by the Qt signal/slot pattern—but without the heavyweight Qt dependencies. It enables clean decoupling of components, seamless thread-to-thread communication, and flexible asynchronous/synchronous slot handling. 11 | 12 | ## Key Features 13 | 14 | - **Pure Python**: No Qt or external GUI frameworks needed. 15 | - **Async/Await Friendly**: Slots can be synchronous or asynchronous, and integrate seamlessly with asyncio. 16 | - **Thread-Safe**: Signal emissions and slot executions are automatically managed for thread safety. 17 | - **Flexible Connection Types**: Direct or queued connections, automatically chosen based on the caller and callee threads. 18 | - **Worker Thread Pattern**: Simplify background task execution with a built-in worker pattern that provides an event loop and task queue in a dedicated thread. 19 | - **Familiar Decorators**: Inspired by Qt’s pattern, `@t_with_signals`, `@t_signal`, and `@t_slot` let you define signals and slots declaratively. 20 | - **Weak Reference**: 21 | - By setting `weak=True` when connecting a slot, the library holds a weak reference to the receiver object. This allows the receiver to be garbage-collected if there are no other strong references to it. Once garbage-collected, the connection is automatically removed, preventing stale references. 22 | 23 | ### **Requires an Existing Event Loop** 24 | 25 | Since TSignal relies on Python’s `asyncio` infrastructure for scheduling async slots and cross-thread calls, you **must** have a running event loop before using TSignal’s decorators like `@t_with_signals` or `@t_slot`. Typically, this means: 26 | 27 | 1. **Inside `asyncio.run(...)`:** 28 | For example: 29 | ```python 30 | async def main(): 31 | # create objects, do your logic 32 | ... 33 | asyncio.run(main()) 34 | ``` 35 | 36 | 2. **@t_with_worker Decorator:** 37 | If you decorate a class with `@t_with_worker`, it automatically creates a worker thread with its own event loop. That pattern is isolated to the worker context, so any other async usage in the main thread also needs its own loop. 38 | 39 | If no event loop is running when a slot is called, TSignal will raise a RuntimeError instead of creating a new loop behind the scenes. This ensures consistent concurrency behavior and avoids hidden loops that might never process tasks. 40 | 41 | ## Why TSignal? 42 | 43 | Modern Python applications often rely on asynchronous operations and multi-threading. Traditional event frameworks either require large external dependencies or lack seamless async/thread support. TSignal provides: 44 | 45 | - A minimal, dependency-free solution for event-driven architectures. 46 | - Smooth integration with asyncio for modern async Python code. 47 | - Automatic thread-affinity handling so cross-thread signals "just work." 48 | - Decorator-based API that’s intuitive and maintainable. 49 | 50 | ## Installation 51 | 52 | TSignal requires Python 3.10 or higher. 53 | 54 | > ⚠️ **Note**: For new installations, please use `pip install pynnex` instead. 55 | 56 | ```bash 57 | git clone https://github.com/nexconnectio/pynnex.git 58 | cd pynnex 59 | pip install -e . 60 | ``` 61 | 62 | For development (includes tests and linting tools): 63 | ``` 64 | pip install -e ".[dev] 65 | ``` 66 | 67 | ## Quick Start 68 | 69 | ### Basic Example 70 | ```python 71 | from tsignal import t_with_signals, t_signal, t_slot 72 | 73 | @t_with_signals 74 | class Counter: 75 | def __init__(self): 76 | self.count = 0 77 | 78 | @t_signal 79 | def count_changed(self): 80 | pass 81 | 82 | def increment(self): 83 | self.count += 1 84 | self.count_changed.emit(self.count) 85 | 86 | @t_with_signals 87 | class Display: 88 | @t_slot 89 | async def on_count_changed(self, value): 90 | print(f"Count is now: {value}") 91 | 92 | # Connect and use 93 | counter = Counter() 94 | display = Display() 95 | counter.count_changed.connect(display, display.on_count_changed) 96 | counter.increment() # Will print: "Count is now: 1" 97 | ``` 98 | 99 | ### Asynchronous Slot Example 100 | ```python 101 | @t_with_signals 102 | class AsyncDisplay: 103 | @t_slot 104 | async def on_count_changed(self, value): 105 | await asyncio.sleep(1) # Simulate async operation 106 | print(f"Count updated to: {value}") 107 | 108 | # Usage in async context 109 | async def main(): 110 | counter = Counter() 111 | display = AsyncDisplay() 112 | 113 | counter.count_changed.connect(display, display.on_count_changed) 114 | counter.increment() 115 | 116 | # Wait for async processing 117 | await asyncio.sleep(1.1) 118 | 119 | asyncio.run(main()) 120 | ``` 121 | 122 | ## Core Concepts 123 | 124 | ### Signals and Slots 125 | - Signals: Declared with `@t_signal`. Signals are attributes of a class that can be emitted to notify interested parties. 126 | - Slots: Declared with `@t_slot`. Slots are methods that respond to signals. Slots can be synchronous or async functions. 127 | - Connections: Use `signal.connect(receiver, slot)` to link signals to slots. Connections can also be made directly to functions or lambdas. 128 | 129 | ### Thread Safety and Connection Types 130 | TSignal automatically detects whether the signal emission and slot execution occur in the same thread or different threads: 131 | 132 | - **Auto Connection**: When connection_type is AUTO_CONNECTION (default), TSignal checks whether the slot is a coroutine function or whether the caller and callee share the same thread affinity. If they are the same thread and slot is synchronous, it uses direct connection. Otherwise, it uses queued connection. 133 | - **Direct Connection**: If signal and slot share the same thread affinity, the slot is invoked directly. 134 | - **Queued Connection**: If they differ, the call is queued to the slot’s thread/event loop, ensuring thread safety. 135 | 136 | This mechanism frees you from manually dispatching calls across threads. 137 | 138 | ### Worker Threads 139 | For background work, TSignal provides a `@t_with_worker` decorator that: 140 | 141 | - Spawns a dedicated event loop in a worker thread. 142 | - Allows you to queue async tasks to this worker. 143 | - Enables easy start/stop lifecycle management. 144 | - Integrates with signals and slots for thread-safe updates to the main 145 | 146 | **Worker Example** 147 | ```python 148 | from tsignal import t_with_worker, t_signal 149 | 150 | @t_with_worker 151 | class DataProcessor: 152 | @t_signal 153 | def processing_done(self): 154 | """Emitted when processing completes""" 155 | 156 | async def run(self, *args, **kwargs): 157 | # The main entry point for the worker thread’s event loop 158 | # Wait for tasks or stopping signal 159 | await self.wait_for_stop() 160 | 161 | async def process_data(self, data): 162 | # Perform heavy computation in the worker thread 163 | result = await heavy_computation(data) 164 | self.processing_done.emit(result) 165 | 166 | processor = DataProcessor() 167 | processor.start() 168 | 169 | # Queue a task to run in the worker thread: 170 | processor.queue_task(processor.process_data(some_data)) 171 | 172 | # Stop the worker gracefully 173 | processor.stop() 174 | ``` 175 | 176 | ## Documentation and Example 177 | - [Usage Guide](https://github.com/TSignalDev/tsignal-python/blob/main/docs/usage.md): Learn how to define signals/slots, manage threads, and structure your event-driven code. 178 | - [API Reference](https://github.com/TSignalDev/tsignal-python/blob/main/docs/api.md): Detailed documentation of classes, decorators, and functions. 179 | - [Examples](https://github.com/TSignalDev/tsignal-python/blob/main/docs/examples.md): Practical use cases, including UI integration, async operations, and worker pattern usage. 180 | - [Logging Guidelines](https://github.com/TSignalDev/tsignal-python/blob/main/docs/logging.md): Configure logging levels and handlers for debugging. 181 | - [Testing Guide](https://github.com/TSignalDev/tsignal-python/blob/main/docs/testing.md): earn how to run tests and contribute safely. 182 | 183 | ## Logging 184 | Configure logging to diagnose issues: 185 | 186 | ```python 187 | import logging 188 | logging.getLogger('tsignal').setLevel(logging.DEBUG) 189 | ``` 190 | 191 | For more details, see the [Logging Guidelines](https://github.com/TSignalDev/tsignal-python/blob/main/docs/logging.md). 192 | 193 | ## Testing 194 | 195 | TSignal uses `pytest` for testing: 196 | 197 | ```bash 198 | # Run all tests 199 | pytest 200 | 201 | # Run with verbose output 202 | pytest -v 203 | 204 | # Run specific test file 205 | pytest tests/unit/test_signal.py 206 | ``` 207 | 208 | See the [Testing Guide](https://github.com/TSignalDev/tsignal-python/blob/main/docs/testing.md) for more details. 209 | 210 | ## Contributing 211 | We welcome contributions. Please read the [Contributing Guidelines](https://github.com/TSignalDev/tsignal-python/blob/main/CONTRIBUTING.md) before submitting PRs. 212 | 213 | ## License 214 | TSignal is licensed under the MIT License. See [LICENSE](https://github.com/TSignalDev/tsignal-python/blob/main/LICENSE) for details. 215 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Requirements 4 | TSignal requires Python 3.10 or higher, and a running `asyncio` event loop for any async usage. 5 | 6 | ## Decorators 7 | ### `@t_with_signals` 8 | Enables signal-slot functionality on a class. Classes decorated with `@t_with_signals` can define signals and have their slots automatically assigned event loops and thread affinity. 9 | 10 | **Important**: `@t_with_signals` expects that you already have an `asyncio` event loop running (e.g., via `asyncio.run(...)`) unless you only rely on synchronous slots in a single-thread scenario. When in doubt, wrap your main logic in an async function and call `asyncio.run(main())`. 11 | 12 | **Usage:** 13 | ```python 14 | @t_with_signals 15 | class MyClass: 16 | @t_signal 17 | def my_signal(self): 18 | pass 19 | ``` 20 | 21 | ### `@t_signal` 22 | Defines a signal within a class that has `@t_with_signals`. Signals are callable attributes that, when emitted, notify all connected slots. 23 | 24 | **Usage:** 25 | 26 | ```python 27 | @t_signal 28 | def my_signal(self): 29 | pass 30 | 31 | # Emission 32 | self.my_signal.emit(value) 33 | ``` 34 | 35 | ### `@t_slot` 36 | Marks a method as a slot. Slots can be synchronous or asynchronous methods. TSignal automatically handles cross-thread invocation—**but only if there is a running event loop**. 37 | 38 | **Usage:** 39 | 40 | ```python 41 | @t_slot 42 | def on_my_signal(self, value): 43 | print("Received:", value) 44 | 45 | @t_slot 46 | async def on_async_signal(self, value): 47 | await asyncio.sleep(1) 48 | print("Async Received:", value) 49 | ``` 50 | 51 | **Event Loop Requirement**: 52 | If the decorated slot is async, or if the slot might be called from another thread, TSignal uses asyncio scheduling. That means a running event loop is mandatory. If no loop is found, a RuntimeError is raised. 53 | 54 | ### `@t_with_worker` 55 | Decorates a class to run inside a dedicated worker thread with its own event loop. Ideal for offloading tasks without blocking the main thread. When using @t_with_worker, the worker thread automatically sets up its own event loop, so calls within that worker are safe. For the main thread, you still need an existing loop if you plan on using async slots or cross-thread signals. The worker provides: 56 | 57 | A dedicated event loop in another thread. 58 | The `run(*args, **kwargs)` coroutine as the main entry point. 59 | A built-in async task queue via `queue_task`. 60 | 61 | **Key Points:** 62 | 63 | `run(*args, **kwargs)` is an async method that you can define to perform long-running operations or await a stopping event. 64 | To pass arguments to start(), ensure run() accepts *args, **kwargs. 65 | Example: 66 | 67 | ```python 68 | @t_with_worker 69 | class Worker: 70 | @t_signal 71 | def finished(self): 72 | pass 73 | 74 | async def run(self, config=None): 75 | # run is the main entry point in the worker thread 76 | print("Worker started with config:", config) 77 | # Wait until stop is requested 78 | await self.wait_for_stop() 79 | self.finished.emit() 80 | 81 | async def do_work(self, data): 82 | await asyncio.sleep(1) 83 | return data * 2 84 | 85 | worker = Worker() 86 | worker.start(config={'threads': 4}) 87 | worker.queue_task(worker.do_work(42)) 88 | worker.stop() 89 | ``` 90 | 91 | ### `t_property` 92 | Creates a thread-safe property that can optionally notify a signal when the property’s value changes. Useful for ensuring that property access and mutation occur on the object's designated event loop, maintaining thread safety. 93 | 94 | **Key Points:** 95 | 96 | - `t_property` can be used similarly to `property`, but wraps get/set operations in event loop calls if accessed from another thread. 97 | - If the `notify` parameter is set to a signal, that signal is emitted whenever the property value changes. 98 | - Get and set operations from the "wrong" thread are automatically queued to the object's event loop, ensuring thread-safe access. 99 | 100 | **Usage:** 101 | ```python 102 | from tsignal.contrib.extensions.property import t_property 103 | 104 | @t_with_signals 105 | class Model: 106 | @t_signal 107 | def value_changed(self): 108 | pass 109 | 110 | @t_property(notify=value_changed) 111 | def value(self): 112 | return self._value 113 | 114 | @value.setter 115 | def value(self, new_val): 116 | self._value = new_val 117 | 118 | model = Model() 119 | model.value = 10 # If called from a different thread, queued to model's loop 120 | print(model.value) # Also thread-safe 121 | ``` 122 | 123 | ## Classes 124 | ### `TSignal` 125 | Represents a signal. Signals are created by `@t_signal` and accessed as class attributes. 126 | 127 | **Key Methods**: 128 | 129 | `connect(receiver_or_slot, slot=None, connection_type=TConnectionType.AUTO_CONNECTION) -> None` 130 | 131 | Connects the signal to a slot. 132 | 133 | - **Parameters:** 134 | - **receiver_or_slot:** Either the receiver object and slot method, or just a callable (function/lambda) if slot is None. 135 | - **slot:** The method in the receiver if a receiver object is provided. 136 | - **connection_type:** DIRECT_CONNECTION, QUEUED_CONNECTION, or AUTO_CONNECTION. 137 | - **AUTO_CONNECTION (default):** Determines connection type automatically based on thread affinity and slot type. 138 | - **weak:** If `True`, the receiver is kept via a weak reference so it can be garbage collected once there are no strong references. The signal automatically removes the connection if the receiver is collected. 139 | - **one_shot:** If `True`, the connection is automatically disconnected after the first successful emit call. This is useful for events that should only notify a slot once. 140 | 141 | **Examples:** 142 | 143 | ```python 144 | # AUTO_CONNECTION (default) decides connection type automatically 145 | signal.connect(receiver, receiver.on_signal) 146 | 147 | # Force direct connection 148 | signal.connect(receiver, receiver.on_signal, connection_type=TConnectionType.DIRECT_CONNECTION) 149 | 150 | # Force queued connection 151 | signal.connect(receiver, receiver.on_signal, connection_type=TConnectionType.QUEUED_CONNECTION) 152 | 153 | # Connect to a standalone function 154 | signal.connect(print) 155 | ``` 156 | 157 | `disconnect(receiver=None, slot=None) -> int` 158 | 159 | Disconnects a previously connected slot. Returns the number of disconnected connections. 160 | 161 | - **Parameters:** 162 | - receiver: The object whose slot is connected. If receiver is None, all receivers are considered. 163 | - slot: The specific slot to disconnect from the signal. If slot is None, all slots for the given receiver (or all connections if receiver is also None) are disconnected. 164 | - **Returns:** The number of connections that were disconnected.- 165 | 166 | **Examples:** 167 | ```python 168 | # Disconnect all connections 169 | signal.disconnect() 170 | 171 | # Disconnect all slots from a specific receiver 172 | signal.disconnect(receiver=my_receiver) 173 | 174 | # Disconnect a specific slot from a specific receiver 175 | signal.disconnect(receiver=my_receiver, slot=my_receiver.some_slot) 176 | 177 | # Disconnect a standalone function 178 | signal.disconnect(slot=my_function) 179 | ``` 180 | 181 | `emit(*args, **kwargs) -> None` 182 | 183 | Emits the signal, invoking all connected slots either directly or via the event loop of the slot’s associated thread, depending on the connection type. If a connection is marked one_shot, it is automatically removed right after invocation. 184 | 185 | `TConnectionType` 186 | 187 | Defines how a slot is invoked relative to the signal emitter’s thread. 188 | 189 | - `DIRECT_CONNECTION`: The slot is called immediately in the emitter's thread. 190 | - `QUEUED_CONNECTION`: The slot invocation is queued in the slot's thread/event loop. 191 | - `AUTO_CONNECTION`: Automatically chooses direct or queued based on thread affinity and slot type (sync/async). 192 | 193 | ## Asynchronous Support 194 | Slots can be async. When a signal with an async slot is emitted: 195 | - The slot runs on the event loop associated with that slot. 196 | - `AUTO_CONNECTION` typically results in queued connections for async slots. 197 | - `emit()` returns immediately; slots run asynchronously without blocking the caller. 198 | 199 | ## Worker Threads 200 | - `@t_with_worker` provides a dedicated thread and event loop. 201 | - `run(*args, **kwargs)` defines the worker’s main logic. 202 | - `queue_task(coro)` schedules coroutines on the worker's event loop. 203 | - `stop()` requests a graceful shutdown, causing `run()` to end after `_tsignal_stopping` is triggered. 204 | - `wait_for_stop()` is a coroutine that waits for the worker to stop. 205 | 206 | **Signature Match for** ``run()``: 207 | 208 | - Use `async def run(self, *args, **kwargs):`. 209 | - Passing parameters to `start()` must align with `run()`’s signature. 210 | 211 | ## Error Handling 212 | - `TypeError`: If slot is not callable or signature issues occur. 213 | - `RuntimeError`: If no event loop is available for async operations. 214 | - `AttributeError`: If connecting to a nonexistent slot or missing receiver. 215 | 216 | ## Examples 217 | **Basic Signal-Slot** 218 | 219 | ```python 220 | @t_with_signals 221 | class Sender: 222 | @t_signal 223 | def value_changed(self): 224 | pass 225 | 226 | @t_with_signals 227 | class Receiver: 228 | @t_slot 229 | def on_value_changed(self, value): 230 | print("Value:", value) 231 | 232 | sender = Sender() 233 | receiver = Receiver() 234 | sender.value_changed.connect(receiver, receiver.on_value_changed) 235 | sender.value_changed.emit(100) 236 | ``` 237 | 238 | **Async Slot** 239 | 240 | ```python 241 | @t_with_signals 242 | class AsyncReceiver: 243 | @t_slot 244 | async def on_value_changed(self, value): 245 | await asyncio.sleep(1) 246 | print("Async Value:", value) 247 | 248 | sender = Sender() 249 | async_receiver = AsyncReceiver() 250 | sender.value_changed.connect(async_receiver, async_receiver.on_value_changed) 251 | sender.value_changed.emit(42) 252 | # "Async Value: 42" printed after ~1 253 | ``` 254 | 255 | **Worker Pattern** 256 | 257 | ```python 258 | @t_with_worker 259 | class BackgroundWorker: 260 | @t_signal 261 | def task_done(self): 262 | pass 263 | 264 | async def run(self): 265 | # Just wait until stopped 266 | await self.wait_for_stop() 267 | 268 | async def heavy_task(self, data): 269 | await asyncio.sleep(2) # Simulate heavy computation 270 | self.task_done.emit(data * 2) 271 | 272 | worker = BackgroundWorker() 273 | worker.start() 274 | worker.queue_task(worker.heavy_task(10)) 275 | worker.stop() 276 | ``` 277 | 278 | **Thread-Safe Property with Notification** 279 | 280 | ```python 281 | @t_with_signals 282 | class Model: 283 | @t_signal 284 | def value_changed(self): 285 | pass 286 | 287 | @t_property(notify=value_changed) 288 | def value(self): 289 | return self._value 290 | 291 | @value.setter 292 | def value(self, new_val): 293 | self._value = new_val 294 | 295 | model = Model() 296 | model.value = 42 # If called from another thread, it's queued safely 297 | ``` 298 | 299 | -------------------------------------------------------------------------------- /docs/images/stock_monitor_console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TSignalDev/tsignal-python/cdca38080cdb64f9d58421a3a5411b157dd631d2/docs/images/stock_monitor_console.png -------------------------------------------------------------------------------- /docs/images/stock_monitor_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TSignalDev/tsignal-python/cdca38080cdb64f9d58421a3a5411b157dd631d2/docs/images/stock_monitor_ui.png -------------------------------------------------------------------------------- /docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging Guidelines 2 | 3 | ## Requirements 4 | TSignal requires Python 3.10 or higher. 5 | 6 | TSignal uses Python's standard logging module with the following levels: 7 | 8 | - `DEBUG`: Detailed information about signal-slot connections and emissions 9 | - `INFO`: Important state changes and major events 10 | - `WARNING`: Potential issues that don't affect functionality 11 | - `ERROR`: Exceptions and failures 12 | 13 | To enable debug logging in tests: 14 | ```bash 15 | TSIGNAL_DEBUG=1 pytest 16 | ``` 17 | 18 | Configure logging in your application: 19 | ```python 20 | import logging 21 | logging.getLogger('tsignal').setLevel(logging.INFO) 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing Guide 2 | 3 | ## Overview 4 | TSignal requires Python 3.10 or higher and uses pytest for testing. Our test suite includes unit tests, integration tests, performance tests, and supports async testing. 5 | 6 | ## Test Structure 7 | ``` 8 | tests/ 9 | ├── __init__.py 10 | ├── conftest.py # Shared fixtures and configurations 11 | ├── unit/ # Unit tests 12 | │ ├── __init__.py 13 | │ ├── test_property.py 14 | │ ├── test_signal.py 15 | │ ├── test_slot.py 16 | │ ├── test_utils.py 17 | │ └── test_weak.py 18 | ├── integration/ # Integration tests 19 | │ ├── __init__.py 20 | │ ├── test_async.py 21 | │ ├── test_threading.py 22 | │ ├── test_with_signals.py 23 | │ ├── test_worker_signal.py 24 | │ ├── test_worker_queue.py 25 | │ └── test_worker.py 26 | └── performance/ # Performance and stress tests 27 | ├── __init__.py 28 | ├── test_stress.py 29 | └── test_memory.py 30 | ``` 31 | 32 | ## Running Tests 33 | 34 | ### Basic Test Commands 35 | ```bash 36 | # Run all tests 37 | pytest 38 | 39 | # Run with verbose output 40 | pytest -v 41 | 42 | # Run with very verbose output 43 | pytest -vv 44 | 45 | # Run with print statements visible 46 | pytest -s 47 | 48 | # Run specific test file 49 | pytest tests/unit/test_signal.py 50 | 51 | # Run specific test case 52 | pytest tests/unit/test_signal.py -k "test_signal_disconnect_all" 53 | 54 | # Run tests by marker 55 | pytest -v -m asyncio 56 | pytest -v -m performance # Run performance tests only 57 | ``` 58 | 59 | ### Performance Tests 60 | Performance tests include stress testing and memory usage analysis. These tests are marked with the `@pytest.mark.performance` decorator. 61 | 62 | ```bash 63 | # Run only performance tests 64 | pytest -v -m performance 65 | 66 | # Run specific performance test 67 | pytest tests/performance/test_stress.py 68 | pytest tests/performance/test_memory.py 69 | ``` 70 | 71 | Note: Performance tests might take longer to run and consume more resources than regular tests. 72 | 73 | ### Debug Mode 74 | To enable debug logging during tests: 75 | ```bash 76 | # Windows 77 | set TSIGNAL_DEBUG=1 78 | pytest tests/unit/test_signal.py -v 79 | 80 | # Linux/Mac 81 | TSIGNAL_DEBUG=1 pytest tests/unit/test_signal.py -v 82 | ``` 83 | 84 | ### Test Coverage 85 | To run tests with coverage report: 86 | ```bash 87 | # Run tests with coverage 88 | pytest --cov=tsignal 89 | 90 | # Generate HTML coverage report 91 | pytest --cov=tsignal --cov-report=html 92 | ``` 93 | 94 | ## Async Testing Configuration 95 | 96 | The project uses `pytest-asyncio` with `asyncio_mode = "auto"` to handle async fixtures and tests. This configuration allows for more flexible handling of async/sync code interactions, especially in worker-related tests where we need to manage both synchronous and asynchronous operations. 97 | 98 | Key points: 99 | - Async fixtures can yield values directly 100 | - Both sync and async tests can use the same fixtures 101 | - Worker thread initialization and cleanup are handled automatically 102 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## Requirements 4 | TSignal requires Python 3.10 or higher. 5 | 6 | ## Table of Contents 7 | 1. [Basic Concepts](#basic-concepts) 8 | 2. [Signals](#signals) 9 | 3. [Slots](#slots) 10 | 4. [Properties (t_property)](#properties-t_property) 11 | 5. [Connection Types](#connection-types) 12 | 6. [Threading and Async](#threading-and-async) 13 | 7. [Best Practices](#best-practices) 14 | 8. [Worker Thread Pattern](#worker-thread-pattern) 15 | 16 | ## Basic Concepts 17 | TSignal implements a signal-slot pattern, allowing loose coupling between components. Core ideas: 18 | 19 | - **Signal**: An event source that can be emitted. 20 | - **Slot**: A function/method that responds to a signal. 21 | - **Connection**: A link binding signals to slots. 22 | 23 | ## Signals 24 | 25 | ### Defining Signals 26 | Use `@t_with_signals` on a class and `@t_signal` on a method to define a signal: 27 | ```python 28 | from tsignal import t_with_signals, t_signal 29 | 30 | @t_with_signals 31 | class Button: 32 | @t_signal 33 | def clicked(self): 34 | """Emitted when the button is clicked.""" 35 | pass 36 | 37 | def click(self): 38 | self.clicked.emit() 39 | ``` 40 | ## Emitting Signals 41 | Emit signals using `.emit()`: 42 | ```python 43 | self.clicked.emit() # No args 44 | self.data_ready.emit(value, timestamp) # With args 45 | ``` 46 | 47 | ## Slots 48 | ### Defining Slots 49 | Use `@t_slot` to mark a method as a slot: 50 | ```python 51 | @t_slot 52 | def on_clicked(self): 53 | print("Button clicked") 54 | ``` 55 | 56 | ## Async Slots 57 | Slots can be async, integrating seamlessly with asyncio: 58 | ```python 59 | @t_with_signals 60 | class DataProcessor: 61 | @t_slot 62 | async def on_data_ready(self, data): 63 | # Perform async operations 64 | await asyncio.sleep(1) 65 | print("Data processed:", data) 66 | ``` 67 | ## Properties (t_property) 68 | t_property provides thread-safe, event-loop-aware properties that can emit signals on change: 69 | 70 | ```python 71 | from tsignal.contrib.extensions.property import t_property 72 | 73 | @t_with_signals 74 | class Model: 75 | @t_signal 76 | def value_changed(self): 77 | pass 78 | 79 | @t_property(notify=value_changed) 80 | def value(self): 81 | return self._value 82 | 83 | @value.setter 84 | def value(self, new_val): 85 | self._value = new_val 86 | ``` 87 | - Accessing or modifying value from another thread is safely queued to the owner's event loop. 88 | - If notify is set, the specified signal is emitted when the property changes. 89 | 90 | ## Connection Types 91 | ### AUTO_CONNECTION 92 | By default, when you connect() a signal to a slot, AUTO_CONNECTION is used. It automatically chooses: 93 | 94 | - Direct Connection if signal and slot share the same thread and slot is synchronous. 95 | - Queued Connection if crossing threads or slot is async, queuing the call in the slot's event loop. 96 | 97 | ### Forcing Connection Type 98 | You can force a specific connection type using the `connection_type` parameter: 99 | ```python 100 | self.clicked.connect(on_clicked, connection_type=DIRECT_CONNECTION) 101 | ``` 102 | 103 | Request for o1 pro mode 104 | Finished thinking 105 | md 106 | Copy code 107 | # Usage Guide 108 | 109 | ## Requirements 110 | TSignal requires Python 3.10 or higher. 111 | 112 | ## Table of Contents 113 | 1. [Basic Concepts](#basic-concepts) 114 | 2. [Signals](#signals) 115 | 3. [Slots](#slots) 116 | 4. [Properties (t_property)](#properties-t_property) 117 | 5. [Connection Types](#connection-types) 118 | 6. [Threading and Async](#threading-and-async) 119 | 7. [Best Practices](#best-practices) 120 | 8. [Worker Thread Pattern](#worker-thread-pattern) 121 | 122 | ## Basic Concepts 123 | TSignal implements a signal-slot pattern, allowing loose coupling between components. Core ideas: 124 | 125 | - **Signal**: An event source that can be emitted. 126 | - **Slot**: A function/method that responds to a signal. 127 | - **Connection**: A link binding signals to slots. 128 | 129 | ## Signals 130 | 131 | ### Defining Signals 132 | Use `@t_with_signals` on a class and `@t_signal` on a method to define a signal: 133 | ```python 134 | from tsignal import t_with_signals, t_signal 135 | 136 | @t_with_signals 137 | class Button: 138 | @t_signal 139 | def clicked(self): 140 | """Emitted when the button is clicked.""" 141 | pass 142 | 143 | def click(self): 144 | self.clicked.emit() 145 | ``` 146 | ## Emitting Signals 147 | Emit signals using `.emit()`: 148 | ```python 149 | self.clicked.emit() # No args 150 | self.data_ready.emit(value, timestamp) # With args 151 | ``` 152 | ## Slots 153 | **Defining Slots** 154 | 155 | Use `@t_slot` to mark a method as a slot: 156 | 157 | ```python 158 | from tsignal import t_slot 159 | 160 | @t_with_signals 161 | class Display: 162 | @t_slot 163 | def on_clicked(self): 164 | print("Button was clicked!") 165 | ``` 166 | 167 | ## Async Slots 168 | Slots can be async, integrating seamlessly with asyncio: 169 | 170 | ```python 171 | @t_with_signals 172 | class DataProcessor: 173 | @t_slot 174 | async def on_data_ready(self, data): 175 | # Perform async operations 176 | await asyncio.sleep(1) 177 | print("Data processed:", data) 178 | ``` 179 | 180 | ## Properties (t_property) 181 | `t_property` provides thread-safe, event-loop-aware properties that can emit signals on change: 182 | 183 | ```python 184 | from tsignal.contrib.extensions.property import t_property 185 | 186 | @t_with_signals 187 | class Model: 188 | @t_signal 189 | def value_changed(self): 190 | pass 191 | 192 | @t_property(notify=value_changed) 193 | def value(self): 194 | return self._value 195 | 196 | @value.setter 197 | def value(self, new_val): 198 | self._value = new_val 199 | ``` 200 | - Accessing or modifying `value` from another thread is safely queued to the owner's event loop. 201 | - If `notify` is set, the specified signal is emitted when the property changes. 202 | 203 | ## Connection Types 204 | **AUTO_CONNECTION** 205 | 206 | By default, when you `connect()` a signal to a slot, `AUTO_CONNECTION` is used. It automatically chooses: 207 | 208 | - **Direct Connection** if signal and slot share the same thread and slot is synchronous. 209 | - **Queued Connection** if crossing threads or slot is async, queuing the call in the slot's event loop. 210 | 211 | ## Forcing Connection Type 212 | ```python 213 | from tsignal.core import TConnectionType 214 | 215 | signal.connect(receiver, receiver.on_slot, connection_type=TConnectionType.DIRECT_CONNECTION) 216 | signal.connect(receiver, receiver.on_slot, connection_type=TConnectionType.QUEUED_CONNECTION) 217 | ``` 218 | 219 | ## Threading and Async 220 | **Thread Safety** 221 | 222 | TSignal ensures thread-safe signal emissions. Signals can be emitted from any thread. Slots execute in their designated event loop (often the thread they were created in). 223 | 224 | **Async Integration** 225 | 226 | TSignal works with asyncio event loops: 227 | 228 | ```python 229 | async def main(): 230 | # Create objects and connect signals/slots 231 | # Emit signals and await async slot completions indirectly 232 | await asyncio.sleep(1) 233 | 234 | asyncio.run(main()) 235 | ``` 236 | ## Best Practices 237 | 1. **Naming Conventions:** 238 | - Signals: value_changed, data_ready, operation_completed 239 | - Slots: on_value_changed, on_data_ready 240 | 2. **Disconnect Unused Signals:** 241 | Before destroying objects or changing system states, disconnect signals to prevent unwanted slot executions. 242 | 3. **Error Handling in Slots:** 243 | Handle exceptions in slots to prevent crashes: 244 | 245 | ```python 246 | @t_slot 247 | def on_data_received(self, data): 248 | try: 249 | self.process_data(data) 250 | except Exception as e: 251 | logger.error(f"Error processing: {e}") 252 | ``` 253 | 254 | 4. **Resource Cleanup:** Disconnect signals before cleanup or shutdown to ensure no pending queued calls to cleaned-up resources. 255 | 256 | ## Worker Thread Pattern 257 | The `@t_with_worker` decorator creates an object with its own thread and event loop, enabling you to queue async tasks and offload work: 258 | 259 | **Basic Worker** 260 | ```python 261 | from tsignal import t_with_worker, t_signal 262 | 263 | @t_with_worker 264 | class BackgroundWorker: 265 | @t_signal 266 | def work_done(self): 267 | pass 268 | 269 | async def run(self, *args, **kwargs): 270 | # The main entry point in the worker thread. 271 | # Wait until stopped 272 | await self.wait_for_stop() 273 | 274 | async def heavy_task(self, data): 275 | await asyncio.sleep(2) # Simulate heavy computation 276 | self.work_done.emit(data * 2) 277 | 278 | worker = BackgroundWorker() 279 | worker.start() 280 | worker.queue_task(worker.heavy_task(10)) 281 | worker.stop() 282 | ``` 283 | ** Key Points for Workers** 284 | - Define `async def run(self, *args, **kwargs)` as the main loop of the worker. 285 | - Call `start(*args, **kwargs)` to launch the worker with optional arguments passed to `run()`. 286 | - Use `queue_task(coro)` to run async tasks in the worker’s event loop. 287 | - Use `stop()` to request a graceful shutdown, causing `run()` to return after `_tsignal_stopping` is set. 288 | 289 | **Passing Arguments to run()** 290 | If `run()` accepts additional parameters, simply provide them to `start()`: 291 | 292 | ```python 293 | async def run(self, config=None): 294 | # Use config here 295 | await self.wait_for_stop() 296 | ``` 297 | 298 | ```python 299 | worker.start(config={'threads':4}) 300 | ``` 301 | 302 | ## Putting It All Together 303 | TSignal allows you to: 304 | 305 | - Define signals and slots easily. 306 | - Connect them across threads and async contexts without manual synchronization. 307 | - Use worker threads for background tasks with seamless signal/slot integration. 308 | - Manage properties thread-safely with `t_property`. 309 | 310 | This ensures a clean, maintainable, and scalable architecture for event-driven Python applications. 311 | -------------------------------------------------------------------------------- /docs/windows-asyncio-iocp-termination-issue.md: -------------------------------------------------------------------------------- 1 | # Windows IOCP and asyncio Event Loop Termination Issue 2 | 3 | ## Problem Overview 4 | A chronic issue that occurs when terminating asyncio event loops in Windows environments. This primarily occurs with `ProactorEventLoop`, which is based on Windows' IOCP (Input/Output Completion Port). 5 | 6 | ## Key Symptoms 7 | - Event loop fails to terminate completely even when all tasks are completed and no pending work exists 8 | - Event loop continues to show as running even after calling `loop.stop()` 9 | - Process remains in background without full termination 10 | 11 | ## Root Cause 12 | - Windows IOCP kernel objects not being properly cleaned up 13 | - Incomplete integration between Python asyncio and Windows IOCP 14 | - Multiple reports in Python bug tracker ([bpo-23057](https://bugs.python.org/issue23057), [bpo-45097](https://bugs.python.org/issue45097)) 15 | 16 | ## Code Example 17 | Here's a typical scenario where this issue occurs: 18 | 19 | ```python 20 | import asyncio 21 | import threading 22 | 23 | class WorkerClass: 24 | def start_worker(self): 25 | self.worker_thread = threading.Thread( 26 | target=run_worker, 27 | name="WorkerThread", 28 | daemon=True # Set as daemon thread to work around the issue 29 | ) 30 | self.worker_thread.start() 31 | 32 | def stop_worker(self): 33 | if self.worker_loop: 34 | self.worker_loop.call_soon_threadsafe(self._worker_loop.stop) 35 | if self.worker_thread: 36 | self.worker_thread.join() 37 | ``` 38 | 39 | ## Solution 40 | The most common workaround is to set the worker thread as a daemon thread: 41 | 42 | ### Benefits of Daemon Thread Approach 43 | - Allows forced thread termination on program exit 44 | - Bypasses event loop hanging issues 45 | - Enables clean process termination 46 | 47 | ### Important Notes 48 | - This issue is less prevalent on Linux and macOS 49 | - Not a perfect solution as forced termination might lead to resource cleanup issues 50 | - Remains an ongoing issue in the Python community 51 | 52 | ## Alternative Solutions 53 | While daemon threads are the most practical solution, other approaches include: 54 | 55 | 1. Implementing careful cleanup logic: 56 | ```python 57 | async def cleanup(): 58 | tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] 59 | for task in tasks: 60 | task.cancel() 61 | await asyncio.gather(tasks, return_exceptions=True) 62 | loop.stop() 63 | ``` 64 | 65 | 2. Using signal handlers for graceful shutdown 66 | 3. Implementing timeout-based forced termination 67 | 68 | ## Platform Specifics 69 | - Windows: Most affected due to IOCP implementation 70 | - Linux/macOS: Less problematic due to different event loop implementations 71 | - The issue is specific to asyncio's integration with Windows' IOCP 72 | 73 | 74 | -------------------------------------------------------------------------------- /examples/signal_async.py: -------------------------------------------------------------------------------- 1 | # examples/signal_async.py 2 | 3 | """ 4 | Async Signal Example 5 | 6 | This example demonstrates the basic usage of TSignal with async slots: 7 | 1. Creating a signal 8 | 2. Connecting an async slot 9 | 3. Emitting a signal to async handler 10 | 11 | Key Points: 12 | - Demonstrates asynchronous signal-slot communication 13 | - Shows how to use @t_slot decorator with async functions 14 | - Illustrates handling of async slot execution in event loop 15 | - Explains integration of signals with asyncio for non-blocking operations 16 | """ 17 | 18 | import asyncio 19 | from tsignal import t_with_signals, t_signal, t_slot 20 | 21 | 22 | @t_with_signals 23 | class Counter: 24 | """ 25 | A simple counter class that emits a signal when its count changes. 26 | """ 27 | 28 | def __init__(self): 29 | self.count = 0 30 | 31 | @t_signal 32 | def count_changed(self): 33 | """Signal emitted when count changes""" 34 | 35 | def increment(self): 36 | """Increment counter and emit signal""" 37 | 38 | self.count += 1 39 | print(f"Counter incremented to: {self.count}") 40 | self.count_changed.emit(self.count) 41 | 42 | 43 | @t_with_signals 44 | class AsyncDisplay: 45 | """ 46 | A simple display class that receives count updates and processes them asynchronously. 47 | """ 48 | 49 | def __init__(self): 50 | self.last_value = None 51 | 52 | @t_slot 53 | async def on_count_changed(self, value): 54 | """Async slot that receives count updates""" 55 | print(f"Display processing count: {value}") 56 | # Simulate some async processing 57 | await asyncio.sleep(1) 58 | self.last_value = value 59 | print(f"Display finished processing: {value}") 60 | 61 | 62 | async def main(): 63 | """ 64 | Main function to run the async counter example. 65 | """ 66 | 67 | # Create instances 68 | counter = Counter() 69 | display = AsyncDisplay() 70 | 71 | # Connect signal to async slot 72 | counter.count_changed.connect(display, display.on_count_changed) 73 | 74 | print("Starting async counter example...") 75 | print("Press Enter to increment counter, or 'q' to quit") 76 | print("(Notice the 1 second delay in processing)") 77 | 78 | while True: 79 | # Get input asynchronously 80 | line = await asyncio.get_event_loop().run_in_executor(None, input, "> ") 81 | 82 | if line.lower() == "q": 83 | break 84 | 85 | # Increment counter which will emit signal 86 | counter.increment() 87 | 88 | # Give some time for async processing to complete 89 | await asyncio.sleep(0.1) 90 | 91 | 92 | if __name__ == "__main__": 93 | asyncio.run(main()) 94 | -------------------------------------------------------------------------------- /examples/signal_basic.py: -------------------------------------------------------------------------------- 1 | # examples/signal_basic.py 2 | 3 | """ 4 | Basic Signal-Slot Example 5 | 6 | This example demonstrates the fundamental usage of TSignal with a synchronous slot: 7 | 1. Creating a signal 8 | 2. Connecting a regular method as a slot (without @t_slot) 9 | 3. Emitting a signal to trigger slot execution 10 | 11 | Key Points: 12 | - Showcases the most basic form of signal-slot connection. 13 | - The slot is a normal instance method of a class, not decorated with @t_slot. 14 | - Emphasizes that even without @t_slot, a callable method can act as a slot. 15 | - Introduces the concept of signal emission and immediate slot execution. 16 | """ 17 | 18 | import asyncio 19 | import time 20 | from tsignal.core import t_with_signals, t_signal, t_slot 21 | 22 | 23 | @t_with_signals 24 | class Counter: 25 | """ 26 | A simple counter class that emits a signal when its count changes. 27 | """ 28 | 29 | def __init__(self): 30 | self.count = 0 31 | 32 | @t_signal 33 | def count_changed(self): 34 | """Signal emitted when count changes""" 35 | 36 | def increment(self): 37 | """Increment counter and emit signal""" 38 | self.count += 1 39 | print(f"Counter incremented to: {self.count}") 40 | self.count_changed.emit(self.count) 41 | 42 | 43 | @t_with_signals 44 | class Display: 45 | """ 46 | A simple display class that receives count updates and processes them. 47 | """ 48 | 49 | def __init__(self): 50 | self.last_value = None 51 | 52 | def on_count_changed(self, value): 53 | """slot that receives count updates""" 54 | print(f"Display processing count: {value}") 55 | # Simulate some heavy processing 56 | time.sleep(1) 57 | self.last_value = value 58 | print(f"Display finished processing: {value}") 59 | 60 | 61 | async def main(): 62 | """ 63 | Main function to run the async counter example. 64 | """ 65 | 66 | # Create instances 67 | counter = Counter() 68 | display = Display() 69 | 70 | # Connect signal to slot 71 | counter.count_changed.connect(display.on_count_changed) 72 | 73 | print("Starting counter example...") 74 | print("Press Enter to increment counter, or 'q' to quit") 75 | print("(Notice the 1 second delay in processing)") 76 | 77 | while True: 78 | line = input("> ") 79 | 80 | if line.lower() == "q": 81 | break 82 | 83 | # Increment counter which will emit signal 84 | counter.increment() 85 | 86 | 87 | if __name__ == "__main__": 88 | asyncio.run(main()) 89 | -------------------------------------------------------------------------------- /examples/signal_function_slots.py: -------------------------------------------------------------------------------- 1 | # examples/signal_function_slots.py 2 | 3 | """ 4 | Signal-Function Slots Example 5 | 6 | This example demonstrates how to connect a signal to a standalone function 7 | (not a class method). This highlights that slots can be any callable, not just methods. 8 | 9 | Steps: 10 | 1. Define a signal in a class (Counter) that emits when its count changes. 11 | 2. Define a standalone function that processes the emitted value. 12 | 3. Connect the signal to this standalone function as a slot. 13 | 4. Emit the signal by incrementing the counter and observe the function being called. 14 | 15 | Key Points: 16 | - Illustrates flexibility in choosing slots. 17 | - Standalone functions can serve as slots without additional decorators. 18 | """ 19 | 20 | import asyncio 21 | from tsignal.core import t_with_signals, t_signal 22 | 23 | 24 | @t_with_signals 25 | class Counter: 26 | """A simple counter class that emits a signal when its count changes.""" 27 | 28 | def __init__(self): 29 | self.count = 0 30 | 31 | @t_signal 32 | def count_changed(self): 33 | """Emitted when the count changes.""" 34 | 35 | def increment(self): 36 | """Increment the counter and emit the signal.""" 37 | 38 | self.count += 1 39 | print(f"Counter incremented to: {self.count}") 40 | self.count_changed.emit(self.count) 41 | 42 | 43 | def print_value(value): 44 | """A standalone function acting as a slot.""" 45 | 46 | print(f"Function Slot received value: {value}") 47 | 48 | 49 | async def main(): 50 | """Main function to run the signal-function slots example.""" 51 | 52 | counter = Counter() 53 | # Connect the signal to the standalone function slot 54 | counter.count_changed.connect(print_value) 55 | 56 | print("Press Enter to increment counter, or 'q' to quit.") 57 | 58 | while True: 59 | line = input("> ") 60 | 61 | if line.lower() == "q": 62 | break 63 | counter.increment() 64 | 65 | 66 | if __name__ == "__main__": 67 | asyncio.run(main()) 68 | -------------------------------------------------------------------------------- /examples/signal_lamba_slots.py: -------------------------------------------------------------------------------- 1 | # examples/signal_lambda_slots.py 2 | 3 | """ 4 | Signal-Lambda Slots Example 5 | 6 | This example demonstrates connecting a signal to a lambda function slot. 7 | It shows that you can quickly define inline, anonymous slots for simple tasks. 8 | 9 | Steps: 10 | 1. Define a signal in a class (Counter) that emits when its count changes. 11 | 2. Connect the signal to a lambda function that prints the received value. 12 | 3. Increment the counter and observe the lambda being called. 13 | 14 | Key Points: 15 | - Demonstrates that slots can be lambdas (anonymous functions). 16 | - Useful for quick, inline actions without defining a separate function or method. 17 | """ 18 | 19 | import asyncio 20 | from tsignal.core import t_with_signals, t_signal 21 | 22 | 23 | @t_with_signals 24 | class Counter: 25 | """A simple counter class that emits a signal when its count changes.""" 26 | 27 | def __init__(self): 28 | self.count = 0 29 | 30 | @t_signal 31 | def count_changed(self): 32 | """Emitted when the count changes.""" 33 | 34 | def increment(self): 35 | """Increment the counter and emit the signal.""" 36 | 37 | self.count += 1 38 | print(f"Counter incremented to: {self.count}") 39 | self.count_changed.emit(self.count) 40 | 41 | 42 | async def main(): 43 | """Main function to run the signal-lambda slots example.""" 44 | 45 | counter = Counter() 46 | # Connect the signal to a lambda slot 47 | counter.count_changed.connect(lambda v: print(f"Lambda Slot received: {v}")) 48 | 49 | print("Press Enter to increment counter, or 'q' to quit.") 50 | 51 | while True: 52 | line = input("> ") 53 | 54 | if line.lower() == "q": 55 | break 56 | counter.increment() 57 | 58 | 59 | if __name__ == "__main__": 60 | asyncio.run(main()) 61 | -------------------------------------------------------------------------------- /examples/stock_core.py: -------------------------------------------------------------------------------- 1 | # examples/stock_core.py 2 | 3 | # pylint: disable=no-member 4 | 5 | """ 6 | Stock monitoring core classes and logic. 7 | 8 | This module provides a simulated stock market data generator (`StockService`), 9 | a processor for handling price updates and alerts (`StockProcessor`), and a 10 | view-model class (`StockViewModel`) that can be connected to various UI or CLI 11 | front-ends. 12 | 13 | Usage: 14 | 1. Instantiate `StockService` to generate price data. 15 | 2. Instantiate `StockProcessor` to handle alert conditions and further processing. 16 | 3. Instantiate `StockViewModel` to manage UI-related state or to relay 17 | processed data to the presentation layer. 18 | 4. Connect the signals/slots between these objects to build a reactive flow 19 | that updates real-time stock information and triggers alerts when conditions are met. 20 | """ 21 | 22 | import asyncio 23 | from dataclasses import dataclass 24 | import logging 25 | import random 26 | import threading 27 | import time 28 | from typing import Dict, Optional 29 | from tsignal import t_with_signals, t_signal, t_slot, t_with_worker 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | @dataclass 35 | class StockPrice: 36 | """ 37 | A dataclass to represent stock price data. 38 | 39 | Attributes 40 | ---------- 41 | code : str 42 | The stock ticker symbol (e.g., 'AAPL', 'GOOGL', etc.). 43 | price : float 44 | The current price of the stock. 45 | change : float 46 | The percentage change compared to the previous price (in %). 47 | timestamp : float 48 | A UNIX timestamp representing the moment of this price capture. 49 | """ 50 | 51 | code: str 52 | price: float 53 | change: float 54 | timestamp: float 55 | 56 | 57 | @t_with_worker 58 | class StockService: 59 | """ 60 | Virtual stock price data generator and distributor. 61 | 62 | This class simulates real-time stock price updates by randomly fluctuating 63 | the prices of a predefined list of stock symbols. It runs in its own worker 64 | thread, driven by an asyncio event loop. 65 | 66 | Attributes 67 | ---------- 68 | prices : Dict[str, float] 69 | A mapping of stock code to current price. 70 | last_prices : Dict[str, float] 71 | A mapping of stock code to the previous price (for calculating percentage change). 72 | _running : bool 73 | Indicates whether the price generation loop is active. 74 | _update_task : asyncio.Task, optional 75 | The asyncio task that periodically updates prices. 76 | 77 | Signals 78 | ------- 79 | price_updated 80 | Emitted every time a single stock price is updated. Receives a `StockPrice` object. 81 | 82 | Lifecycle 83 | --------- 84 | - `on_started()` is called after the worker thread starts and before `update_prices()`. 85 | - `on_stopped()` is called when the worker thread is shutting down. 86 | """ 87 | 88 | def __init__(self): 89 | logger.debug("[StockService][__init__] started") 90 | 91 | self.prices: Dict[str, float] = { 92 | "AAPL": 180.0, # Apple Inc. 93 | "GOOGL": 140.0, # Alphabet Inc. 94 | "MSFT": 370.0, # Microsoft Corporation 95 | "AMZN": 145.0, # Amazon.com Inc. 96 | "TSLA": 240.0, # Tesla Inc. 97 | } 98 | self._desc_lock = threading.RLock() 99 | self._descriptions = { 100 | "AAPL": "Apple Inc.", 101 | "GOOGL": "Alphabet Inc.", 102 | "MSFT": "Microsoft Corporation", 103 | "AMZN": "Amazon.com Inc.", 104 | "TSLA": "Tesla Inc.", 105 | } 106 | self.last_prices = self.prices.copy() 107 | self._running = False 108 | self._update_task = None 109 | self.started.connect(self.on_started) 110 | self.stopped.connect(self.on_stopped) 111 | super().__init__() 112 | 113 | @property 114 | def descriptions(self) -> Dict[str, str]: 115 | """ 116 | Get the stock descriptions. 117 | 118 | Returns 119 | ------- 120 | Dict[str, str] 121 | A dictionary mapping stock codes to their descriptive names (e.g. "AAPL": "Apple Inc."). 122 | """ 123 | 124 | with self._desc_lock: 125 | return dict(self._descriptions) 126 | 127 | @t_signal 128 | def price_updated(self): 129 | """Signal emitted when stock price is updated""" 130 | 131 | async def on_started(self): 132 | """ 133 | Called automatically when the worker thread is started. 134 | 135 | Prepares and launches the asynchronous price update loop. 136 | """ 137 | 138 | logger.info("[StockService][on_started] started") 139 | self._running = True 140 | self._update_task = asyncio.create_task(self.update_prices()) 141 | 142 | async def on_stopped(self): 143 | """ 144 | Called automatically when the worker thread is stopped. 145 | 146 | Performs cleanup and cancellation of any active update tasks. 147 | """ 148 | 149 | logger.info("[StockService][on_stopped] stopped") 150 | self._running = False 151 | 152 | if hasattr(self, "_update_task"): 153 | self._update_task.cancel() 154 | 155 | try: 156 | await self._update_task 157 | except asyncio.CancelledError: 158 | pass 159 | 160 | async def update_prices(self): 161 | """ 162 | Periodically update stock prices in a loop. 163 | 164 | Randomly perturbs the prices within a small percentage range, then 165 | emits `price_updated` with a new `StockPrice` object for each stock. 166 | """ 167 | 168 | while self._running: 169 | for code, price in self.prices.items(): 170 | self.last_prices[code] = price 171 | change_pct = random.uniform(-0.01, 0.01) 172 | self.prices[code] *= 1 + change_pct 173 | 174 | price_data = StockPrice( 175 | code=code, 176 | price=self.prices[code], 177 | change=((self.prices[code] / self.last_prices[code]) - 1) * 100, 178 | timestamp=time.time(), 179 | ) 180 | 181 | logger.debug( 182 | "[StockService][update_prices] price_data: %s", 183 | price_data, 184 | ) 185 | self.price_updated.emit(price_data) 186 | 187 | logger.debug( 188 | "[StockService][update_prices] prices updated price_data: %s", 189 | price_data, 190 | ) 191 | 192 | await asyncio.sleep(1) 193 | 194 | 195 | @t_with_signals 196 | class StockViewModel: 197 | """ 198 | UI state manager for stock prices and alerts. 199 | 200 | This class holds the current stock prices and user-defined alert settings, 201 | and provides signals/slots for updating UI layers or notifying other components 202 | about price changes and alerts. 203 | 204 | Attributes 205 | ---------- 206 | current_prices : Dict[str, StockPrice] 207 | The latest known stock prices. 208 | alerts : list[tuple[str, str, float]] 209 | A list of triggered alerts in the form (stock_code, alert_type, current_price). 210 | alert_settings : Dict[str, tuple[Optional[float], Optional[float]]] 211 | A mapping of stock_code to (lower_alert_threshold, upper_alert_threshold). 212 | """ 213 | 214 | def __init__(self): 215 | self.current_prices: Dict[str, StockPrice] = {} 216 | self.alerts: list[tuple[str, str, float]] = [] 217 | self.alert_settings: Dict[str, tuple[Optional[float], Optional[float]]] = {} 218 | 219 | @t_signal 220 | def prices_updated(self): 221 | """ 222 | Signal emitted when stock prices are updated. 223 | 224 | Receives a dictionary of the form {stock_code: StockPrice}. 225 | """ 226 | 227 | @t_signal 228 | def alert_added(self): 229 | """ 230 | Signal emitted when a new alert is added. 231 | 232 | Receives (code, alert_type, current_price). 233 | """ 234 | 235 | @t_signal 236 | def set_alert(self): 237 | """ 238 | Signal emitted when user requests to set an alert. 239 | 240 | Receives (code, lower, upper). 241 | """ 242 | 243 | @t_signal 244 | def remove_alert(self): 245 | """ 246 | Signal emitted when user requests to remove an alert. 247 | 248 | Receives (code). 249 | """ 250 | 251 | @t_slot 252 | def on_price_processed(self, price_data: StockPrice): 253 | """ 254 | Receive processed stock price data from StockProcessor. 255 | 256 | Updates the local `current_prices` and notifies listeners that prices 257 | have changed. 258 | """ 259 | 260 | logger.debug("[StockViewModel][on_price_processed] price_data: %s", price_data) 261 | self.current_prices[price_data.code] = price_data 262 | self.prices_updated.emit(dict(self.current_prices)) 263 | 264 | @t_slot 265 | def on_alert_triggered(self, code: str, alert_type: str, price: float): 266 | """ 267 | Receive an alert trigger from StockProcessor. 268 | 269 | Appends the alert to `alerts` and emits `alert_added`. 270 | """ 271 | 272 | self.alerts.append((code, alert_type, price)) 273 | self.alert_added.emit(code, alert_type, price) 274 | 275 | @t_slot 276 | def on_alert_settings_changed( 277 | self, code: str, lower: Optional[float], upper: Optional[float] 278 | ): 279 | """ 280 | Receive alert settings change notification from StockProcessor. 281 | 282 | If both lower and upper are None, remove any alert setting for that code. 283 | Otherwise, update or create a new alert setting for that code. 284 | """ 285 | 286 | if lower is None and upper is None: 287 | self.alert_settings.pop(code, None) 288 | else: 289 | self.alert_settings[code] = (lower, upper) 290 | 291 | 292 | @t_with_worker 293 | class StockProcessor: 294 | """ 295 | Stock price data processor and alert condition checker. 296 | 297 | This class runs in a separate worker thread, receiving price updates from 298 | `StockService` and determining whether alerts should be triggered based on 299 | user-defined thresholds. If an alert condition is met, an `alert_triggered` 300 | signal is emitted. 301 | 302 | Attributes 303 | ---------- 304 | price_alerts : Dict[str, tuple[Optional[float], Optional[float]]] 305 | A mapping of stock_code to (lower_alert_threshold, upper_alert_threshold). 306 | 307 | Signals 308 | ------- 309 | price_processed 310 | Emitted after processing a new price data and optionally triggering alerts. 311 | alert_triggered 312 | Emitted if a stock price crosses its set threshold. 313 | alert_settings_changed 314 | Emitted whenever a stock's alert thresholds are changed. 315 | 316 | Lifecycle 317 | --------- 318 | - `on_started()` is invoked when the worker is fully initialized. 319 | - `on_stopped()` is called upon shutdown/cleanup. 320 | """ 321 | 322 | def __init__(self): 323 | logger.debug("[StockProcessor][__init__] started") 324 | self.price_alerts: Dict[str, tuple[Optional[float], Optional[float]]] = {} 325 | self.started.connect(self.on_started) 326 | self.stopped.connect(self.on_stopped) 327 | super().__init__() 328 | 329 | async def on_started(self): 330 | """Worker initialization""" 331 | 332 | logger.info("[StockProcessor][on_started] started") 333 | 334 | async def on_stopped(self): 335 | """Worker shutdown""" 336 | 337 | logger.info("[StockProcessor][on_stopped] stopped") 338 | 339 | @t_signal 340 | def price_processed(self): 341 | """Signal emitted when stock price is processed""" 342 | 343 | @t_signal 344 | def alert_triggered(self): 345 | """Signal emitted when price alert condition is met""" 346 | 347 | @t_signal 348 | def alert_settings_changed(self): 349 | """Signal emitted when price alert settings are changed""" 350 | 351 | @t_slot 352 | async def on_set_price_alert( 353 | self, code: str, lower: Optional[float], upper: Optional[float] 354 | ): 355 | """ 356 | Receive a price alert setting request from the main thread or UI. 357 | 358 | Updates (or creates) a new alert threshold entry, then emits `alert_settings_changed`. 359 | """ 360 | 361 | self.price_alerts[code] = (lower, upper) 362 | self.alert_settings_changed.emit(code, lower, upper) 363 | 364 | @t_slot 365 | async def on_remove_price_alert(self, code: str): 366 | """ 367 | Receive a price alert removal request from the main thread or UI. 368 | 369 | Deletes the alert thresholds for a given code, then emits `alert_settings_changed`. 370 | """ 371 | 372 | if code in self.price_alerts: 373 | del self.price_alerts[code] 374 | self.alert_settings_changed.emit(code, None, None) 375 | 376 | @t_slot 377 | async def on_price_updated(self, price_data: StockPrice): 378 | """ 379 | Receive stock price updates from the `StockService`. 380 | 381 | Delegates the actual processing to `process_price` via the task queue to 382 | avoid blocking other operations. 383 | """ 384 | 385 | logger.debug("[StockProcessor][on_price_updated] price_data: %s", price_data) 386 | 387 | try: 388 | coro = self.process_price(price_data) 389 | self.queue_task(coro) 390 | except Exception as e: 391 | logger.error("[SLOT] Error in on_price_updated: %s", e, exc_info=True) 392 | 393 | async def process_price(self, price_data: StockPrice): 394 | """ 395 | Process the updated price data. 396 | 397 | Checks if the stock meets the alert conditions (e.g., crossing upper/lower limits), 398 | emits `alert_triggered` as needed, then emits `price_processed`. 399 | """ 400 | 401 | logger.debug("[StockProcessor][process_price] price_data: %s", price_data) 402 | 403 | try: 404 | if price_data.code in self.price_alerts: 405 | logger.debug( 406 | "[process_price] Process price event loop: %s", 407 | asyncio.get_running_loop(), 408 | ) 409 | 410 | if price_data.code in self.price_alerts: 411 | lower, upper = self.price_alerts[price_data.code] 412 | 413 | if lower and price_data.price <= lower: 414 | self.alert_triggered.emit(price_data.code, "LOW", price_data.price) 415 | 416 | if upper and price_data.price >= upper: 417 | self.alert_triggered.emit(price_data.code, "HIGH", price_data.price) 418 | 419 | self.price_processed.emit(price_data) 420 | except Exception as e: 421 | logger.error("[StockProcessor][process_price] error: %s", e) 422 | raise 423 | -------------------------------------------------------------------------------- /examples/stock_monitor_console.py: -------------------------------------------------------------------------------- 1 | # examples/stock_monitor_console.py 2 | 3 | """ 4 | Stock monitor console example. 5 | 6 | This module demonstrates a command-line interface (CLI) for interacting 7 | with the stock monitoring system. It ties together `StockService`, 8 | `StockProcessor`, and `StockViewModel`, showing how signals/slots 9 | flow between them to provide user commands and real-time price updates. 10 | 11 | Usage: 12 | 1. Instantiate the main `StockMonitorCLI` class with references 13 | to the service, processor, and view model. 14 | 2. Run `cli.run()` in an async context to start the CLI loop. 15 | 3. The user can type commands like "stocks", "alert", or "remove" 16 | to manage alerts and display prices. 17 | """ 18 | 19 | import asyncio 20 | from typing import Dict 21 | import logging 22 | from utils import logger_setup 23 | from stock_core import StockPrice, StockService, StockProcessor, StockViewModel 24 | from tsignal import t_with_signals, t_slot 25 | 26 | logger_setup("tsignal", level=logging.NOTSET) 27 | logger_setup("stock_core", level=logging.NOTSET) 28 | logger = logger_setup(__name__, level=logging.NOTSET) 29 | 30 | 31 | @t_with_signals 32 | class StockMonitorCLI: 33 | """ 34 | Stock monitoring CLI interface. 35 | 36 | This class provides a text-based interactive prompt where users can: 37 | - View stock prices 38 | - Set or remove price alerts 39 | - Start/stop showing price updates 40 | - Quit the application 41 | 42 | Attributes 43 | ---------- 44 | service : StockService 45 | The worker responsible for generating stock prices. 46 | processor : StockProcessor 47 | The worker responsible for processing prices and handling alerts. 48 | view_model : StockViewModel 49 | A view-model that stores the latest prices and user alert settings. 50 | showing_prices : bool 51 | Whether the CLI is currently in "showprices" mode, continuously updating prices. 52 | running : bool 53 | Whether the CLI loop is active. 54 | """ 55 | 56 | def __init__( 57 | self, 58 | service: StockService, 59 | processor: StockProcessor, 60 | view_model: StockViewModel, 61 | ): 62 | logger.debug("[StockMonitorCLI][__init__] started") 63 | self.service = service 64 | self.processor = processor 65 | self.view_model = view_model 66 | self.current_input = "" 67 | self.running = True 68 | self.showing_prices = False 69 | 70 | def print_menu(self): 71 | """Print the menu""" 72 | 73 | print("\n===== MENU =====") 74 | print("stocks - List available stocks and prices") 75 | print("alert - Set price alert") 76 | print("remove - Remove price alert") 77 | print("list - List alert settings") 78 | print("showprices - Start showing price updates (press Enter to stop)") 79 | print("quit - Exit") 80 | print("================\n") 81 | 82 | async def get_line_input(self, prompt="Command> "): 83 | """ 84 | Get a line of user input asynchronously. 85 | 86 | Parameters 87 | ---------- 88 | prompt : str 89 | The prompt to display before reading user input. 90 | 91 | Returns 92 | ------- 93 | str 94 | The user-inputted line. 95 | """ 96 | 97 | return await asyncio.get_event_loop().run_in_executor( 98 | None, lambda: input(prompt) 99 | ) 100 | 101 | @t_slot 102 | def on_prices_updated(self, prices: Dict[str, StockPrice]): 103 | """ 104 | Respond to updated prices in the view model. 105 | 106 | If `showing_prices` is True, prints the current prices and any triggered alerts 107 | to the console without re-displaying the main menu. 108 | """ 109 | 110 | # If we are in showprices mode, display current prices: 111 | if self.showing_prices: 112 | print("Showing price updates (Press Enter to return to menu):") 113 | print("\nCurrent Prices:") 114 | 115 | for code, data in sorted(self.view_model.current_prices.items()): 116 | print(f"{code} ${data.price:.2f} ({data.change:+.2f}%)") 117 | 118 | print("\n(Press Enter to return to menu)") 119 | 120 | alerts = [] 121 | 122 | for code, data in prices.items(): 123 | if code in self.view_model.alert_settings: 124 | lower, upper = self.view_model.alert_settings[code] 125 | 126 | if lower and data.price <= lower: 127 | alerts.append( 128 | f"{code} price (${data.price:.2f}) below ${lower:.2f}" 129 | ) 130 | 131 | if upper and data.price >= upper: 132 | alerts.append( 133 | f"{code} price (${data.price:.2f}) above ${upper:.2f}" 134 | ) 135 | 136 | if alerts: 137 | print("\nAlerts:") 138 | 139 | for alert in alerts: 140 | print(alert) 141 | 142 | async def process_command(self, command: str): 143 | """ 144 | Process a single user command from the CLI. 145 | 146 | Supported commands: 147 | - stocks 148 | - alert 149 | - remove 150 | - list 151 | - showprices 152 | - quit 153 | """ 154 | 155 | parts = command.strip().split() 156 | 157 | if not parts: 158 | return 159 | 160 | if parts[0] == "stocks": 161 | print("\nAvailable Stocks:") 162 | print(f"{'Code':<6} {'Price':>10} {'Change':>8} {'Company Name':<30}") 163 | print("-" * 60) 164 | 165 | desc = self.service.descriptions 166 | 167 | for code in desc: 168 | if code in self.view_model.current_prices: 169 | price_data = self.view_model.current_prices[code] 170 | print( 171 | f"{code:<6} ${price_data.price:>9.2f} {price_data.change:>+7.2f}% {desc[code]:<30}" 172 | ) 173 | 174 | elif parts[0] == "alert" and len(parts) == 4: 175 | try: 176 | code = parts[1].upper() 177 | lower = float(parts[2]) 178 | upper = float(parts[3]) 179 | 180 | if code not in self.view_model.current_prices: 181 | print(f"Unknown stock code: {code}") 182 | return 183 | 184 | self.view_model.set_alert.emit(code, lower, upper) 185 | print(f"Alert set for {code}: lower={lower} upper={upper}") 186 | except ValueError: 187 | print("Invalid price values") 188 | 189 | elif parts[0] == "remove" and len(parts) == 2: 190 | code = parts[1].upper() 191 | 192 | if code in self.view_model.alert_settings: 193 | self.view_model.remove_alert.emit(code) 194 | print(f"Alert removed for {code}") 195 | 196 | elif parts[0] == "list": 197 | if not self.view_model.alert_settings: 198 | print("\nNo alerts currently set.") 199 | else: 200 | print("\nCurrent alerts:") 201 | print(f"{'Code':^6} {'Lower':>10} {'Upper':>10}") 202 | print("-" * 30) 203 | for code, (lower, upper) in sorted( 204 | self.view_model.alert_settings.items() 205 | ): 206 | print(f"{code:<6} ${lower:>9.2f} ${upper:>9.2f}") 207 | 208 | elif parts[0] == "showprices": 209 | self.showing_prices = True 210 | print("Now showing price updates. Press Enter to return to menu.") 211 | 212 | elif parts[0] == "quit": 213 | self.running = False 214 | print("Exiting...") 215 | 216 | else: 217 | print(f"Unknown command: {command}") 218 | 219 | async def run(self): 220 | """ 221 | Main execution loop for the CLI. 222 | 223 | Connects signals between `service`, `processor`, and `view_model`, 224 | then continuously reads user input until the user exits. 225 | """ 226 | 227 | logger.debug( 228 | "[StockMonitorCLI][run] started current loop: %s %s", 229 | id(asyncio.get_running_loop()), 230 | asyncio.get_running_loop(), 231 | ) 232 | 233 | # Future for receiving started signal 234 | main_loop = asyncio.get_running_loop() 235 | processor_started = asyncio.Future() 236 | 237 | # Connect service.start to processor's started signal 238 | def on_processor_started(): 239 | """Processor started""" 240 | 241 | logger.debug("[StockMonitorCLI][run] processor started, starting service") 242 | self.service.start() 243 | 244 | # Set processor_started future to True in the main loop 245 | def set_processor_started_true(): 246 | """Set processor started""" 247 | 248 | logger.debug( 249 | "[StockMonitorCLI][run] set_processor_started_true current loop: %s %s", 250 | id(asyncio.get_running_loop()), 251 | asyncio.get_running_loop(), 252 | ) 253 | processor_started.set_result(True) 254 | 255 | main_loop.call_soon_threadsafe(set_processor_started_true) 256 | 257 | self.service.price_updated.connect( 258 | self.processor, self.processor.on_price_updated 259 | ) 260 | self.processor.price_processed.connect( 261 | self.view_model, self.view_model.on_price_processed 262 | ) 263 | self.view_model.prices_updated.connect(self, self.on_prices_updated) 264 | self.view_model.set_alert.connect( 265 | self.processor, self.processor.on_set_price_alert 266 | ) 267 | self.view_model.remove_alert.connect( 268 | self.processor, self.processor.on_remove_price_alert 269 | ) 270 | self.processor.alert_triggered.connect( 271 | self.view_model, self.view_model.on_alert_triggered 272 | ) 273 | self.processor.alert_settings_changed.connect( 274 | self.view_model, self.view_model.on_alert_settings_changed 275 | ) 276 | 277 | self.processor.started.connect(on_processor_started) 278 | self.processor.start() 279 | 280 | # Wait until processor is started and service is started 281 | await processor_started 282 | 283 | while self.running: 284 | if not self.showing_prices: 285 | self.print_menu() 286 | command = await self.get_line_input() 287 | await self.process_command(command) 288 | else: 289 | await self.get_line_input("") 290 | self.showing_prices = False 291 | 292 | self.service.stop() 293 | self.processor.stop() 294 | 295 | 296 | async def main(): 297 | """Main function""" 298 | service = StockService() 299 | view_model = StockViewModel() 300 | processor = StockProcessor() 301 | 302 | cli = StockMonitorCLI(service, processor, view_model) 303 | 304 | await cli.run() 305 | 306 | 307 | if __name__ == "__main__": 308 | try: 309 | asyncio.run(main()) 310 | except KeyboardInterrupt: 311 | pass 312 | except SystemExit: 313 | pass 314 | -------------------------------------------------------------------------------- /examples/stock_monitor_simple.py: -------------------------------------------------------------------------------- 1 | # examples/stock_monitor_simple.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=unused-argument 5 | 6 | """ 7 | Stock monitor simple example. 8 | 9 | This module shows a straightforward example of using a worker (`DataWorker`) 10 | to generate data continuously and a display (`DataDisplay`) to process and 11 | log that data on the main thread. It's a minimal demonstration of TSignal's 12 | thread-safe signal/slot invocation. 13 | """ 14 | 15 | import asyncio 16 | import logging 17 | import threading 18 | import time 19 | from utils import logger_setup 20 | from tsignal import t_with_signals, t_signal, t_slot, t_with_worker 21 | 22 | logger_setup("tsignal", level=logging.DEBUG) 23 | logger = logger_setup(__name__, level=logging.DEBUG) 24 | 25 | 26 | @t_with_worker 27 | class DataWorker: 28 | """ 29 | A simple data worker that emits incrementing integers every second. 30 | 31 | Attributes 32 | ---------- 33 | _running : bool 34 | Indicates whether the update loop is active. 35 | _update_task : asyncio.Task, optional 36 | The asynchronous task that updates and emits data. 37 | 38 | Signals 39 | ------- 40 | data_processed 41 | Emitted with the incremented integer each time data is processed. 42 | 43 | Lifecycle 44 | --------- 45 | - `run(...)` is called automatically in the worker thread. 46 | - `stop()` stops the worker, cancelling the update loop. 47 | """ 48 | 49 | def __init__(self): 50 | self._running = False 51 | self._update_task = None 52 | 53 | @t_signal 54 | def data_processed(self): 55 | """ 56 | Signal emitted when data is processed. 57 | 58 | Receives an integer count. 59 | """ 60 | 61 | async def run(self, *args, **kwargs): 62 | """ 63 | Worker initialization and main event loop. 64 | 65 | Creates the update loop task and waits until the worker is stopped. 66 | """ 67 | 68 | logger.info("[DataWorker][run] Starting") 69 | 70 | self._running = True 71 | self._update_task = asyncio.create_task(self.update_loop()) 72 | # Wait until run() is finished 73 | await self.wait_for_stop() 74 | # Clean up 75 | self._running = False 76 | 77 | if self._update_task: 78 | self._update_task.cancel() 79 | 80 | try: 81 | await self._update_task 82 | except asyncio.CancelledError: 83 | pass 84 | 85 | async def update_loop(self): 86 | """ 87 | Periodically emits a counter value. 88 | 89 | Every second, the counter increments and `data_processed` is emitted. 90 | """ 91 | 92 | count = 0 93 | 94 | while self._running: 95 | logger.debug("[Worker] Processing data %d", count) 96 | self.data_processed.emit(count) 97 | 98 | count += 1 99 | 100 | await asyncio.sleep(1) 101 | 102 | 103 | @t_with_signals 104 | class DataDisplay: 105 | """ 106 | A display class that receives the processed data from the worker. 107 | 108 | Attributes 109 | ---------- 110 | last_value : int or None 111 | Stores the last received value from the worker. 112 | """ 113 | 114 | def __init__(self): 115 | self.last_value = None 116 | logger.debug("[Display] Created in thread: %s", threading.current_thread().name) 117 | 118 | @t_slot 119 | def on_data_processed(self, value): 120 | """ 121 | Slot called when data is processed. 122 | 123 | Logs the received value and simulates a brief processing delay. 124 | """ 125 | 126 | current_thread = threading.current_thread() 127 | logger.debug( 128 | "[Display] Received value %d in thread: %s", value, current_thread.name 129 | ) 130 | self.last_value = value 131 | # Add a small delay to check the result 132 | time.sleep(0.1) 133 | logger.debug("[Display] Processed value %d", value) 134 | 135 | 136 | async def main(): 137 | """ 138 | Main function demonstrating how to set up and run the worker and display. 139 | """ 140 | 141 | logger.debug("[Main] Starting in thread: %s", threading.current_thread().name) 142 | 143 | worker = DataWorker() 144 | display = DataDisplay() 145 | 146 | # Both are in the main thread at the connection point 147 | worker.data_processed.connect(display, display.on_data_processed) 148 | 149 | worker.start() 150 | 151 | try: 152 | await asyncio.sleep(3) # Run for 3 seconds 153 | finally: 154 | worker.stop() 155 | 156 | 157 | if __name__ == "__main__": 158 | asyncio.run(main()) 159 | -------------------------------------------------------------------------------- /examples/stock_monitor_ui.py: -------------------------------------------------------------------------------- 1 | # examples/stock_monitor_ui.py 2 | 3 | # pylint: disable=too-many-instance-attributes 4 | # pylint: disable=no-member 5 | # pylint: disable=unused-argument 6 | 7 | """ 8 | Stock monitor UI example. 9 | 10 | Demonstrates integrating TSignal-based signals/slots into a Kivy GUI application. 11 | It showcases a real-time price update loop (`StockService`), an alert/processing 12 | component (`StockProcessor`), and a Kivy-based front-end (`StockView`) for 13 | visualizing and setting stock alerts, all running asynchronously. 14 | """ 15 | 16 | import asyncio 17 | from typing import Dict 18 | 19 | from kivy.app import App 20 | from kivy.uix.boxlayout import BoxLayout 21 | from kivy.uix.label import Label 22 | from kivy.uix.button import Button 23 | from kivy.uix.spinner import Spinner 24 | from kivy.uix.textinput import TextInput 25 | from kivy.uix.widget import Widget 26 | from kivy.core.window import Window 27 | from kivy.clock import Clock 28 | 29 | from utils import logger_setup 30 | from stock_core import StockPrice, StockService, StockProcessor, StockViewModel 31 | from tsignal import t_with_signals, t_slot 32 | 33 | logger = logger_setup(__name__) 34 | 35 | 36 | @t_with_signals 37 | class StockView(BoxLayout): 38 | """ 39 | Stock monitor UI view (Kivy layout). 40 | 41 | Displays: 42 | - A status label 43 | - A Start/Stop button 44 | - A dropdown to select stock codes 45 | - Current price and change 46 | - Alert setting/removal inputs 47 | - A display label for triggered alerts 48 | """ 49 | 50 | def __init__(self, **kwargs): 51 | super().__init__(**kwargs) 52 | self.orientation = "vertical" 53 | self.spacing = 10 54 | self.padding = 10 55 | 56 | # Area to display status 57 | self.status_label = Label( 58 | text="Press Start to begin", size_hint_y=None, height=40 59 | ) 60 | self.add_widget(self.status_label) 61 | 62 | # Start/Stop button 63 | self.control_button = Button(text="Start", size_hint_y=None, height=40) 64 | self.add_widget(self.control_button) 65 | 66 | # Stock selection (using only AAPL for now, expand as needed) 67 | self.stock_spinner = Spinner( 68 | text="AAPL", 69 | values=("AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"), 70 | size_hint_y=None, 71 | height=40, 72 | ) 73 | self.add_widget(self.stock_spinner) 74 | 75 | # Display price 76 | self.price_layout = BoxLayout( 77 | orientation="horizontal", size_hint_y=None, height=40 78 | ) 79 | self.price_label = Label(text="Price: --") 80 | self.change_label = Label(text="Change: --") 81 | self.price_layout.add_widget(self.price_label) 82 | self.price_layout.add_widget(self.change_label) 83 | self.add_widget(self.price_layout) 84 | 85 | # Alert setting layout 86 | self.alert_layout = BoxLayout( 87 | orientation="horizontal", size_hint_y=None, height=40, spacing=5 88 | ) 89 | 90 | self.lower_input = TextInput( 91 | text="", hint_text="Lower", multiline=False, size_hint=(0.3, 1) 92 | ) 93 | self.upper_input = TextInput( 94 | text="", hint_text="Upper", multiline=False, size_hint=(0.3, 1) 95 | ) 96 | self.set_alert_button = Button(text="Set Alert", size_hint=(0.2, 1)) 97 | self.remove_alert_button = Button(text="Remove Alert", size_hint=(0.2, 1)) 98 | 99 | self.alert_layout.add_widget(self.lower_input) 100 | self.alert_layout.add_widget(self.upper_input) 101 | self.alert_layout.add_widget(self.set_alert_button) 102 | self.alert_layout.add_widget(self.remove_alert_button) 103 | 104 | self.add_widget(self.alert_layout) 105 | 106 | # Alert display label 107 | self.alert_label = Label(text="", size_hint_y=None, height=40) 108 | self.add_widget(self.alert_label) 109 | 110 | self.add_widget(Widget(size_hint_y=1)) 111 | 112 | def update_prices(self, prices: Dict[str, StockPrice]): 113 | """ 114 | Update the displayed price information based on the currently selected stock. 115 | 116 | If the spinner's text matches a code in `prices`, update the price label 117 | and change label. Also shows a status message indicating successful update. 118 | """ 119 | 120 | if self.stock_spinner.text in prices: 121 | price_data = prices[self.stock_spinner.text] 122 | self.price_label.text = f"Price: {price_data.price:.2f}" 123 | self.change_label.text = f"Change: {price_data.change:+.2f}%" 124 | self.status_label.text = "Prices updated" 125 | 126 | @t_slot 127 | def on_alert_added(self, code: str, alert_type: str, price: float): 128 | """ 129 | Slot for handling newly triggered alerts. 130 | 131 | Updates the `alert_label` in the UI to inform the user about the alert. 132 | """ 133 | 134 | self.alert_label.text = f"ALERT: {code} {alert_type} {price:.2f}" 135 | 136 | 137 | class AsyncKivyApp(App): 138 | """ 139 | A Kivy application that integrates with asyncio for background tasks. 140 | 141 | This class sets up the UI (`StockView`), the stock service, processor, 142 | and view model, and wires them together with signals/slots. It also provides 143 | a background task that keeps the UI responsive and handles graceful shutdown. 144 | """ 145 | 146 | def __init__(self): 147 | super().__init__() 148 | self.title = "Stock Monitor" 149 | self.background_task_running = True 150 | self.tasks = [] 151 | self.view = None 152 | self.service = None 153 | self.processor = None 154 | self.viewmodel = None 155 | self.async_lib = None 156 | 157 | def build(self): 158 | """ 159 | Build the UI layout, connect signals, and initialize the main components. 160 | """ 161 | 162 | self.view = StockView() 163 | 164 | self.service = StockService() 165 | self.processor = StockProcessor() 166 | self.viewmodel = StockViewModel() 167 | 168 | # Connect signals 169 | self.service.price_updated.connect( 170 | self.processor, self.processor.on_price_updated 171 | ) 172 | self.processor.price_processed.connect( 173 | self.viewmodel, self.viewmodel.on_price_processed 174 | ) 175 | self.viewmodel.prices_updated.connect(self.view, self.view.update_prices) 176 | 177 | # Alert related signals 178 | self.processor.alert_triggered.connect( 179 | self.viewmodel, self.viewmodel.on_alert_triggered 180 | ) 181 | self.processor.alert_settings_changed.connect( 182 | self.viewmodel, self.viewmodel.on_alert_settings_changed 183 | ) 184 | self.viewmodel.alert_added.connect(self.view, self.view.on_alert_added) 185 | 186 | # Alert setting/removal signals 187 | self.viewmodel.set_alert.connect( 188 | self.processor, self.processor.on_set_price_alert 189 | ) 190 | self.viewmodel.remove_alert.connect( 191 | self.processor, self.processor.on_remove_price_alert 192 | ) 193 | 194 | # Button event connections 195 | self.view.control_button.bind(on_press=self._toggle_service) 196 | self.view.set_alert_button.bind(on_press=self._set_alert) 197 | self.view.remove_alert_button.bind(on_press=self._remove_alert) 198 | 199 | Window.bind(on_request_close=self.on_request_close) 200 | 201 | return self.view 202 | 203 | def _toggle_service(self, instance): 204 | """ 205 | Start or stop the StockService and StockProcessor based on the current button state. 206 | """ 207 | 208 | if instance.text == "Start": 209 | self.service.start() 210 | self.processor.start() 211 | instance.text = "Stop" 212 | self.view.status_label.text = "Service started" 213 | else: 214 | self.service.stop() 215 | self.processor.stop() 216 | instance.text = "Start" 217 | self.view.status_label.text = "Service stopped" 218 | 219 | def _set_alert(self, instance): 220 | """ 221 | Handle the "Set Alert" button press. 222 | 223 | Reads the lower/upper thresholds from the text fields and emits `set_alert`. 224 | """ 225 | 226 | code = self.view.stock_spinner.text 227 | lower_str = self.view.lower_input.text.strip() 228 | upper_str = self.view.upper_input.text.strip() 229 | 230 | lower = float(lower_str) if lower_str else None 231 | upper = float(upper_str) if upper_str else None 232 | 233 | if not code: 234 | self.view.alert_label.text = "No stock selected" 235 | return 236 | 237 | self.viewmodel.set_alert.emit(code, lower, upper) 238 | self.view.alert_label.text = f"Alert set for {code}: lower={lower if lower else 'None'} upper={upper if upper else 'None'}" 239 | 240 | def _remove_alert(self, instance): 241 | """ 242 | Handle the "Remove Alert" button press. 243 | 244 | Emits `remove_alert` for the currently selected stock code. 245 | """ 246 | 247 | code = self.view.stock_spinner.text 248 | 249 | if not code: 250 | self.view.alert_label.text = "No stock selected" 251 | return 252 | 253 | self.viewmodel.remove_alert.emit(code) 254 | self.view.alert_label.text = f"Alert removed for {code}" 255 | 256 | async def background_task(self): 257 | """ 258 | Background task that can be used for periodic checks or housekeeping. 259 | 260 | Runs concurrently with the Kivy event loop in async mode. 261 | """ 262 | 263 | try: 264 | while self.background_task_running: 265 | await asyncio.sleep(2) 266 | except asyncio.CancelledError: 267 | pass 268 | 269 | def on_request_close(self, *args): 270 | """ 271 | Intercept the Kivy window close event to properly shut down. 272 | 273 | Returns True to indicate we handle the closing ourselves. 274 | """ 275 | 276 | asyncio.create_task(self.cleanup()) 277 | return True 278 | 279 | async def cleanup(self): 280 | """ 281 | Perform a graceful shutdown by stopping background tasks and stopping the app. 282 | """ 283 | 284 | self.background_task_running = False 285 | 286 | for task in self.tasks: 287 | if not task.done(): 288 | task.cancel() 289 | 290 | try: 291 | await task 292 | except asyncio.CancelledError: 293 | pass 294 | self.stop() 295 | 296 | async def async_run(self, async_lib=None): 297 | """ 298 | Launch the Kivy app in an async context. 299 | 300 | Parameters 301 | ---------- 302 | async_lib : module or None 303 | The async library to use (defaults to `asyncio`). 304 | """ 305 | 306 | self._async_lib = async_lib or asyncio 307 | 308 | return await self._async_lib.gather( 309 | self._async_lib.create_task(super().async_run(async_lib=async_lib)) 310 | ) 311 | 312 | 313 | async def main(): 314 | """ 315 | Main entry point for running the Kivy app in an async-friendly manner. 316 | """ 317 | 318 | Clock.init_async_lib("asyncio") 319 | 320 | app = AsyncKivyApp() 321 | background_task = asyncio.create_task(app.background_task()) 322 | app.tasks.append(background_task) 323 | 324 | try: 325 | await app.async_run() 326 | except Exception as e: 327 | print(f"Error during app execution: {e}") 328 | finally: 329 | for task in app.tasks: 330 | if not task.done(): 331 | task.cancel() 332 | 333 | try: 334 | await task 335 | except asyncio.CancelledError: 336 | pass 337 | 338 | 339 | if __name__ == "__main__": 340 | asyncio.run(main()) 341 | -------------------------------------------------------------------------------- /examples/thread_basic.py: -------------------------------------------------------------------------------- 1 | # examples/thread_basic.py 2 | 3 | """ 4 | Thread Communication Example 5 | 6 | This example demonstrates thread-safe communication between UI (main thread) and 7 | backend (worker thread) components using TSignal: 8 | 9 | 1. Main Thread: 10 | - UserView: UI component that displays data and handles user input 11 | 2. Worker Thread: 12 | - UserModel: Data model that manages user data 13 | - UserMediator: Mediates between View and Model 14 | 15 | Architecture: 16 | - View runs in main thread for UI operations 17 | - Model and Mediator run in worker thread for data processing 18 | - Signal/Slot connections automatically handle thread-safe communication 19 | """ 20 | 21 | import asyncio 22 | import threading 23 | import time 24 | from tsignal import t_with_signals, t_signal, t_slot 25 | 26 | 27 | @t_with_signals 28 | class UserView: 29 | """UI component in main thread""" 30 | 31 | def __init__(self): 32 | self.current_user = None 33 | print("UserView created in main thread") 34 | 35 | @t_signal 36 | def login_requested(self): 37 | """Signal emitted when user requests login""" 38 | 39 | @t_signal 40 | def logout_requested(self): 41 | """Signal emitted when user requests logout""" 42 | 43 | @t_slot 44 | def on_user_logged_in(self, user_data): 45 | """Slot called when login succeeds (automatically runs in main thread)""" 46 | 47 | self.current_user = user_data 48 | print(f"[Main Thread] UI Updated: Logged in as {user_data['name']}") 49 | 50 | @t_slot 51 | def on_user_logged_out(self): 52 | """Slot called when logout completes""" 53 | self.current_user = None 54 | print("[Main Thread] UI Updated: Logged out") 55 | 56 | def request_login(self, username, password): 57 | """UI action to request login""" 58 | 59 | print(f"[Main Thread] Login requested for user: {username}") 60 | self.login_requested.emit(username, password) 61 | 62 | def request_logout(self): 63 | """UI action to request logout""" 64 | 65 | if self.current_user: 66 | print(f"[Main Thread] Logout requested for {self.current_user['name']}") 67 | self.logout_requested.emit() 68 | 69 | 70 | @t_with_signals 71 | class UserModel: 72 | """Data model in worker thread""" 73 | 74 | def __init__(self): 75 | self.users = { 76 | "admin": {"password": "admin123", "name": "Administrator", "role": "admin"} 77 | } 78 | print("UserModel created in worker thread") 79 | 80 | @t_signal 81 | def user_authenticated(self): 82 | """Signal emitted when user authentication succeeds""" 83 | 84 | @t_signal 85 | def user_logged_out(self): 86 | """Signal emitted when user logout completes""" 87 | 88 | def authenticate_user(self, username, password): 89 | """Authenticate user credentials (runs in worker thread)""" 90 | print(f"[Worker Thread] Authenticating user: {username}") 91 | # Simulate database lookup 92 | time.sleep(1) 93 | 94 | user = self.users.get(username) 95 | if user and user["password"] == password: 96 | print(f"[Worker Thread] Authentication successful for {username}") 97 | self.user_authenticated.emit(user) 98 | return True 99 | return False 100 | 101 | def logout_user(self): 102 | """Process user logout (runs in worker thread)""" 103 | 104 | print("[Worker Thread] Processing logout") 105 | # Simulate cleanup 106 | time.sleep(0.5) 107 | self.user_logged_out.emit() 108 | 109 | 110 | @t_with_signals 111 | class UserMediator: 112 | """Mediator between View and Model in worker thread""" 113 | 114 | def __init__(self, view: UserView, model: UserModel): 115 | self.view = view 116 | self.model = model 117 | 118 | # Connect View signals to Mediator slots 119 | view.login_requested.connect(self, self.on_login_requested) 120 | view.logout_requested.connect(self, self.on_logout_requested) 121 | 122 | # Connect Model signals to View slots 123 | model.user_authenticated.connect(view, view.on_user_logged_in) 124 | model.user_logged_out.connect(view, view.on_user_logged_out) 125 | 126 | print("UserMediator created in worker thread") 127 | 128 | @t_slot 129 | def on_login_requested(self, username, password): 130 | """Handle login request from View (automatically runs in worker thread)""" 131 | 132 | print(f"[Worker Thread] Mediator handling login request for {username}") 133 | self.model.authenticate_user(username, password) 134 | 135 | @t_slot 136 | def on_logout_requested(self): 137 | """Handle logout request from View""" 138 | 139 | print("[Worker Thread] Mediator handling logout request") 140 | self.model.logout_user() 141 | 142 | 143 | def run_worker_thread(view): 144 | """Worker thread function""" 145 | 146 | print(f"Worker thread started: {threading.current_thread().name}") 147 | 148 | # Create Model and Mediator in worker thread 149 | async def create_model_and_mediator(): 150 | _model = UserModel() 151 | _mediator = UserMediator(view, _model) 152 | 153 | # Create and run event loop for worker thread 154 | loop = asyncio.new_event_loop() 155 | asyncio.set_event_loop(loop) 156 | 157 | # Keep worker thread running 158 | try: 159 | loop.create_task(create_model_and_mediator()) 160 | loop.run_forever() 161 | finally: 162 | loop.close() 163 | 164 | 165 | async def main(): 166 | """Main function""" 167 | 168 | # Create View in main thread 169 | view = UserView() 170 | 171 | # Start worker thread 172 | worker = threading.Thread(target=run_worker_thread, args=(view,)) 173 | worker.daemon = True 174 | worker.start() 175 | 176 | # Wait for worker thread to initialize 177 | await asyncio.sleep(0.1) 178 | 179 | print("\n=== Starting user interaction simulation ===\n") 180 | 181 | # Simulate user interactions 182 | view.request_login("admin", "admin123") 183 | await asyncio.sleep(1.5) # Wait for login process 184 | 185 | view.request_logout() 186 | await asyncio.sleep(1) # Wait for logout process 187 | 188 | print("\n=== Simulation completed ===") 189 | 190 | 191 | if __name__ == "__main__": 192 | 193 | asyncio.run(main()) 194 | -------------------------------------------------------------------------------- /examples/thread_worker.py: -------------------------------------------------------------------------------- 1 | # examples/thread_worker.py 2 | 3 | """ 4 | Thread Worker Pattern Example 5 | 6 | This example demonstrates the worker thread pattern using TSignal's t_with_worker decorator: 7 | 8 | 1. Worker Thread: 9 | - ImageProcessor: A worker class that processes images in background 10 | - Supports async initialization and cleanup 11 | - Uses task queue for processing requests 12 | - Communicates results via signals 13 | 14 | 2. Main Thread: 15 | - Controls worker lifecycle 16 | - Submits processing requests 17 | - Receives processing results 18 | 19 | Architecture: 20 | - Worker runs in separate thread with its own event loop 21 | - Task queue ensures sequential processing 22 | - Signal/Slot connections handle thread-safe communication 23 | """ 24 | 25 | # pylint: disable=no-member 26 | # pylint: disable=unused-argument 27 | 28 | import asyncio 29 | from tsignal import t_with_signals, t_signal, t_slot, t_with_worker 30 | 31 | 32 | @t_with_worker 33 | class ImageProcessor: 34 | """Worker that processes images in background thread""" 35 | 36 | def __init__(self, cache_size=100): 37 | self.cache_size = cache_size 38 | self.cache = {} 39 | super().__init__() 40 | self.stopped.connect(self, self.on_stopped) 41 | 42 | async def on_stopped(self): 43 | """Cleanup worker (runs in worker thread)""" 44 | 45 | print("[Worker Thread] Cleaning up image processor") 46 | self.cache.clear() 47 | 48 | @t_signal 49 | def processing_complete(self): 50 | """Signal emitted when image processing completes""" 51 | 52 | @t_signal 53 | def batch_complete(self): 54 | """Signal emitted when batch processing completes""" 55 | 56 | async def process_image(self, image_id: str, image_data: bytes): 57 | """Process single image (runs in worker thread)""" 58 | 59 | print(f"[Worker Thread] Processing image {image_id}") 60 | 61 | # Simulate image processing 62 | await asyncio.sleep(0.5) 63 | result = f"Processed_{image_id}" 64 | 65 | # Cache result 66 | if len(self.cache) >= self.cache_size: 67 | self.cache.pop(next(iter(self.cache))) 68 | self.cache[image_id] = result 69 | 70 | # Emit result 71 | self.processing_complete.emit(image_id, result) 72 | return result 73 | 74 | async def process_batch(self, images: list): 75 | """Process batch of images (runs in worker thread)""" 76 | 77 | results = [] 78 | 79 | for img_id, img_data in images: 80 | result = await self.process_image(img_id, img_data) 81 | results.append(result) 82 | 83 | self.batch_complete.emit(results) 84 | 85 | return results 86 | 87 | 88 | @t_with_signals 89 | class ImageViewer: 90 | """UI component that displays processed images""" 91 | 92 | def __init__(self): 93 | print("[Main Thread] Creating image viewer") 94 | self.processed_images = {} 95 | 96 | @t_slot 97 | def on_image_processed(self, image_id: str, result: str): 98 | """Handle processed image (runs in main thread)""" 99 | print(f"[Main Thread] Received processed image {image_id}") 100 | self.processed_images[image_id] = result 101 | 102 | @t_slot 103 | def on_batch_complete(self, results: list): 104 | """Handle completed batch (runs in main thread)""" 105 | print(f"[Main Thread] Batch processing complete: {len(results)} images") 106 | 107 | 108 | async def main(): 109 | """Main function to run the example""" 110 | 111 | # Create components 112 | processor = ImageProcessor() 113 | viewer = ImageViewer() 114 | 115 | # Connect signals 116 | processor.processing_complete.connect(viewer, viewer.on_image_processed) 117 | processor.batch_complete.connect(viewer, viewer.on_batch_complete) 118 | 119 | # Start worker 120 | print("\n=== Starting worker ===\n") 121 | processor.start(cache_size=5) 122 | 123 | # Simulate image processing requests 124 | print("\n=== Processing single images ===\n") 125 | for i in range(3): 126 | image_id = f"img_{i}" 127 | image_data = b"fake_image_data" 128 | processor.queue_task(processor.process_image(image_id, image_data)) 129 | 130 | # Simulate batch processing 131 | print("\n=== Processing batch ===\n") 132 | batch = [(f"batch_img_{i}", b"fake_batch_data") for i in range(3)] 133 | processor.queue_task(processor.process_batch(batch)) 134 | 135 | # Wait for processing to complete 136 | await asyncio.sleep(3) 137 | 138 | # Stop worker 139 | print("\n=== Stopping worker ===\n") 140 | processor.stop() 141 | 142 | 143 | if __name__ == "__main__": 144 | asyncio.run(main()) 145 | -------------------------------------------------------------------------------- /examples/utils.py: -------------------------------------------------------------------------------- 1 | # examples/utils.py 2 | 3 | """ 4 | Utility functions for example applications 5 | """ 6 | 7 | import logging 8 | import sys 9 | 10 | 11 | def logger_setup(name: str, level: int = logging.INFO): 12 | """setup logging for example application""" 13 | logger = logging.getLogger(name) 14 | logger.setLevel(level) 15 | 16 | if not logger.hasHandlers(): 17 | console_handler = logging.StreamHandler(sys.stdout) 18 | console_handler.setLevel(level) 19 | 20 | formatter = logging.Formatter( 21 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S" 22 | ) 23 | console_handler.setFormatter(formatter) 24 | logger.addHandler(console_handler) 25 | 26 | return logger 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "tsignal" 7 | version = "0.5.0" 8 | description = "A Python Signal-Slot library inspired by Qt (Deprecated - Use pynnex instead)" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "San Kim", email = "tsignal.dev@gmail.com"} 14 | ] 15 | keywords = ["signal-slot", "decorator", "multithreading", "asyncio"] 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Software Development :: Libraries" 25 | ] 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/nexconnectio/pynnex" 29 | Repository = "https://github.com/nexconnectio/pynnex" 30 | Documentation = "https://github.com/nexconnectio/pynnex#readme" 31 | Issues = "https://github.com/nexconnectio/pynnex/issues" 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | "pytest>=7.0", 36 | "pytest-cov>=4.0", 37 | "pytest-asyncio>=0.21.0", 38 | "memory_profiler" 39 | ] 40 | 41 | [tool.setuptools] 42 | package-dir = {"" = "src"} 43 | 44 | [tool.pytest.ini_options] 45 | minversion = "6.0" 46 | addopts = "--strict-markers --disable-warnings" 47 | testpaths = ["tests"] 48 | markers = [ 49 | "asyncio: mark test as an async test", 50 | "performance: mark test as a performance test", 51 | ] 52 | asyncio_mode = "auto" 53 | asyncio_default_fixture_loop_scope = "function" 54 | 55 | [tool.coverage.run] 56 | source = ["tsignal"] 57 | branch = true 58 | 59 | [tool.pylint] 60 | disable = [ 61 | "protected-access", 62 | "too-few-public-methods", 63 | "too-many-statements", 64 | "broad-exception-caught" 65 | ] 66 | max-line-length = 140 67 | 68 | [tool.coverage.report] 69 | exclude_lines = [ 70 | "pragma: no cover", 71 | "def __repr__", 72 | "if self.debug:", 73 | "raise NotImplementedError", 74 | "if __name__ == .__main__.:", 75 | "pass", 76 | "raise ImportError", 77 | ] 78 | 79 | [tool.mypy] 80 | # Disable checking for untyped function bodies 81 | check_untyped_defs = false 82 | 83 | # Allow implicit Optional 84 | no_implicit_optional = false 85 | 86 | # Disable specific errors 87 | disable_error_code = ["annotation-unchecked", "assignment"] 88 | 89 | # Do not treat warnings as errors 90 | warn_return_any = false 91 | warn_unused_configs = false 92 | 93 | ignore_errors = true 94 | omit = [ 95 | "tests/*", 96 | "setup.py", 97 | ] 98 | -------------------------------------------------------------------------------- /src/tsignal/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | TSignal - Python Signal/Slot Implementation (Deprecated - Use pynnex instead) 3 | """ 4 | 5 | import warnings 6 | 7 | from .core import ( 8 | t_with_signals, 9 | t_signal, 10 | t_slot, 11 | TConnectionType, 12 | TSignalConstants, 13 | t_signal_graceful_shutdown, 14 | ) 15 | from .utils import t_signal_log_and_raise_error 16 | from .contrib.patterns.worker.decorators import t_with_worker 17 | 18 | warnings.warn( 19 | "The tsignal package is deprecated as of version 0.5.x. " 20 | "Please use the pynnex package instead. " 21 | "For more information, visit https://github.com/nexconnectio/pynnex", 22 | DeprecationWarning, 23 | ) 24 | 25 | __version__ = "0.5.0" 26 | 27 | __all__ = [ 28 | "t_with_signals", 29 | "t_signal", 30 | "t_slot", 31 | "t_with_worker", 32 | "TConnectionType", 33 | "TSignalConstants", 34 | "t_signal_log_and_raise_error", 35 | "t_signal_graceful_shutdown", 36 | ] 37 | -------------------------------------------------------------------------------- /src/tsignal/contrib/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | 3 | from .property import t_property 4 | 5 | __all__ = ["t_property"] 6 | -------------------------------------------------------------------------------- /src/tsignal/contrib/extensions/property.py: -------------------------------------------------------------------------------- 1 | # src/tsignal/contrib/extensions/property.py 2 | 3 | # pylint: disable=too-many-arguments 4 | # pylint: disable=too-many-positional-arguments 5 | # pylint: disable=no-else-return 6 | # pylint: disable=unnecessary-dunder-call 7 | 8 | """ 9 | This module provides a property decorator that allows for thread-safe access to properties. 10 | """ 11 | 12 | import asyncio 13 | import threading 14 | import logging 15 | from tsignal.core import TSignalConstants 16 | from tsignal.utils import t_signal_log_and_raise_error 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class TProperty(property): 22 | """ 23 | A thread-safe property decorator for classes decorated with `@t_with_signals` 24 | or `@t_with_worker`. 25 | 26 | This property ensures that all reads (getter) and writes (setter) occur on the 27 | object's designated event loop, maintaining thread-safety across different threads. 28 | 29 | If the property is accessed from a different thread than the object's thread affinity: 30 | - The operation is automatically dispatched (queued) onto the object's event loop 31 | via `asyncio.run_coroutine_threadsafe`. 32 | - This prevents race conditions that might otherwise occur if multiple threads 33 | tried to read/write the same attribute. 34 | 35 | Parameters 36 | ---------- 37 | fget : callable, optional 38 | The getter function for the property. 39 | fset : callable, optional 40 | The setter function for the property. 41 | fdel : callable, optional 42 | The deleter function for the property (not commonly used). 43 | doc : str, optional 44 | Docstring for this property. 45 | notify : TSignal, optional 46 | A signal to emit when the property value changes. If provided, the signal is 47 | triggered after a successful write operation, and only if the new value is 48 | different from the old value. 49 | 50 | Notes 51 | ----- 52 | - The property’s underlying storage is typically `_private_name` on the instance, 53 | inferred from the property name (e.g. `value` -> `self._value`). 54 | - If `notify` is set, `signal.emit(new_value)` is called whenever the property changes. 55 | - Reading or writing this property from its "home" thread is done synchronously; 56 | from any other thread, TSignal automatically queues the operation in the 57 | object's event loop. 58 | 59 | Example 60 | ------- 61 | @t_with_signals 62 | class Model: 63 | @t_signal 64 | def value_changed(self): 65 | pass 66 | 67 | @t_property(notify=value_changed) 68 | def value(self): 69 | return self._value 70 | 71 | @value.setter 72 | def value(self, new_val): 73 | self._value = new_val 74 | 75 | # Usage: 76 | model = Model() 77 | model.value = 10 # If called from a different thread, it’s queued to model's loop 78 | current_val = model.value # Also thread-safe read 79 | 80 | See Also 81 | -------- 82 | t_with_signals : Decorates a class to enable signal/slot features and thread affinity. 83 | """ 84 | 85 | def __init__(self, fget=None, fset=None, fdel=None, doc=None, notify=None): 86 | super().__init__(fget, fset, fdel, doc) 87 | self.notify = notify 88 | self._private_name = None 89 | 90 | def __set_name__(self, owner, name): 91 | self._private_name = f"_{name}" 92 | 93 | def __get__(self, obj, objtype=None): 94 | if obj is None: 95 | return self 96 | 97 | if self.fget is None: 98 | raise AttributeError("unreadable attribute") 99 | 100 | if ( 101 | hasattr(obj, TSignalConstants.THREAD) 102 | and threading.current_thread() != obj._tsignal_thread 103 | ): 104 | # Dispatch to event loop when accessed from a different thread 105 | future = asyncio.run_coroutine_threadsafe( 106 | self._get_value(obj), obj._tsignal_loop 107 | ) 108 | 109 | return future.result() 110 | else: 111 | return self._get_value_sync(obj) 112 | 113 | def __set__(self, obj, value): 114 | if self.fset is None: 115 | raise AttributeError("can't set attribute") 116 | 117 | # DEBUG: Thread safety verification logs 118 | # logger.debug(f"[PROPERTY] thread: {obj._tsignal_thread} current thread: {threading.current_thread()} loop: {obj._tsignal_loop}") 119 | 120 | if ( 121 | hasattr(obj, TSignalConstants.THREAD) 122 | and threading.current_thread() != obj._tsignal_thread 123 | ): 124 | # Queue the setter call in the object's event loop 125 | future = asyncio.run_coroutine_threadsafe( 126 | self._set_value(obj, value), obj._tsignal_loop 127 | ) 128 | 129 | # Wait for completion like slot direct calls 130 | return future.result() 131 | else: 132 | return self._set_value_sync(obj, value) 133 | 134 | def _set_value_sync(self, obj, value): 135 | old_value = self.__get__(obj, type(obj)) 136 | result = self.fset(obj, value) 137 | 138 | if self.notify is not None and old_value != value: 139 | try: 140 | signal_name = getattr(self.notify, "signal_name", None) 141 | 142 | if signal_name: 143 | signal = getattr(obj, signal_name) 144 | signal.emit(value) 145 | else: 146 | t_signal_log_and_raise_error( 147 | logger, AttributeError, f"No signal_name found in {self.notify}" 148 | ) 149 | 150 | except AttributeError as e: 151 | logger.warning( 152 | "Property %s notify attribute not found. Error: %s", 153 | self._private_name, 154 | str(e), 155 | ) 156 | 157 | return result 158 | 159 | async def _set_value(self, obj, value): 160 | return self._set_value_sync(obj, value) 161 | 162 | def _get_value_sync(self, obj): 163 | return self.fget(obj) 164 | 165 | async def _get_value(self, obj): 166 | return self._get_value_sync(obj) 167 | 168 | def setter(self, fset): 169 | """ 170 | Set the setter for the property. 171 | """ 172 | return type(self)(self.fget, fset, self.fdel, self.__doc__, self.notify) 173 | 174 | 175 | def t_property(notify=None): 176 | """ 177 | Decorator to create a TProperty-based thread-safe property. 178 | 179 | Parameters 180 | ---------- 181 | notify : TSignal, optional 182 | If provided, this signal is automatically emitted when the property's value changes. 183 | 184 | Returns 185 | ------- 186 | function 187 | A decorator that converts a normal getter function into a TProperty-based property. 188 | 189 | Example 190 | ------- 191 | @t_with_signals 192 | class Example: 193 | @t_signal 194 | def updated(self): 195 | pass 196 | 197 | @t_property(notify=updated) 198 | def data(self): 199 | return self._data 200 | 201 | @data.setter 202 | def data(self, value): 203 | self._data = value 204 | 205 | e = Example() 206 | e.data = 42 # Thread-safe property set; emits 'updated' signal on change 207 | """ 208 | 209 | def decorator(func): 210 | return TProperty(fget=func, notify=notify) 211 | 212 | return decorator 213 | -------------------------------------------------------------------------------- /src/tsignal/contrib/patterns/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | -------------------------------------------------------------------------------- /src/tsignal/contrib/patterns/worker/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | -------------------------------------------------------------------------------- /src/tsignal/contrib/patterns/worker/decorators.py: -------------------------------------------------------------------------------- 1 | # src/tsignal/contrib/patterns/worker/decorators.py 2 | 3 | # pylint: disable=not-callable 4 | 5 | """ 6 | Decorator for the worker pattern. 7 | 8 | This decorator enhances a class to support a worker pattern, allowing for 9 | asynchronous task processing in a separate thread. It ensures that the 10 | class has the required asynchronous `initialize` and `finalize` methods, 11 | facilitating the management of worker threads and task queues. 12 | """ 13 | 14 | import asyncio 15 | import inspect 16 | import logging 17 | import threading 18 | from tsignal.core import t_signal 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class _WorkerConstants: 24 | """Constants for the worker pattern.""" 25 | 26 | RUN_CORO = "run_coro" 27 | RUN = "run" 28 | 29 | 30 | def t_with_worker(cls): 31 | """ 32 | Class decorator that adds a worker pattern to the decorated class, allowing it 33 | to run in a dedicated thread with its own asyncio event loop. This is especially 34 | useful for background processing or offloading tasks that should not block the 35 | main thread. 36 | 37 | Features 38 | -------- 39 | - **Dedicated Thread & Event Loop**: The decorated class, once started, runs in 40 | a new thread with its own event loop. 41 | - **Signal/Slot Support**: The worker class can define signals (with `@t_signal`) 42 | and slots (`@t_slot`), enabling event-driven communication. 43 | - **Task Queue**: A built-in asyncio `Queue` is provided for scheduling coroutines 44 | in the worker thread via `queue_task(coro)`. 45 | - **Lifecycle Signals**: Automatically emits `started` and `stopped` signals, 46 | indicating when the worker thread is up and when it has fully terminated. 47 | - **Lifecycle Management**: Methods like `start(...)`, `stop()`, and `move_to_thread(...)` 48 | help manage the worker thread's lifecycle and move other `@t_with_signals` objects 49 | into this worker thread. 50 | 51 | Usage 52 | ----- 53 | 1. Decorate the class with `@t_with_worker`. 54 | 2. Optionally implement an async `run(*args, **kwargs)` method to control 55 | the worker's main logic. This method is run in the worker thread. 56 | 3. Call `start(...)` to launch the thread and event loop. 57 | 4. Use `queue_task(...)` to schedule coroutines on the worker's event loop. 58 | 59 | Example 60 | ------- 61 | @t_with_worker 62 | class BackgroundWorker: 63 | @t_signal 64 | def started(self): 65 | pass 66 | 67 | @t_signal 68 | def stopped(self): 69 | pass 70 | 71 | @t_signal 72 | def result_ready(self): 73 | pass 74 | 75 | async def run(self, config=None): 76 | print("Worker started with config:", config) 77 | # Wait until stop is requested 78 | await self.wait_for_stop() 79 | print("Worker finishing...") 80 | 81 | async def do_work(self, data): 82 | await asyncio.sleep(2) 83 | self.result_ready.emit(data * 2) 84 | 85 | worker = BackgroundWorker() 86 | worker.start(config={'threads': 4}) 87 | worker.queue_task(worker.do_work(10)) 88 | worker.stop() 89 | 90 | See Also 91 | -------- 92 | t_slot : Decorates methods as thread-safe or async slots. 93 | t_signal : Decorates functions to define signals. 94 | """ 95 | 96 | class WorkerClass(cls): 97 | """ 98 | Worker class for the worker pattern. 99 | """ 100 | 101 | def __init__(self): 102 | self._tsignal_loop = None 103 | self._tsignal_thread = None 104 | 105 | """ 106 | _tsignal_lifecycle_lock: 107 | A re-entrant lock that protects worker's lifecycle state (event loop and thread). 108 | All operations that access or modify worker's lifecycle state must be 109 | performed while holding this lock. 110 | """ 111 | self._tsignal_lifecycle_lock = threading.RLock() 112 | self._tsignal_stopping = asyncio.Event() 113 | self._tsignal_affinity = object() 114 | self._tsignal_process_queue_task = None 115 | self._tsignal_task_queue = None 116 | super().__init__() 117 | 118 | @property 119 | def event_loop(self) -> asyncio.AbstractEventLoop: 120 | """Returns the worker's event loop""" 121 | 122 | if not self._tsignal_loop: 123 | raise RuntimeError("Worker not started") 124 | 125 | return self._tsignal_loop 126 | 127 | @t_signal 128 | def started(self): 129 | """Signal emitted when the worker starts""" 130 | 131 | @t_signal 132 | def stopped(self): 133 | """Signal emitted when the worker stops""" 134 | 135 | async def run(self, *args, **kwargs): 136 | """Run the worker.""" 137 | 138 | logger.debug("[WorkerClass][run] calling super") 139 | 140 | super_run = getattr(super(), _WorkerConstants.RUN, None) 141 | is_super_run_called = False 142 | 143 | if super_run is not None and inspect.iscoroutinefunction(super_run): 144 | sig = inspect.signature(super_run) 145 | 146 | try: 147 | logger.debug("[WorkerClass][run] sig: %s", sig) 148 | sig.bind(self, *args, **kwargs) 149 | await super_run(*args, **kwargs) 150 | logger.debug("[WorkerClass][run] super_run called") 151 | is_super_run_called = True 152 | except TypeError: 153 | logger.warning( 154 | "[WorkerClass][run] Parent run() signature mismatch. " 155 | "Expected: async def run(*args, **kwargs) but got %s", 156 | sig, 157 | ) 158 | 159 | if not is_super_run_called: 160 | logger.debug("[WorkerClass][run] super_run not called, starting queue") 161 | await self.start_queue() 162 | 163 | async def _process_queue(self): 164 | """Process the task queue.""" 165 | 166 | while not self._tsignal_stopping.is_set(): 167 | coro = await self._tsignal_task_queue.get() 168 | 169 | try: 170 | await coro 171 | except Exception as e: 172 | logger.error( 173 | "[WorkerClass][_process_queue] Task failed: %s", 174 | e, 175 | exc_info=True, 176 | ) 177 | finally: 178 | self._tsignal_task_queue.task_done() 179 | 180 | async def start_queue(self): 181 | """Start the task queue processing. Returns the queue task.""" 182 | 183 | self._tsignal_process_queue_task = asyncio.create_task( 184 | self._process_queue() 185 | ) 186 | 187 | def queue_task(self, coro): 188 | """Method to add a task to the queue""" 189 | 190 | if not asyncio.iscoroutine(coro): 191 | logger.error( 192 | "[WorkerClass][queue_task] Task must be a coroutine object: %s", 193 | coro, 194 | ) 195 | return 196 | 197 | with self._tsignal_lifecycle_lock: 198 | loop = self._tsignal_loop 199 | 200 | loop.call_soon_threadsafe(lambda: self._tsignal_task_queue.put_nowait(coro)) 201 | 202 | def start(self, *args, **kwargs): 203 | """Start the worker thread.""" 204 | 205 | run_coro = kwargs.pop(_WorkerConstants.RUN_CORO, None) 206 | 207 | if run_coro is not None and not asyncio.iscoroutine(run_coro): 208 | logger.error( 209 | "[WorkerClass][start][run_coro] must be a coroutine object: %s", 210 | run_coro, 211 | ) 212 | return 213 | 214 | def thread_main(): 215 | """Thread main function.""" 216 | 217 | self._tsignal_task_queue = asyncio.Queue() 218 | 219 | with self._tsignal_lifecycle_lock: 220 | self._tsignal_loop = asyncio.new_event_loop() 221 | asyncio.set_event_loop(self._tsignal_loop) 222 | 223 | async def runner(): 224 | """Runner function.""" 225 | 226 | self.started.emit() 227 | 228 | if run_coro is not None: 229 | run_task = asyncio.create_task(run_coro(*args, **kwargs)) 230 | else: 231 | run_task = asyncio.create_task(self.run(*args, **kwargs)) 232 | 233 | try: 234 | await self._tsignal_stopping.wait() 235 | 236 | run_task.cancel() 237 | 238 | try: 239 | await run_task 240 | except asyncio.CancelledError: 241 | pass 242 | 243 | if ( 244 | self._tsignal_process_queue_task 245 | and not self._tsignal_process_queue_task.done() 246 | ): 247 | self._tsignal_process_queue_task.cancel() 248 | 249 | try: 250 | await self._tsignal_process_queue_task 251 | except asyncio.CancelledError: 252 | logger.debug( 253 | "[WorkerClass][start][thread_main] _process_queue_task cancelled" 254 | ) 255 | 256 | finally: 257 | self.stopped.emit() 258 | # Give the event loop a chance to emit the signal 259 | await asyncio.sleep(0) 260 | logger.debug("[WorkerClass][start][thread_main] emit stopped") 261 | 262 | with self._tsignal_lifecycle_lock: 263 | loop = self._tsignal_loop 264 | 265 | loop.create_task(runner()) 266 | loop.run_forever() 267 | loop.close() 268 | 269 | with self._tsignal_lifecycle_lock: 270 | self._tsignal_loop = None 271 | 272 | # Protect thread creation and assignment under the same lock 273 | with self._tsignal_lifecycle_lock: 274 | self._tsignal_thread = threading.Thread(target=thread_main, daemon=True) 275 | 276 | with self._tsignal_lifecycle_lock: 277 | self._tsignal_thread.start() 278 | 279 | def stop(self): 280 | """Stop the worker thread.""" 281 | 282 | logger.debug("[WorkerClass][stop][START]") 283 | 284 | # Acquire lock to safely access _tsignal_loop and _tsignal_thread 285 | with self._tsignal_lifecycle_lock: 286 | loop = self._tsignal_loop 287 | thread = self._tsignal_thread 288 | 289 | if loop and thread and thread.is_alive(): 290 | logger.debug("[WorkerClass][stop][SET STOPPING]") 291 | loop.call_soon_threadsafe(self._tsignal_stopping.set) 292 | logger.debug("[WorkerClass][stop][WAITING FOR THREAD TO JOIN]") 293 | thread.join(timeout=2) 294 | logger.debug("[WorkerClass][stop][THREAD JOINED]") 295 | 296 | with self._tsignal_lifecycle_lock: 297 | self._tsignal_loop = None 298 | self._tsignal_thread = None 299 | 300 | def move_to_thread(self, target): 301 | """ 302 | Move target object to this worker's thread and loop. 303 | target must be an object created by t_with_signals or t_with_worker, 304 | and the worker must be started with start() method. 305 | """ 306 | with self._tsignal_lifecycle_lock: 307 | if not self._tsignal_thread or not self._tsignal_loop: 308 | raise RuntimeError( 309 | "[WorkerClass][move_to_thread] Worker thread not started. " 310 | "Cannot move target to this thread." 311 | ) 312 | 313 | # Assume target is initialized with t_with_signals 314 | # Reset target's _tsignal_thread, _tsignal_loop, _tsignal_affinity 315 | if not hasattr(target, "_tsignal_thread") or not hasattr( 316 | target, "_tsignal_loop" 317 | ): 318 | raise TypeError( 319 | "[WorkerClass][move_to_thread] Target is not compatible. " 320 | "Ensure it is decorated with t_with_signals or t_with_worker." 321 | ) 322 | 323 | # Copy worker's _tsignal_affinity, _tsignal_thread, _tsignal_loop to target 324 | target._tsignal_thread = self._tsignal_thread 325 | target._tsignal_loop = self._tsignal_loop 326 | target._tsignal_affinity = self._tsignal_affinity 327 | 328 | logger.debug( 329 | "[WorkerClass][move_to_thread] Moved %s to worker thread=%s with affinity=%s", 330 | target, 331 | self._tsignal_thread, 332 | self._tsignal_affinity, 333 | ) 334 | 335 | async def wait_for_stop(self): 336 | """Wait for the worker to stop.""" 337 | 338 | await self._tsignal_stopping.wait() 339 | 340 | return WorkerClass 341 | -------------------------------------------------------------------------------- /src/tsignal/utils.py: -------------------------------------------------------------------------------- 1 | # src/tsignal/utils.py 2 | 3 | """ 4 | Utility functions for tsignal 5 | """ 6 | 7 | import logging 8 | 9 | 10 | def t_signal_log_and_raise_error( 11 | logger: logging.Logger, exception_class, message, known_test_exception=False 12 | ): 13 | """ 14 | Log the provided message and raise the specified exception. 15 | If known_test_exception is True, logs as WARNING without a full stack trace. 16 | Otherwise logs as ERROR with stack trace. 17 | """ 18 | if not issubclass(exception_class, Exception): 19 | raise TypeError("exception_class must be a subclass of Exception") 20 | 21 | if known_test_exception: 22 | # intentional test exception -> warning level, no full stack trace 23 | logger.warning(f"{message} (Known test scenario, no full stack trace)") 24 | else: 25 | # regular exception -> error level, stack trace included 26 | logger.error(message, exc_info=True) 27 | 28 | raise exception_class(message) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # tests/conftest.py 2 | 3 | """ 4 | Shared fixtures for tests. 5 | """ 6 | 7 | # pylint: disable=no-member 8 | # pylint: disable=redefined-outer-name 9 | # pylint: disable=unused-variable 10 | # pylint: disable=unused-argument 11 | # pylint: disable=property-with-parameters 12 | 13 | import os 14 | import sys 15 | import asyncio 16 | import threading 17 | import logging 18 | import pytest 19 | import pytest_asyncio 20 | from tsignal import t_with_signals, t_signal, t_slot 21 | 22 | # Only creating the logger without configuration 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | @t_with_signals 27 | class Sender: 28 | """Sender class""" 29 | 30 | @t_signal 31 | def value_changed(self, value): 32 | """Signal for value changes""" 33 | 34 | def emit_value(self, value): 35 | """Emit a value change signal""" 36 | self.value_changed.emit(value) 37 | 38 | 39 | @t_with_signals 40 | class Receiver: 41 | """Receiver class""" 42 | 43 | def __init__(self): 44 | super().__init__() 45 | 46 | logger.debug("[Receiver][__init__] self=%s", self) 47 | 48 | self.received_value = None 49 | self.received_count = 0 50 | self.id = id(self) 51 | logger.info("Created Receiver[%d]", self.id) 52 | 53 | @t_slot 54 | async def on_value_changed(self, value: int): 55 | """Slot for value changes""" 56 | logger.info( 57 | "Receiver[%d] on_value_changed called with value: %d", self.id, value 58 | ) 59 | logger.info("Current thread: %s", threading.current_thread().name) 60 | logger.info("Current event loop: %s", asyncio.get_running_loop()) 61 | self.received_value = value 62 | self.received_count += 1 63 | logger.info( 64 | "Receiver[%d] updated: value=%d, count=%d", 65 | self.id, 66 | self.received_value, 67 | self.received_count, 68 | ) 69 | 70 | @t_slot 71 | def on_value_changed_sync(self, value: int): 72 | """Sync slot for value changes""" 73 | logger.info( 74 | "Receiver[%d] on_value_changed_sync called with value: %d", self.id, value 75 | ) 76 | self.received_value = value 77 | self.received_count += 1 78 | logger.info( 79 | "Receiver[%d] updated (sync): value=%d, count=%d", 80 | self.id, 81 | self.received_value, 82 | self.received_count, 83 | ) 84 | 85 | 86 | @pytest_asyncio.fixture 87 | async def receiver(): 88 | """Create a receiver""" 89 | logger.info("Creating receiver. event loop: %s", asyncio.get_running_loop()) 90 | return Receiver() 91 | 92 | 93 | @pytest_asyncio.fixture 94 | async def sender(): 95 | """Create a sender""" 96 | logger.info("Creating receiver. event loop: %s", asyncio.get_running_loop()) 97 | return Sender() 98 | 99 | 100 | @pytest.fixture(scope="session", autouse=True) 101 | def setup_logging(): 102 | """Configure logging for tests""" 103 | # Setting up the root logger 104 | root = logging.getLogger() 105 | 106 | # Setting to WARNING level by default 107 | default_level = logging.WARNING 108 | # default_level = logging.DEBUG 109 | 110 | # Can enable DEBUG mode via environment variable 111 | 112 | if os.environ.get("TSIGNAL_DEBUG"): 113 | default_level = logging.DEBUG 114 | 115 | root.setLevel(default_level) 116 | logger.debug("Logging level set to: %s", default_level) 117 | 118 | # Removing existing handlers 119 | for handler in root.handlers: 120 | root.removeHandler(handler) 121 | 122 | # Setting up console handler 123 | console_handler = logging.StreamHandler(sys.stdout) 124 | console_handler.setLevel(default_level) 125 | 126 | # Setting formatter 127 | formatter = logging.Formatter( 128 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S" 129 | ) 130 | console_handler.setFormatter(formatter) 131 | root.addHandler(console_handler) 132 | 133 | # Setting package logger levels 134 | logging.getLogger("tsignal").setLevel(default_level) 135 | logging.getLogger("tests").setLevel(default_level) 136 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | -------------------------------------------------------------------------------- /tests/integration/test_async.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_async.py 2 | 3 | """ 4 | Test cases for asynchronous operations. 5 | """ 6 | 7 | import asyncio 8 | import logging 9 | import pytest 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_multiple_async_slots(sender, receiver): 16 | """Test multiple async slots receiving signals""" 17 | 18 | logger.info("Test starting with receiver[%s]", receiver.id) 19 | receiver2 = receiver.__class__() 20 | logger.info("Created receiver2[%s]", receiver2.id) 21 | 22 | logger.info("Connecting receiver[%s] to signal", receiver.id) 23 | sender.value_changed.connect(receiver, receiver.on_value_changed) 24 | logger.info("Connecting receiver2[%s] to signal", receiver2.id) 25 | sender.value_changed.connect(receiver2, receiver2.on_value_changed) 26 | 27 | logger.info("Emitting value 42") 28 | sender.emit_value(42) 29 | 30 | for i in range(5): 31 | logger.info("Wait iteration %d", i + 1) 32 | if receiver.received_value is not None and receiver2.received_value is not None: 33 | logger.info("Both receivers have received values") 34 | break 35 | await asyncio.sleep(0.1) 36 | 37 | logger.info( 38 | "Final state - receiver1[%s]: value=%d", 39 | receiver.id, 40 | receiver.received_value, 41 | ) 42 | logger.info( 43 | "Final state - receiver2[%s]: value=%d", 44 | receiver2.id, 45 | receiver2.received_value, 46 | ) 47 | 48 | assert receiver.received_value == 42 49 | assert receiver2.received_value == 42 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_async_slot_execution(sender, receiver): 54 | """Test async slot execution with event loop""" 55 | 56 | logger.info("Starting test_async_slot_execution") 57 | sender.value_changed.connect(receiver, receiver.on_value_changed) 58 | 59 | logger.info("Emitting value") 60 | sender.emit_value(42) 61 | 62 | for _ in range(5): 63 | if receiver.received_value is not None: 64 | break 65 | await asyncio.sleep(0.1) 66 | 67 | logger.info("Receiver value: %d", receiver.received_value) 68 | assert receiver.received_value == 42 69 | -------------------------------------------------------------------------------- /tests/integration/test_thread_safety.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_thread_safety.py 2 | 3 | # pylint: disable=unused-argument 4 | 5 | """ 6 | Test cases for thread safety of TSignal. 7 | """ 8 | 9 | import unittest 10 | import threading 11 | import gc 12 | from tsignal.core import t_with_signals, t_signal 13 | 14 | 15 | @t_with_signals 16 | class SafeSender: 17 | """ 18 | A class that sends events. 19 | """ 20 | 21 | @t_signal 22 | def event(self, value): 23 | """ 24 | Event signal. 25 | """ 26 | 27 | 28 | class SafeReceiver: 29 | """ 30 | A class that receives events. 31 | """ 32 | 33 | def __init__(self, name=None): 34 | self.called = 0 35 | self.name = name 36 | 37 | def on_event(self, value): 38 | """ 39 | Event handler. 40 | """ 41 | 42 | self.called += 1 43 | 44 | 45 | class TestThreadSafe(unittest.IsolatedAsyncioTestCase): 46 | """ 47 | Test cases for thread safety of TSignal. 48 | """ 49 | 50 | async def test_thread_safety(self): 51 | """ 52 | Test thread safety of TSignal. 53 | """ 54 | 55 | sender = SafeSender() 56 | receiver = SafeReceiver("strong_ref") 57 | 58 | # regular connection 59 | sender.event.connect(receiver, receiver.on_event, weak=False) 60 | 61 | # weak reference connection 62 | weak_receiver = SafeReceiver("weak_ref") 63 | sender.event.connect(weak_receiver, weak_receiver.on_event, weak=True) 64 | 65 | # additional receivers 66 | extra_receivers = [SafeReceiver(f"extra_{i}") for i in range(10)] 67 | for r in extra_receivers: 68 | sender.event.connect(r, r.on_event, weak=False) 69 | 70 | # background thread to emit events 71 | def emit_task(): 72 | """ 73 | Background thread to emit events. 74 | """ 75 | 76 | for i in range(1000): 77 | sender.event.emit(i) 78 | 79 | # thread to connect/disconnect repeatedly 80 | def connect_disconnect_task(): 81 | """ 82 | Thread to connect/disconnect repeatedly. 83 | """ 84 | 85 | # randomly connect/disconnect one of extra_receivers 86 | for i in range(500): 87 | idx = i % len(extra_receivers) 88 | r = extra_receivers[idx] 89 | 90 | if i % 2 == 0: 91 | sender.event.connect(r, r.on_event, weak=False) 92 | else: 93 | sender.event.disconnect(r, r.on_event) 94 | 95 | # thread to try to GC weak_receiver 96 | def gc_task(): 97 | """ 98 | Thread to try to GC weak_receiver. 99 | """ 100 | 101 | nonlocal weak_receiver 102 | for i in range(100): 103 | if i == 50: 104 | # release weak_receiver reference and try to GC 105 | del weak_receiver 106 | gc.collect() 107 | else: 108 | # randomly emit events 109 | sender.event.emit(i) 110 | 111 | threads = [] 112 | # multiple threads to perform various tasks 113 | threads.append(threading.Thread(target=emit_task)) 114 | threads.append(threading.Thread(target=connect_disconnect_task)) 115 | threads.append(threading.Thread(target=gc_task)) 116 | 117 | # start threads 118 | for t in threads: 119 | t.start() 120 | 121 | # wait for all threads to finish 122 | for t in threads: 123 | t.join() 124 | 125 | # check: strong_ref receiver should have been called 126 | self.assertTrue( 127 | receiver.called > 0, 128 | f"Strong ref receiver should have been called. Called={receiver.called}", 129 | ) 130 | 131 | # some extra_receivers may have been connected/disconnected repeatedly 132 | # if at least one of them has been called, it's normal 133 | called_counts = [r.called for r in extra_receivers] 134 | self.assertTrue( 135 | any(c > 0 for c in called_counts), 136 | "At least one extra receiver should have been called.", 137 | ) 138 | 139 | # weak_receiver can be GCed. If it is GCed, the receiver will not be called anymore. 140 | # weak_receiver itself is GCed, so it is not accessible. In this case, it is simply checked that it works without errors. 141 | # Here, it is simply checked that the code does not raise an exception and terminates normally. 142 | -------------------------------------------------------------------------------- /tests/integration/test_threading.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_threading.py 2 | 3 | # pylint: disable=unused-argument 4 | # pylint: disable=unnecessary-lambda 5 | 6 | """ 7 | Test cases for threading. 8 | """ 9 | 10 | import asyncio 11 | import threading 12 | import time 13 | import logging 14 | import pytest 15 | from tsignal.core import t_with_signals, t_signal, t_slot 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_different_thread_connection(sender, receiver): 22 | """Test signal emission from different thread""" 23 | 24 | sender.value_changed.connect(receiver, receiver.on_value_changed) 25 | sender_done = threading.Event() 26 | 27 | def run_sender(): 28 | """Run the sender thread""" 29 | for i in range(3): 30 | sender.emit_value(i) 31 | time.sleep(0.1) 32 | sender_done.set() 33 | 34 | sender_thread = threading.Thread(target=run_sender) 35 | sender_thread.start() 36 | 37 | while not sender_done.is_set() or receiver.received_count < 3: 38 | await asyncio.sleep(0.1) 39 | 40 | sender_thread.join() 41 | 42 | assert receiver.received_count == 3 43 | assert receiver.received_value == 2 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_call_slot_from_other_thread(receiver): 48 | """Test calling slot from different thread""" 49 | 50 | done = threading.Event() 51 | 52 | def other_thread(): 53 | """Run the other thread""" 54 | 55 | loop = asyncio.new_event_loop() 56 | asyncio.set_event_loop(loop) 57 | 58 | async def call_slot(): 59 | await receiver.on_value_changed(100) 60 | 61 | loop.run_until_complete(call_slot()) 62 | done.set() 63 | loop.close() 64 | 65 | thread = threading.Thread(target=other_thread) 66 | thread.start() 67 | 68 | while not done.is_set(): 69 | await asyncio.sleep(0.1) 70 | 71 | thread.join() 72 | assert receiver.received_value == 100 73 | assert receiver.received_count == 1 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_connection_type_with_different_threads(): 78 | """Test connection type is determined correctly for different thread scenarios""" 79 | 80 | @t_with_signals 81 | class Sender: 82 | """Sender class""" 83 | 84 | @t_signal 85 | def value_changed(self): 86 | """Signal emitted when value changes""" 87 | 88 | @t_with_signals 89 | class Receiver: 90 | """Receiver class""" 91 | 92 | def __init__(self): 93 | super().__init__() 94 | self.received = False 95 | 96 | @t_slot 97 | def on_value_changed(self, value): 98 | """Slot called when value changes""" 99 | 100 | self.received = True 101 | 102 | # Create receiver in a different thread 103 | receiver_in_thread = None 104 | receiver_thread_ready = threading.Event() 105 | receiver_loop_running = threading.Event() 106 | receiver_stop_loop_event = threading.Event() 107 | 108 | def create_receiver(): 109 | """Create receiver in a different thread""" 110 | 111 | nonlocal receiver_in_thread 112 | loop = asyncio.new_event_loop() 113 | asyncio.set_event_loop(loop) 114 | 115 | async def create_receiver_async(): 116 | r = Receiver() 117 | return r 118 | 119 | receiver_in_thread = loop.run_until_complete(create_receiver_async()) 120 | receiver_thread_ready.set() 121 | 122 | # Keep the loop running after receiver is created 123 | def keep_running(): 124 | loop.call_soon_threadsafe(lambda: receiver_loop_running.set()) 125 | loop.run_forever() 126 | 127 | loop_thread = threading.Thread(target=keep_running, daemon=True) 128 | loop_thread.start() 129 | 130 | # Wait until the test is finished 131 | receiver_stop_loop_event.wait() 132 | 133 | # Stop the event loop 134 | loop.call_soon_threadsafe(loop.stop) 135 | loop_thread.join() 136 | 137 | receiver_thread = threading.Thread(target=create_receiver) 138 | receiver_thread.start() 139 | 140 | receiver_thread_ready.wait() # Wait for receiver to be created 141 | receiver_loop_running.wait() # Wait for receiver loop to be actually running 142 | 143 | sender_in_main_thread = Sender() 144 | sender_in_main_thread.value_changed.connect( 145 | receiver_in_thread, receiver_in_thread.on_value_changed 146 | ) 147 | sender_in_main_thread.value_changed.emit(123) 148 | 149 | time.sleep(1) 150 | 151 | assert ( 152 | receiver_in_thread.received is True 153 | ), "Slot should be triggered asynchronously (queued)" 154 | 155 | receiver_stop_loop_event.set() 156 | receiver_thread.join() 157 | -------------------------------------------------------------------------------- /tests/integration/test_with_signal.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_with_signal.py 2 | 3 | # pylint: disable=duplicate-code 4 | 5 | """ 6 | Test cases for the with-signal pattern. 7 | """ 8 | 9 | import asyncio 10 | import pytest 11 | from tests.conftest import Receiver 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_same_thread_connection(sender, receiver): 16 | """Test signal-slot connection in same thread""" 17 | 18 | sender.value_changed.connect(receiver, receiver.on_value_changed) 19 | sender.emit_value(42) 20 | await asyncio.sleep(0.1) 21 | assert receiver.received_value == 42 22 | assert receiver.received_count == 1 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_multiple_slots(sender): 27 | """Test multiple slot connections""" 28 | 29 | receiver1 = Receiver() 30 | receiver2 = Receiver() 31 | 32 | sender.value_changed.connect(receiver1, receiver1.on_value_changed) 33 | sender.value_changed.connect(receiver2, receiver2.on_value_changed) 34 | 35 | sender.emit_value(42) 36 | await asyncio.sleep(0.1) 37 | assert receiver1.received_value == 42 38 | assert receiver1.received_count == 1 39 | assert receiver2.received_value == 42 40 | assert receiver2.received_count == 1 41 | -------------------------------------------------------------------------------- /tests/integration/test_worker.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_worker.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=redefined-outer-name 5 | # pylint: disable=unused-variable 6 | 7 | """ 8 | Test cases for the worker pattern. 9 | """ 10 | 11 | import asyncio 12 | import logging 13 | import pytest 14 | from tsignal.contrib.patterns.worker.decorators import t_with_worker 15 | from tsignal.core import TSignalConstants 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @pytest.fixture 21 | async def worker(): 22 | """Create a worker""" 23 | logger.info("[TestWorker][fixture] Creating worker in fixture") 24 | w = TestWorker() 25 | yield w 26 | logger.info("[TestWorker][fixture] Cleaning up worker in fixture") 27 | if getattr(w, TSignalConstants.THREAD, None) and w._tsignal_thread.is_alive(): 28 | logger.info("[TestWorker][fixture] Stopping worker thread") 29 | w.stop() 30 | logger.info("[TestWorker][fixture] Stopping worker thread complete") 31 | 32 | 33 | @t_with_worker 34 | class TestWorker: 35 | """Test worker class""" 36 | 37 | def __init__(self): 38 | logger.info("[TestWorker][__init__]") 39 | self.run_called = False 40 | self.data = [] 41 | super().__init__() 42 | 43 | async def run(self, *args, **kwargs): 44 | """Run the worker""" 45 | logger.info("[TestWorker][run] called with %s, %s", args, kwargs) 46 | self.run_called = True 47 | initial_value = args[0] if args else kwargs.get("initial_value", None) 48 | if initial_value: 49 | self.data.append(initial_value) 50 | await self.start_queue() 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_worker_lifecycle(worker): 55 | """Test the worker lifecycle""" 56 | logger.info("Starting test_worker_lifecycle") 57 | initial_value = "test" 58 | 59 | logger.info("Checking initial state") 60 | assert worker._tsignal_thread is None 61 | assert worker._tsignal_loop is None 62 | assert not worker.run_called 63 | 64 | logger.info("Starting worker") 65 | worker.start(initial_value) 66 | 67 | logger.info("Waiting for worker initialization") 68 | for i in range(10): 69 | if worker.run_called: 70 | logger.info("Worker run called after %d attempts", i + 1) 71 | break 72 | logger.info("Waiting attempt %d", i + 1) 73 | await asyncio.sleep(0.1) 74 | else: 75 | logger.error("Worker failed to run") 76 | pytest.fail("Worker did not run in time") 77 | 78 | logger.info("Checking worker state") 79 | assert worker.run_called 80 | assert worker.data == [initial_value] 81 | 82 | logger.info("Stopping worker") 83 | worker.stop() 84 | -------------------------------------------------------------------------------- /tests/integration/test_worker_queue.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_worker_queue.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=redefined-outer-name 5 | 6 | """ 7 | Test cases for the worker-queue pattern. 8 | """ 9 | 10 | import asyncio 11 | import logging 12 | import pytest 13 | from tsignal import t_signal 14 | from tsignal.contrib.patterns.worker.decorators import t_with_worker 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @pytest.fixture 20 | async def queue_worker(): 21 | """Create a queue worker""" 22 | 23 | logger.info("Creating QueueWorker") 24 | w = QueueWorker() 25 | yield w 26 | logger.info("Cleaning up QueueWorker") 27 | w.stop() 28 | 29 | 30 | @t_with_worker 31 | class QueueWorker: 32 | """Queue worker class""" 33 | 34 | def __init__(self): 35 | self.processed_items = [] 36 | super().__init__() 37 | 38 | async def process_item(self, item): 39 | """Process an item""" 40 | 41 | logger.info( 42 | "[QueueWorker][process_item] processing item: %s processed_items: %s", 43 | item, 44 | self.processed_items, 45 | ) 46 | 47 | await asyncio.sleep(0.1) # Simulate work 48 | self.processed_items.append(item) 49 | 50 | logger.info( 51 | "[QueueWorker][process_item] processed item: %s processed_items: %s", 52 | item, 53 | self.processed_items, 54 | ) 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_basic_queue_operation(queue_worker): 59 | """Basic queue operation test""" 60 | 61 | logger.info("Starting queue worker") 62 | queue_worker.start() 63 | logger.info("Queue worker started") 64 | await asyncio.sleep(0.1) 65 | 66 | logger.info("Queueing tasks") 67 | queue_worker.queue_task(queue_worker.process_item("item1")) 68 | logger.info("Task queued: item1") 69 | queue_worker.queue_task(queue_worker.process_item("item2")) 70 | logger.info("Task queued: item2") 71 | 72 | logger.info("Waiting for tasks to complete") 73 | await asyncio.sleep(0.5) 74 | 75 | logger.info("Checking processed items: %s", queue_worker.processed_items) 76 | assert "item1" in queue_worker.processed_items 77 | assert "item2" in queue_worker.processed_items 78 | assert len(queue_worker.processed_items) == 2 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_queue_order(queue_worker): 83 | """Test for ensuring the order of the task queue""" 84 | 85 | queue_worker.start() 86 | await asyncio.sleep(0.1) 87 | 88 | items = ["first", "second", "third"] 89 | 90 | for item in items: 91 | queue_worker.queue_task(queue_worker.process_item(item)) 92 | 93 | await asyncio.sleep(0.5) 94 | 95 | assert queue_worker.processed_items == items 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_queue_error_handling(queue_worker): 100 | """Test for error handling in the task queue""" 101 | 102 | async def failing_task(): 103 | raise ValueError("Test error") 104 | 105 | queue_worker.start() 106 | await asyncio.sleep(0.1) 107 | 108 | # Add normal and failing tasks 109 | queue_worker.queue_task(queue_worker.process_item("good_item")) 110 | queue_worker.queue_task(failing_task()) 111 | queue_worker.queue_task(queue_worker.process_item("after_error")) 112 | 113 | await asyncio.sleep(0.5) 114 | 115 | # The error should not prevent the next task from being processed 116 | print("[test_queue_error_handling] processed_items: ", queue_worker.processed_items) 117 | assert "good_item" in queue_worker.processed_items 118 | assert "after_error" in queue_worker.processed_items 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_queue_cleanup_on_stop(queue_worker): 123 | """Test for queue cleanup when worker stops""" 124 | 125 | queue_worker.start() 126 | await asyncio.sleep(0.1) 127 | 128 | # Add a long task 129 | async def long_task(): 130 | await asyncio.sleep(0.5) 131 | queue_worker.processed_items.append("long_task") 132 | 133 | queue_worker.queue_task(long_task()) 134 | 135 | await asyncio.sleep(1) # Wait for the task to start 136 | assert "long_task" in queue_worker.processed_items 137 | 138 | # Stop the worker while the task is running 139 | queue_worker.stop() 140 | 141 | # Check if the worker exited normally 142 | assert not queue_worker._tsignal_thread 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_mixed_signal_and_queue(queue_worker): 147 | """Test for simultaneous use of signals and task queue""" 148 | 149 | # Add a signal 150 | @t_signal 151 | def task_completed(): 152 | pass 153 | 154 | queue_worker.task_completed = task_completed.__get__(queue_worker) 155 | signal_received = [] 156 | queue_worker.task_completed.connect(lambda: signal_received.append(True)) 157 | 158 | queue_worker.start() 159 | await asyncio.sleep(0.1) 160 | 161 | # Add a task and emit the signal 162 | async def task_with_signal(): 163 | await asyncio.sleep(0.1) 164 | queue_worker.processed_items.append("signal_task") 165 | queue_worker.task_completed.emit() 166 | 167 | queue_worker.queue_task(task_with_signal()) 168 | await asyncio.sleep(0.3) 169 | 170 | assert "signal_task" in queue_worker.processed_items 171 | assert signal_received == [True] 172 | -------------------------------------------------------------------------------- /tests/integration/test_worker_signal.py: -------------------------------------------------------------------------------- 1 | # tests/integration/test_worker_signal.py 2 | 3 | # pylint: disable=redefined-outer-name 4 | # pylint: disable=unnecessary-lambda 5 | # pylint: disable=unnecessary-lambda-assignment 6 | # pylint: disable=no-member 7 | 8 | """ 9 | Test cases for the worker-signal pattern. 10 | """ 11 | 12 | import asyncio 13 | import logging 14 | import pytest 15 | from tsignal.contrib.patterns.worker.decorators import t_with_worker 16 | from tsignal import t_signal, TSignalConstants 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @pytest.fixture 22 | async def signal_worker_lifecycle(): 23 | """Create a signal worker for testing the lifecycle""" 24 | logger.info("Creating SignalWorker") 25 | w = SignalWorker() 26 | yield w 27 | 28 | 29 | @pytest.fixture 30 | async def signal_worker(): 31 | """Create a signal worker for testing the value changed signal""" 32 | logger.info("Creating SignalWorker") 33 | w = SignalWorker() 34 | yield w 35 | logger.info("Cleaning up SignalWorker") 36 | if getattr(w, TSignalConstants.THREAD, None) and w._tsignal_thread.is_alive(): 37 | w.stop() 38 | 39 | 40 | @t_with_worker 41 | class SignalWorker: 42 | """Signal worker class""" 43 | 44 | def __init__(self): 45 | self.value = None 46 | super().__init__() 47 | 48 | @t_signal 49 | def worker_event(self): 50 | """Signal emitted when the worker event occurs""" 51 | 52 | @t_signal 53 | def value_changed(self): 54 | """Signal emitted when the value changes""" 55 | 56 | def set_value(self, value): 57 | """Set the value and emit the signal""" 58 | logger.info("[SignalWorker][set_value]Setting value to: %s", value) 59 | self.value = value 60 | self.value_changed.emit(value) 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_signal_lifecycle(signal_worker_lifecycle): 65 | """Test if the signal emitted from initialize is processed correctly""" 66 | received = [] 67 | 68 | async def on_started(): 69 | logger.info("[on_started]started") 70 | received.append("started") 71 | logger.info("[on_started]received: %s", received) 72 | 73 | async def on_stopped(): 74 | logger.info("[on_stopped]stopped") 75 | received.append("stopped") 76 | logger.info("[on_stopped]received: %s", received) 77 | 78 | signal_worker_lifecycle.started.connect(on_started) 79 | signal_worker_lifecycle.stopped.connect(on_stopped) 80 | 81 | signal_worker_lifecycle.start() 82 | await asyncio.sleep(0.1) 83 | signal_worker_lifecycle.stop() 84 | await asyncio.sleep(0.1) 85 | 86 | logger.info("received: %s", received) 87 | 88 | assert "started" in received 89 | assert "stopped" in received 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_signal_from_worker_thread(signal_worker): 94 | """Test if the signal emitted from the worker thread is processed correctly""" 95 | received = [] 96 | 97 | async def on_value_changed(value): 98 | """Callback for the value changed signal""" 99 | logger.info("[on_value_changed]Received value: %s", value) 100 | received.append(value) 101 | 102 | signal_worker.value_changed.connect(on_value_changed) 103 | 104 | signal_worker.start() 105 | await asyncio.sleep(0.1) 106 | 107 | # Emit signal from the worker thread's event loop 108 | signal_worker.event_loop.call_soon_threadsafe( 109 | lambda: signal_worker.set_value("test_value") 110 | ) 111 | 112 | await asyncio.sleep(0.1) 113 | assert "test_value" in received 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_multiple_signals(signal_worker): 118 | """Test if multiple signals are processed independently""" 119 | value_changes = [] 120 | worker_events = [] 121 | 122 | signal_worker.value_changed.connect(lambda v: value_changes.append(v)) 123 | signal_worker.worker_event.connect(lambda v: worker_events.append(v)) 124 | 125 | signal_worker.start() 126 | await asyncio.sleep(0.1) 127 | 128 | # Emit value_changed signal 129 | signal_worker.event_loop.call_soon_threadsafe( 130 | lambda: signal_worker.set_value("test_value") 131 | ) 132 | 133 | # Emit worker_event signal 134 | signal_worker.event_loop.call_soon_threadsafe( 135 | lambda: signal_worker.worker_event.emit("worker_event") 136 | ) 137 | 138 | await asyncio.sleep(0.1) 139 | 140 | assert "test_value" in value_changes 141 | assert "worker_event" in worker_events 142 | assert len(worker_events) == 1 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_signal_disconnect(signal_worker): 147 | """Test if signal disconnection works correctly""" 148 | received = [] 149 | handler = lambda v: received.append(v) 150 | 151 | signal_worker.value_changed.connect(handler) 152 | signal_worker.start() 153 | await asyncio.sleep(0.1) 154 | 155 | signal_worker.event_loop.call_soon_threadsafe( 156 | lambda: signal_worker.set_value("test_value") 157 | ) 158 | await asyncio.sleep(0.1) 159 | 160 | assert "test_value" in received 161 | received.clear() 162 | 163 | # Disconnect signal 164 | signal_worker.value_changed.disconnect(slot=handler) 165 | 166 | signal_worker._tsignal_loop.call_soon_threadsafe( 167 | lambda: signal_worker.set_value("after_disconnect") 168 | ) 169 | 170 | await asyncio.sleep(0.1) 171 | assert len(received) == 0 172 | -------------------------------------------------------------------------------- /tests/performance/test_memory.py: -------------------------------------------------------------------------------- 1 | # tests/performance/test_memory.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=redefined-outer-name 5 | # pylint: disable=unused-variable 6 | 7 | 8 | """ 9 | Test cases for memory usage. 10 | """ 11 | 12 | import pytest 13 | from tsignal import t_with_signals, t_signal, t_slot 14 | 15 | 16 | def create_complex_signal_chain(): 17 | """Create a complex signal chain""" 18 | 19 | @t_with_signals 20 | class Sender: 21 | """Sender class""" 22 | 23 | @t_signal 24 | def signal(self): 25 | """Signal method""" 26 | 27 | @t_with_signals 28 | class Receiver: 29 | """Receiver class""" 30 | 31 | @t_slot 32 | def slot(self, value): 33 | """Slot method""" 34 | 35 | sender = Sender() 36 | receivers = [Receiver() for _ in range(100)] 37 | for r in receivers: 38 | sender.signal.connect(r, r.slot) 39 | return sender 40 | 41 | 42 | @pytest.mark.performance 43 | @pytest.mark.asyncio 44 | async def test_memory_usage(): 45 | """Test memory usage""" 46 | # Create and delete signal/slot pairs repeatedly 47 | for _ in range(1000): 48 | sender = create_complex_signal_chain() 49 | sender.signal.disconnect() 50 | -------------------------------------------------------------------------------- /tests/performance/test_stress.py: -------------------------------------------------------------------------------- 1 | # tests/performance/test_stress.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=redefined-outer-name 5 | # pylint: disable=unused-variable 6 | 7 | """ 8 | Test cases for stress testing. 9 | """ 10 | 11 | import asyncio 12 | import logging 13 | import pytest 14 | from tsignal import t_with_signals, t_signal, t_slot, t_signal_graceful_shutdown 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | async def graceful_shutdown(): 20 | """ 21 | Waits for all pending tasks to complete. 22 | This repeatedly checks for tasks until none are left except the current one. 23 | """ 24 | while True: 25 | await asyncio.sleep(0) # Let the event loop process pending callbacks 26 | tasks = asyncio.all_tasks() 27 | tasks.discard(asyncio.current_task()) 28 | if not tasks: 29 | break 30 | # Wait for all pending tasks to complete (or fail) before checking again 31 | await asyncio.gather(*tasks, return_exceptions=True) 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_heavy_signal_load(): 36 | """Test heavy signal load""" 37 | 38 | @t_with_signals 39 | class Sender: 40 | """Sender class""" 41 | 42 | @t_signal 43 | def signal(self): 44 | """Signal method""" 45 | 46 | @t_with_signals 47 | class Receiver: 48 | """Receiver class""" 49 | 50 | @t_slot 51 | async def slot(self): 52 | """Slot method""" 53 | await asyncio.sleep(0.001) 54 | 55 | sender = Sender() 56 | receivers = [Receiver() for _ in range(100)] 57 | for r in receivers: 58 | sender.signal.connect(r, r.slot) 59 | 60 | for _ in range(1000): 61 | sender.signal.emit() 62 | 63 | # Graceful shutdown: ensure all tasks triggered by emit are completed 64 | await t_signal_graceful_shutdown() 65 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | # pylint: disable=duplicate-code 3 | -------------------------------------------------------------------------------- /tests/unit/test_property.py: -------------------------------------------------------------------------------- 1 | # tests/unit/test_property.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=unnecessary-lambda 5 | # pylint: disable=useless-with-lock 6 | 7 | """ 8 | Test cases for the property pattern. 9 | """ 10 | 11 | import asyncio 12 | import threading 13 | import logging 14 | import pytest 15 | from tsignal.contrib.extensions.property import t_property 16 | from tsignal import t_signal, t_with_signals 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | @t_with_signals 23 | class Temperature: 24 | """Temperature class for testing""" 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self._celsius = -273 29 | 30 | @t_signal 31 | def celsius_changed(self): 32 | """Signal for celsius change""" 33 | 34 | @t_property(notify=celsius_changed) 35 | def celsius(self) -> float: 36 | """Getter for celsius""" 37 | return self._celsius 38 | 39 | @celsius.setter 40 | def celsius(self, value: float): 41 | """Setter for celsius""" 42 | self._celsius = value 43 | 44 | 45 | @t_with_signals 46 | class ReadOnlyTemperature: 47 | """ReadOnlyTemperature class for testing""" 48 | 49 | def __init__(self): 50 | super().__init__() 51 | self._celsius = 0 52 | 53 | @t_signal 54 | def celsius_changed(self): 55 | """Signal for celsius change""" 56 | 57 | @t_property(notify=celsius_changed) 58 | def celsius(self) -> float: 59 | """Getter for celsius""" 60 | return self._celsius 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_property_basic(): 65 | """Test basic property get/set operations""" 66 | 67 | temp = Temperature() 68 | assert temp.celsius == -273 69 | 70 | # Test setter 71 | temp.celsius = 25 72 | assert temp.celsius == 25 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_property_notification(): 77 | """Test property change notifications""" 78 | 79 | temp = Temperature() 80 | received_values = [] 81 | 82 | # Connect signal 83 | temp.celsius_changed.connect(lambda x: received_values.append(x)) 84 | 85 | # Test initial value 86 | temp.celsius = 25 87 | assert temp.celsius == 25 88 | assert received_values == [25] 89 | 90 | # Clear received values 91 | received_values.clear() 92 | 93 | # Test no notification on same value 94 | temp.celsius = 25 95 | assert not received_values 96 | 97 | # Test notification on value change 98 | temp.celsius = 30 99 | assert received_values == [30] 100 | 101 | # Test multiple changes 102 | temp.celsius = 15 103 | temp.celsius = 45 104 | assert received_values == [30, 15, 45] 105 | 106 | 107 | @pytest.mark.asyncio 108 | async def test_property_read_only(): 109 | """Test read-only property behavior""" 110 | 111 | temp = ReadOnlyTemperature() 112 | 113 | with pytest.raises(AttributeError, match="can't set attribute"): 114 | temp.celsius = 25 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_property_thread_safety(): 119 | """Test property thread safety and notifications across threads""" 120 | 121 | temp = Temperature() 122 | received_values = [] 123 | task_completed = asyncio.Event() 124 | main_loop = asyncio.get_running_loop() 125 | 126 | def background_task(): 127 | loop = asyncio.new_event_loop() 128 | asyncio.set_event_loop(loop) 129 | 130 | async def set_temp(): 131 | temp.celsius = 15 132 | await asyncio.sleep(0.1) 133 | 134 | try: 135 | loop.run_until_complete(set_temp()) 136 | finally: 137 | loop.close() 138 | main_loop.call_soon_threadsafe(task_completed.set) 139 | 140 | # Connect signal 141 | temp.celsius_changed.connect(lambda x: received_values.append(x)) 142 | 143 | # Start background thread 144 | thread = threading.Thread(target=background_task) 145 | thread.start() 146 | 147 | await task_completed.wait() 148 | 149 | thread.join() 150 | 151 | assert temp.celsius == 15 152 | assert 15 in received_values 153 | 154 | 155 | @pytest.mark.asyncio 156 | async def test_property_multiple_threads(): 157 | """Test property behavior with multiple threads""" 158 | 159 | temp = Temperature() 160 | received_values = [] 161 | values_lock = threading.Lock() 162 | threads_lock = threading.Lock() 163 | num_threads = 5 164 | task_completed = asyncio.Event() 165 | threads_done = 0 166 | main_loop = asyncio.get_running_loop() 167 | 168 | def on_celsius_changed(value): 169 | """Handler for celsius change""" 170 | 171 | with values_lock: 172 | received_values.append(value) 173 | 174 | temp.celsius_changed.connect(on_celsius_changed) 175 | 176 | def background_task(value): 177 | """Background task for thread safety testing""" 178 | 179 | nonlocal threads_done 180 | loop = asyncio.new_event_loop() 181 | asyncio.set_event_loop(loop) 182 | 183 | try: 184 | with threads_lock: 185 | temp.celsius = value 186 | loop.run_until_complete(asyncio.sleep(0.1)) 187 | finally: 188 | loop.close() 189 | 190 | with threading.Lock(): 191 | nonlocal threads_done 192 | threads_done += 1 193 | if threads_done == num_threads: 194 | main_loop.call_soon_threadsafe(task_completed.set) 195 | 196 | threads = [ 197 | threading.Thread(target=background_task, args=(i * 10,)) 198 | for i in range(num_threads) 199 | ] 200 | 201 | # Start threads 202 | for thread in threads: 203 | thread.start() 204 | 205 | # Wait for task completion 206 | try: 207 | await asyncio.wait_for(task_completed.wait(), timeout=2.0) 208 | except asyncio.TimeoutError: 209 | logger.warning("Timeout waiting for threads") 210 | 211 | # Join threads 212 | for thread in threads: 213 | thread.join() 214 | 215 | await asyncio.sleep(0.2) 216 | 217 | expected_values = set(i * 10 for i in range(num_threads)) 218 | received_set = set(received_values) 219 | 220 | assert ( 221 | expected_values == received_set 222 | ), f"Expected {expected_values}, got {received_set}" 223 | 224 | # DEBUG: Connect debug handler to monitor value changes 225 | # temp.celsius_changed.connect( 226 | # lambda x: logger.debug(f"Temperature value after change: {temp.celsius}") 227 | # ) 228 | 229 | 230 | @pytest.mark.asyncio 231 | async def test_property_exception_handling(): 232 | """Test property behavior with exceptions in signal handlers""" 233 | 234 | temp = Temperature() 235 | received_values = [] 236 | 237 | def handler_with_exception(value): 238 | """Handler with exception""" 239 | received_values.append(value) 240 | raise ValueError("Test exception") 241 | 242 | def normal_handler(value): 243 | """Normal handler""" 244 | received_values.append(value * 2) 245 | 246 | # Connect multiple handlers 247 | temp.celsius_changed.connect(handler_with_exception) 248 | temp.celsius_changed.connect(normal_handler) 249 | 250 | # Exception in handler shouldn't prevent property update 251 | temp.celsius = 25 252 | 253 | assert temp.celsius == 25 254 | assert 25 in received_values # First handler executed 255 | assert 50 in received_values # Second handler executed 256 | -------------------------------------------------------------------------------- /tests/unit/test_signal.py: -------------------------------------------------------------------------------- 1 | # tests/unit/test_signal.py 2 | 3 | # pylint: disable=unused-argument 4 | # pylint: disable=unused-variable 5 | # pylint: disable=too-many-locals 6 | 7 | """ 8 | Test cases for the signal pattern. 9 | """ 10 | 11 | import asyncio 12 | import logging 13 | import pytest 14 | from tsignal.core import ( 15 | t_with_signals, 16 | t_signal, 17 | t_slot, 18 | TSignal, 19 | TConnectionType, 20 | _determine_connection_type, 21 | ) 22 | from tsignal.contrib.patterns.worker.decorators import t_with_worker 23 | from ..conftest import Receiver 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_signal_creation(sender): 30 | """Test signal creation and initialization""" 31 | 32 | assert hasattr(sender, "value_changed") 33 | assert isinstance(sender.value_changed, TSignal) 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_signal_connection(sender, receiver): 38 | """Test signal connection""" 39 | 40 | sender.value_changed.connect(receiver, receiver.on_value_changed) 41 | assert len(sender.value_changed.connections) == 1 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_invalid_connection(sender, receiver): 46 | """Test invalid signal connections""" 47 | 48 | with pytest.raises(AttributeError): 49 | sender.value_changed.connect(None, receiver.on_value_changed) 50 | 51 | with pytest.raises(TypeError): 52 | sender.value_changed.connect(receiver, "not a callable") 53 | 54 | with pytest.raises(TypeError): 55 | non_existent_slot = getattr(receiver, "non_existent_slot", None) 56 | sender.value_changed.connect(receiver, non_existent_slot) 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_signal_disconnect_all(sender, receiver): 61 | """Test disconnecting all slots""" 62 | 63 | sender.value_changed.connect(receiver, receiver.on_value_changed) 64 | sender.value_changed.connect(receiver, receiver.on_value_changed_sync) 65 | 66 | assert len(sender.value_changed.connections) == 2 67 | 68 | # Disconnect all slots 69 | disconnected = sender.value_changed.disconnect() 70 | assert disconnected == 2 71 | assert len(sender.value_changed.connections) == 0 72 | 73 | # Emit should not trigger any slots 74 | sender.emit_value(42) 75 | assert receiver.received_value is None 76 | assert receiver.received_count == 0 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_signal_disconnect_specific_slot(sender, receiver): 81 | """Test disconnecting a specific slot""" 82 | 83 | sender.value_changed.connect(receiver, receiver.on_value_changed) 84 | sender.value_changed.connect(receiver, receiver.on_value_changed_sync) 85 | 86 | assert len(sender.value_changed.connections) == 2 87 | 88 | # Disconnect only the sync slot 89 | disconnected = sender.value_changed.disconnect(slot=receiver.on_value_changed_sync) 90 | assert disconnected == 1 91 | assert len(sender.value_changed.connections) == 1 92 | 93 | # Only async slot should remain 94 | remaining = sender.value_changed.connections[0] 95 | assert remaining.get_slot_to_call() == receiver.on_value_changed 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_signal_disconnect_specific_receiver(sender, receiver): 100 | """Test disconnecting a specific receiver""" 101 | 102 | # Create another receiver instance 103 | receiver2 = Receiver() 104 | 105 | sender.value_changed.connect(receiver, receiver.on_value_changed) 106 | sender.value_changed.connect(receiver2, receiver2.on_value_changed) 107 | 108 | assert len(sender.value_changed.connections) == 2 109 | 110 | # Disconnect receiver1 111 | disconnected = sender.value_changed.disconnect(receiver=receiver) 112 | assert disconnected == 1 113 | assert len(sender.value_changed.connections) == 1 114 | 115 | # Only receiver2 should get the signal 116 | sender.emit_value(42) 117 | await asyncio.sleep(0.1) 118 | assert receiver.received_value is None 119 | assert receiver2.received_value == 42 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_signal_disconnect_specific_receiver_and_slot(sender, receiver): 124 | """Test disconnecting a specific receiver-slot combination""" 125 | 126 | sender.value_changed.connect(receiver, receiver.on_value_changed) 127 | sender.value_changed.connect(receiver, receiver.on_value_changed_sync) 128 | 129 | assert len(sender.value_changed.connections) == 2 130 | 131 | # Disconnect specific receiver-slot combination 132 | disconnected = sender.value_changed.disconnect( 133 | receiver=receiver, slot=receiver.on_value_changed 134 | ) 135 | assert disconnected == 1 136 | assert len(sender.value_changed.connections) == 1 137 | 138 | # Only sync slot should remain 139 | conn = sender.value_changed.connections[0] 140 | assert conn.get_slot_to_call() == receiver.on_value_changed_sync 141 | 142 | 143 | @pytest.mark.asyncio 144 | async def test_signal_disconnect_nonexistent(sender, receiver): 145 | """Test disconnecting slots that don't exist""" 146 | 147 | sender.value_changed.connect(receiver, receiver.on_value_changed) 148 | 149 | # Try to disconnect nonexistent slot 150 | disconnected = sender.value_changed.disconnect( 151 | receiver=receiver, slot=receiver.on_value_changed_sync 152 | ) 153 | assert disconnected == 0 154 | assert len(sender.value_changed.connections) == 1 155 | 156 | # Try to disconnect nonexistent receiver 157 | other_receiver = Receiver() # Create another instance 158 | disconnected = sender.value_changed.disconnect(receiver=other_receiver) 159 | assert disconnected == 0 160 | assert len(sender.value_changed.connections) == 1 161 | 162 | 163 | @pytest.mark.asyncio 164 | async def test_signal_disconnect_during_emit(sender, receiver): 165 | """Test disconnecting slots while emission is in progress""" 166 | 167 | @t_with_signals 168 | class SlowReceiver: 169 | """Receiver class for slow slot""" 170 | 171 | def __init__(self): 172 | self.received_value = None 173 | 174 | @t_slot 175 | async def on_value_changed(self, value): 176 | """Slot for value changed""" 177 | await asyncio.sleep(0.1) 178 | self.received_value = value 179 | 180 | slow_receiver = SlowReceiver() 181 | sender.value_changed.connect(slow_receiver, slow_receiver.on_value_changed) 182 | sender.value_changed.connect(receiver, receiver.on_value_changed) 183 | 184 | # Disconnect first, then emit 185 | sender.value_changed.disconnect(receiver=receiver) 186 | sender.emit_value(42) # Changed emission order 187 | 188 | await asyncio.sleep(0.2) 189 | 190 | assert slow_receiver.received_value == 42 191 | assert receiver.received_value is None 192 | 193 | 194 | def test_direct_function_connection(sender): 195 | """Test direct connection of lambda and regular functions""" 196 | 197 | received_values = [] 198 | 199 | def collect_value(value): 200 | """Slot for value changed""" 201 | received_values.append(value) 202 | 203 | # Connect lambda function 204 | sender.value_changed.connect(lambda v: received_values.append(v * 2)) 205 | 206 | # Connect regular function 207 | sender.value_changed.connect(collect_value) 208 | 209 | # Emit signal 210 | sender.emit_value(42) 211 | 212 | assert 42 in received_values # Added by collect_value 213 | assert 84 in received_values # Added by lambda function (42 * 2) 214 | assert len(received_values) == 2 215 | 216 | 217 | @pytest.mark.asyncio 218 | async def test_direct_async_function_connection(sender): 219 | """Test direct connection of async functions""" 220 | 221 | received_values = [] 222 | 223 | async def async_collector(value): 224 | """Slot for value changed""" 225 | await asyncio.sleep(0.1) 226 | received_values.append(value) 227 | 228 | # Connect async function 229 | sender.value_changed.connect(async_collector) 230 | 231 | # Emit signal 232 | sender.emit_value(42) 233 | 234 | # Wait for async processing 235 | await asyncio.sleep(0.2) 236 | 237 | assert received_values == [42] 238 | 239 | 240 | @pytest.mark.asyncio 241 | async def test_direct_function_disconnect(sender): 242 | """Test disconnection of directly connected functions""" 243 | 244 | received_values = [] 245 | 246 | def collector(v): 247 | """Slot for value changed""" 248 | received_values.append(v) 249 | 250 | sender.value_changed.connect(collector) 251 | 252 | # First emit 253 | sender.emit_value(42) 254 | assert received_values == [42] 255 | 256 | # Disconnect 257 | disconnected = sender.value_changed.disconnect(slot=collector) 258 | assert disconnected == 1 259 | 260 | # Second emit - should not add value since connection is disconnected 261 | sender.emit_value(43) 262 | assert received_values == [42] 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_method_connection_with_signal_attributes(sender): 267 | """Test connecting a method with _tsignal_thread and _tsignal_loop attributes automatically sets up receiver""" 268 | 269 | received_values = [] 270 | 271 | @t_with_signals 272 | class SignalReceiver: 273 | """Receiver class for signal attributes""" 274 | 275 | def collect_value(self, value): 276 | """Slot for value changed""" 277 | 278 | received_values.append(value) 279 | 280 | class RegularClass: 281 | """Regular class for value changed""" 282 | 283 | def collect_value(self, value): 284 | """Slot for value changed""" 285 | 286 | received_values.append(value * 2) 287 | 288 | signal_receiver = SignalReceiver() 289 | signal = sender.value_changed 290 | regular_receiver = RegularClass() 291 | 292 | signal.connect(signal_receiver.collect_value) 293 | signal.connect(regular_receiver.collect_value) 294 | 295 | # The connection type of signal_receiver's method is DIRECT_CONNECTION 296 | # because it has the same thread affinity as the signal 297 | conn = signal.connections[-1] 298 | actual_type = _determine_connection_type( 299 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 300 | ) 301 | assert actual_type == TConnectionType.DIRECT_CONNECTION 302 | 303 | # The connection type of regular class's method is DIRECT_CONNECTION 304 | # because it has the same thread affinity as the signal 305 | conn = signal.connections[-1] 306 | actual_type = _determine_connection_type( 307 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 308 | ) 309 | assert actual_type == TConnectionType.DIRECT_CONNECTION 310 | 311 | signal.emit(42) 312 | 313 | assert 42 in received_values 314 | assert 84 in received_values 315 | 316 | 317 | @pytest.mark.asyncio 318 | async def test_connection_type_determination(): 319 | """Test connection type is correctly determined for different scenarios""" 320 | 321 | # Regular function should use DIRECT_CONNECTION 322 | def regular_handler(value): 323 | """Regular handler""" 324 | 325 | # Async function should use QUEUED_CONNECTION 326 | async def async_handler(value): 327 | """Async handler""" 328 | 329 | # Regular class with no thread/loop attributes 330 | class RegularClass: 331 | """Regular class""" 332 | 333 | def handler(self, value): 334 | """Handler""" 335 | 336 | # Regular class with thread/loop attributes 337 | @t_with_signals 338 | class RegularClassWithSignal: 339 | """Regular class with signal""" 340 | 341 | @t_signal 342 | def test_signal(self): 343 | """Signal""" 344 | 345 | # Class with thread/loop but not worker 346 | @t_with_signals 347 | class ThreadedClass: 348 | """Threaded class""" 349 | 350 | @t_slot 351 | def sync_handler(self, value): 352 | """Sync handler""" 353 | 354 | @t_slot 355 | async def async_handler(self, value): 356 | """Async handler""" 357 | 358 | # Worker class 359 | @t_with_worker 360 | class WorkerClass: 361 | """Worker class""" 362 | 363 | @t_slot 364 | def sync_handler(self, value): 365 | """Sync handler""" 366 | 367 | @t_slot 368 | async def async_handler(self, value): 369 | """Async handler""" 370 | 371 | regular_obj = RegularClass() 372 | regular_with_signal_obj = RegularClassWithSignal() 373 | threaded_obj = ThreadedClass() 374 | worker_obj = WorkerClass() 375 | 376 | signal = regular_with_signal_obj.test_signal 377 | signal.connect(regular_handler) 378 | 379 | # Test sync function connections 380 | conn = signal.connections[-1] 381 | actual_type = _determine_connection_type( 382 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 383 | ) 384 | assert actual_type == TConnectionType.DIRECT_CONNECTION 385 | 386 | # Test async function connections 387 | signal.connect(async_handler) 388 | conn = signal.connections[-1] 389 | actual_type = _determine_connection_type( 390 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 391 | ) 392 | assert actual_type == TConnectionType.QUEUED_CONNECTION 393 | 394 | # Test regular class method 395 | signal.connect(regular_obj.handler) 396 | conn = signal.connections[-1] 397 | actual_type = _determine_connection_type( 398 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 399 | ) 400 | assert actual_type == TConnectionType.DIRECT_CONNECTION 401 | 402 | # Test threaded class with sync method 403 | signal.connect(threaded_obj, threaded_obj.sync_handler) 404 | conn = signal.connections[-1] 405 | actual_type = _determine_connection_type( 406 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 407 | ) 408 | assert actual_type == TConnectionType.DIRECT_CONNECTION 409 | 410 | # Test threaded class with async method 411 | signal.connect(threaded_obj, threaded_obj.async_handler) 412 | conn = signal.connections[-1] 413 | actual_type = _determine_connection_type( 414 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 415 | ) 416 | assert actual_type == TConnectionType.QUEUED_CONNECTION 417 | 418 | # Test worker class with sync method 419 | signal.connect(worker_obj.sync_handler) 420 | conn = signal.connections[-1] 421 | actual_type = _determine_connection_type( 422 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 423 | ) 424 | assert actual_type == TConnectionType.QUEUED_CONNECTION 425 | 426 | # Test worker class with async method 427 | signal.connect(worker_obj.async_handler) 428 | conn = signal.connections[-1] 429 | actual_type = _determine_connection_type( 430 | conn.conn_type, conn.get_receiver(), signal.owner, conn.is_coro_slot 431 | ) 432 | assert actual_type == TConnectionType.QUEUED_CONNECTION 433 | 434 | 435 | async def test_one_shot(): 436 | """ 437 | Verifies that one_shot connections are triggered exactly once, 438 | then removed automatically upon the first call. 439 | """ 440 | 441 | @t_with_signals 442 | class OneShotSender: 443 | """ 444 | A class that sends one-shot events. 445 | """ 446 | 447 | @t_signal 448 | def one_shot_event(self, value): 449 | """ 450 | One-shot event signal. 451 | """ 452 | 453 | class OneShotReceiver: 454 | """ 455 | A class that receives one-shot events. 456 | """ 457 | 458 | def __init__(self): 459 | self.called_count = 0 460 | 461 | def on_event(self, value): 462 | """ 463 | Event handler. 464 | """ 465 | 466 | self.called_count += 1 467 | 468 | sender = OneShotSender() 469 | receiver = OneShotReceiver() 470 | 471 | sender.one_shot_event.connect(receiver, receiver.on_event, one_shot=True) 472 | 473 | sender.one_shot_event.emit(123) 474 | # Ensure all processing is complete 475 | await asyncio.sleep(1) 476 | 477 | # Already called once, so second emit should not trigger on_event 478 | sender.one_shot_event.emit(456) 479 | await asyncio.sleep(1) 480 | 481 | # Check if it was called only once 482 | assert ( 483 | receiver.called_count == 1 484 | ), "Receiver should only be called once for a one_shot connection" 485 | -------------------------------------------------------------------------------- /tests/unit/test_slot.py: -------------------------------------------------------------------------------- 1 | # tests/unit/test_slot.py 2 | 3 | # pylint: disable=duplicate-code 4 | # pylint: disable=no-member 5 | 6 | """ 7 | Test cases for the slot pattern. 8 | """ 9 | 10 | import asyncio 11 | import threading 12 | import time 13 | import logging 14 | import pytest 15 | from tsignal import t_with_signals, t_slot 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_sync_slot(sender, receiver): 22 | """Test synchronous slot execution""" 23 | 24 | sender.value_changed.connect(receiver, receiver.on_value_changed_sync) 25 | sender.emit_value(42) 26 | assert receiver.received_value == 42 27 | assert receiver.received_count == 1 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_directly_call_slot(receiver): 32 | """Test direct slot calls""" 33 | 34 | await receiver.on_value_changed(42) 35 | assert receiver.received_value == 42 36 | assert receiver.received_count == 1 37 | 38 | receiver.on_value_changed_sync(43) 39 | assert receiver.received_value == 43 40 | assert receiver.received_count == 2 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_slot_exception(sender, receiver): 45 | """Test exception handling in slots""" 46 | 47 | @t_with_signals 48 | class ExceptionReceiver: 49 | """Receiver class for exception testing""" 50 | 51 | @t_slot 52 | async def on_value_changed(self, value): 53 | """Slot for value changed""" 54 | raise ValueError("Test exception") 55 | 56 | exception_receiver = ExceptionReceiver() 57 | sender.value_changed.connect( 58 | exception_receiver, exception_receiver.on_value_changed 59 | ) 60 | sender.value_changed.connect(receiver, receiver.on_value_changed) 61 | 62 | sender.emit_value(42) 63 | await asyncio.sleep(0.1) 64 | assert receiver.received_value == 42 65 | assert receiver.received_count == 1 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_slot_thread_safety(): 70 | """Test slot direct calls from different threads""" 71 | 72 | @t_with_signals 73 | class ThreadTestReceiver: 74 | """Receiver class for thread safety testing""" 75 | 76 | def __init__(self): 77 | super().__init__() 78 | self.received_value = None 79 | self.received_count = 0 80 | self.execution_thread = None 81 | 82 | @t_slot 83 | async def async_slot(self, value): 84 | """Async slot for thread safety testing""" 85 | self.execution_thread = threading.current_thread() 86 | await asyncio.sleep(0.1) # work simulation with sleep 87 | self.received_value = value 88 | self.received_count += 1 89 | 90 | @t_slot 91 | def sync_slot(self, value): 92 | """Sync slot for thread safety testing""" 93 | self.execution_thread = threading.current_thread() 94 | time.sleep(0.1) # work simulation with sleep 95 | self.received_value = value 96 | self.received_count += 1 97 | 98 | receiver = ThreadTestReceiver() 99 | task_completed = threading.Event() 100 | main_thread = threading.current_thread() 101 | initial_values = {"value": None, "count": 0} # save initial values 102 | 103 | def background_task(): 104 | """Background task for thread safety testing""" 105 | try: 106 | # Before async_slot call 107 | initial_values["value"] = receiver.received_value 108 | initial_values["count"] = receiver.received_count 109 | 110 | coro = receiver.async_slot(42) 111 | future = asyncio.run_coroutine_threadsafe(coro, receiver._tsignal_loop) 112 | # Wait for async_slot result 113 | future.result() 114 | 115 | # verify state change 116 | assert receiver.received_value != initial_values["value"] 117 | assert receiver.received_count == initial_values["count"] + 1 118 | assert receiver.execution_thread == main_thread 119 | 120 | # Before sync_slot call 121 | initial_values["value"] = receiver.received_value 122 | initial_values["count"] = receiver.received_count 123 | 124 | receiver.sync_slot(43) 125 | 126 | # verify state change 127 | assert receiver.received_value != initial_values["value"] 128 | assert receiver.received_count == initial_values["count"] + 1 129 | assert receiver.execution_thread == main_thread 130 | 131 | finally: 132 | task_completed.set() 133 | 134 | async def run_test(): 135 | """Run test for thread safety""" 136 | thread = threading.Thread(target=background_task) 137 | thread.start() 138 | 139 | while not task_completed.is_set(): 140 | await asyncio.sleep(0.1) 141 | 142 | thread.join() 143 | 144 | # Cleanup 145 | pending = asyncio.all_tasks(receiver._tsignal_loop) 146 | if pending: 147 | logger.debug("Cleaning up %d pending tasks", len(pending)) 148 | for task in pending: 149 | if "test_slot_thread_safety" in str(task.get_coro()): 150 | logger.debug("Skipping test function task: %s", task) 151 | else: 152 | logger.debug("Found application task: %s", task) 153 | try: 154 | await asyncio.gather(task, return_exceptions=True) 155 | except Exception as e: 156 | logger.error("Error during cleanup: %s", e) 157 | else: 158 | logger.debug("No pending tasks to clean up") 159 | 160 | try: 161 | await run_test() 162 | except Exception as e: 163 | logger.error("Error in test: %s", e) 164 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # tests/unit/test_utils.py 2 | 3 | # pylint: disable=no-member 4 | # pylint: disable=unused-argument 5 | # pylint: disable=redefined-outer-name 6 | 7 | """Test utils""" 8 | 9 | from unittest.mock import MagicMock 10 | import logging 11 | import pytest 12 | from tsignal.utils import t_signal_log_and_raise_error 13 | 14 | 15 | @pytest.fixture 16 | def mock_logger(): 17 | """Mock logger""" 18 | 19 | return MagicMock(spec=logging.Logger) 20 | 21 | 22 | def test_valid_exception_class(mock_logger): 23 | """Test with valid exception class""" 24 | 25 | with pytest.raises(ValueError) as exc_info: 26 | t_signal_log_and_raise_error(mock_logger, ValueError, "test message") 27 | 28 | assert str(exc_info.value) == "test message" 29 | mock_logger.error.assert_called_once_with("test message", exc_info=True) 30 | mock_logger.warning.assert_not_called() 31 | 32 | 33 | def test_invalid_exception_class(mock_logger): 34 | """Test with invalid exception class""" 35 | 36 | with pytest.raises(TypeError) as exc_info: 37 | t_signal_log_and_raise_error(mock_logger, str, "test message") 38 | 39 | assert str(exc_info.value) == "exception_class must be a subclass of Exception" 40 | mock_logger.error.assert_not_called() 41 | mock_logger.warning.assert_not_called() 42 | 43 | 44 | def test_known_test_exception(mock_logger): 45 | """Test with known test exception""" 46 | 47 | with pytest.raises(ValueError) as exc_info: 48 | t_signal_log_and_raise_error( 49 | mock_logger, ValueError, "test message", known_test_exception=True 50 | ) 51 | 52 | assert str(exc_info.value) == "test message" 53 | mock_logger.warning.assert_called_once_with( 54 | "test message (Known test scenario, no full stack trace)" 55 | ) 56 | mock_logger.error.assert_not_called() 57 | 58 | 59 | def test_custom_exception(mock_logger): 60 | """Test with custom exception class""" 61 | 62 | class CustomError(Exception): 63 | """Custom error""" 64 | 65 | with pytest.raises(CustomError) as exc_info: 66 | t_signal_log_and_raise_error(mock_logger, CustomError, "test message") 67 | 68 | assert str(exc_info.value) == "test message" 69 | mock_logger.error.assert_called_once_with("test message", exc_info=True) 70 | mock_logger.warning.assert_not_called() 71 | 72 | 73 | def test_with_actual_logger(caplog): 74 | """Test with actual logger to verify log output""" 75 | 76 | logger = logging.getLogger("test") 77 | 78 | with pytest.raises(ValueError), caplog.at_level(logging.ERROR): 79 | t_signal_log_and_raise_error(logger, ValueError, "test actual logger") 80 | 81 | assert "test actual logger" in caplog.text 82 | -------------------------------------------------------------------------------- /tests/unit/test_weak.py: -------------------------------------------------------------------------------- 1 | # tests/unit/test_weak.py 2 | 3 | """ 4 | Test cases for weak reference connections. 5 | """ 6 | 7 | # pylint: disable=unused-argument 8 | # pylint: disable=redundant-unittest-assert 9 | 10 | import unittest 11 | import gc 12 | import asyncio 13 | import weakref 14 | from tsignal.core import t_with_signals, t_signal 15 | 16 | 17 | class WeakRefReceiver: 18 | """ 19 | A class that receives weak reference events. 20 | """ 21 | 22 | def __init__(self): 23 | self.called = False 24 | 25 | def on_signal(self, value): 26 | """ 27 | Event handler. 28 | """ 29 | 30 | self.called = True 31 | print(f"WeakRefReceiver got value: {value}") 32 | 33 | 34 | @t_with_signals(weak_default=True) 35 | class WeakRefSender: 36 | """ 37 | A class that sends weak reference events. 38 | """ 39 | 40 | @t_signal 41 | def event(self): 42 | """ 43 | Event signal. 44 | """ 45 | 46 | 47 | class StrongRefReceiver: 48 | """ 49 | A class that receives strong reference events. 50 | """ 51 | 52 | def __init__(self): 53 | """ 54 | Initialize the receiver. 55 | """ 56 | 57 | self.called = False 58 | 59 | def on_signal(self, value): 60 | """ 61 | Event handler. 62 | """ 63 | 64 | self.called = True 65 | print(f"StrongRefReceiver got value: {value}") 66 | 67 | 68 | @t_with_signals(weak_default=True) 69 | class MixedSender: 70 | """ 71 | A class that sends mixed reference events. 72 | """ 73 | 74 | @t_signal 75 | def event(self, value): 76 | """ 77 | Event signal. 78 | """ 79 | 80 | 81 | class TestWeakRefConnections(unittest.IsolatedAsyncioTestCase): 82 | """ 83 | Test cases for weak reference connections. 84 | """ 85 | 86 | async def test_weak_default_connection(self): 87 | """ 88 | Test weak default connection. 89 | """ 90 | 91 | sender = WeakRefSender() 92 | receiver = WeakRefReceiver() 93 | 94 | # connect without specifying weak, should use weak_default=True 95 | sender.event.connect(receiver, receiver.on_signal) 96 | 97 | sender.event.emit(42) 98 | self.assertTrue(receiver.called, "Receiver should be called when alive") 99 | 100 | # Delete receiver and force GC 101 | del receiver 102 | gc.collect() 103 | 104 | # After GC, the connection should be removed automatically 105 | # Emit again and ensure no error and no print from receiver 106 | sender.event.emit(100) 107 | # If receiver was alive or connection remained, it would print or set called to True 108 | # But we no longer have access to receiver here 109 | # Just ensure no exception - implicit check 110 | self.assertTrue( 111 | True, "No exception emitted, weak ref disconnected automatically" 112 | ) 113 | 114 | async def test_override_weak_false(self): 115 | """ 116 | Test override weak=False. 117 | """ 118 | 119 | sender = MixedSender() 120 | receiver = StrongRefReceiver() 121 | 122 | # Even though weak_default=True, we explicitly set weak=False 123 | sender.event.connect(receiver, receiver.on_signal, weak=False) 124 | 125 | sender.event.emit(10) 126 | self.assertTrue(receiver.called, "Receiver called with strong ref") 127 | 128 | # Reset called 129 | receiver.called = False 130 | 131 | # Delete receiver and force GC 132 | receiver_ref = weakref.ref(receiver) 133 | del receiver 134 | gc.collect() 135 | 136 | # Check if receiver is GCed 137 | # Originally: self.assertIsNone(receiver_ref(), "Receiver should be GCed") 138 | # Update the expectation: Since weak=False means strong ref remains, receiver won't GC. 139 | self.assertIsNotNone( 140 | receiver_ref(), "Receiver should NOT be GCed due to strong ref" 141 | ) 142 | 143 | # Emit again, should still have a reference 144 | sender.event.emit(200) 145 | # Even if we can't call receiver (it was del), the reference in slot keeps it alive, 146 | # but possibly as an inaccessible object. 147 | # Just checking no exception raised and that receiver_ref is not None. 148 | # This confirms the slot strong reference scenario. 149 | self.assertTrue(True, "No exception raised, strong ref scenario is consistent") 150 | 151 | async def test_explicit_weak_true(self): 152 | """ 153 | Test explicit weak=True. 154 | """ 155 | 156 | sender = MixedSender() 157 | receiver = StrongRefReceiver() 158 | 159 | # weak_default=True anyway, but let's be explicit 160 | sender.event.connect(receiver, receiver.on_signal, weak=True) 161 | 162 | sender.event.emit(20) 163 | self.assertTrue(receiver.called, "Explicit weak=True call") 164 | 165 | receiver.called = False 166 | 167 | # Create a weak reference to the receiver 168 | receiver_ref = weakref.ref(receiver) 169 | self.assertIsNotNone(receiver_ref(), "Receiver should be alive before deletion") 170 | 171 | # Delete strong reference and force GC 172 | del receiver 173 | gc.collect() 174 | 175 | # Check if the receiver has been collected 176 | self.assertIsNone( 177 | receiver_ref(), "Receiver should be GCed after weakref disconnection" 178 | ) 179 | 180 | # Receiver gone, emit again 181 | # Should not call anything, no crash 182 | sender.event.emit(30) 183 | self.assertTrue(True, "No exception and no call because weak disconnect") 184 | 185 | 186 | if __name__ == "__main__": 187 | asyncio.run(unittest.main()) 188 | --------------------------------------------------------------------------------