diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b6c8bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + PYTHONDONTWRITEBYTECODE: "1" + PYTHONUNBUFFERED: "1" + +jobs: + ruff-format: + name: Check Code Formatting (Ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install ruff>=0.8.0 + + - name: Check formatting + run: ruff format --check --diff pysdl/ examples/ tests/ + + ruff-lint: + name: Lint Code (Ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install ruff>=0.8.0 + + - name: Run linter + run: ruff check pysdl/ examples/ tests/ + + mypy-type-check: + name: Type Check (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install mypy==1.13.0 + + - name: Run type checker + run: mypy pysdl/ examples/ --ignore-missing-imports + + pytest: + name: Run Tests (pytest) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + pytest --cov=pysdl --cov-report=term --cov-report=xml --cov-report=html --tb=short -v + + - name: Upload coverage reports to Codecov + if: matrix.python-version == '3.13' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage artifacts + if: matrix.python-version == '3.13' + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: | + htmlcov/ + coverage.xml + retention-days: 30 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..066bcf6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,81 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: # Allow manual triggering + +permissions: + contents: read + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish to PyPI + if: github.event_name == 'release' && github.event.action == 'published' + needs: [build] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pysdl + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + publish-to-testpypi: + name: Publish to TestPyPI + if: github.event_name == 'workflow_dispatch' + needs: [build] + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/pysdl + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/README.md b/README.md index dcd64d6..91e2323 100644 --- a/README.md +++ b/README.md @@ -178,11 +178,11 @@ async_sdl_python/ - **Python**: 3.9 or higher (uses type hints and async features) - **Dependencies**: None (uses only Python standard library) - **Development Dependencies**: - - pytest >= 7.0 - - pytest-asyncio >= 0.21 - - pytest-cov >= 4.0 - - mypy >= 1.0 (for type checking) - - pylint >= 2.0 (for linting) + - pytest >= 8.0 + - pytest-asyncio >= 0.23 + - pytest-cov >= 4.1 + - mypy >= 1.13 (for type checking) + - ruff >= 0.8 (for linting) ## Testing @@ -199,7 +199,7 @@ pytest --cov=pysdl --cov-report=html mypy pysdl/ # Run linting -pylint pysdl/ +ruff check pysdl/ ``` ## Contributing @@ -220,7 +220,7 @@ Contributions are welcome! Please: - **Type Safety**: All code must have comprehensive type hints - **Testing**: Maintain >90% test coverage - **Documentation**: Update docs for API changes -- **Code Style**: Follow PEP 8, use black formatter +- **Code Style**: Follow PEP 8, use ruff formatter - **Async**: Use async/await properly, avoid blocking calls - **Logging**: Use SdlLogger for framework events @@ -248,8 +248,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail PySDL v1.0.0 introduces breaking changes to support instance-based systems. This enables running multiple independent SDL systems in the same process and eliminates global state. -**See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for detailed migration instructions from v0.0.1 to v1.0.0.** - Key changes: - `SdlSystem` is now instance-based - create instances with `system = SdlSystem()` - `SdlProcess.create()` and `__init__()` now require `system` parameter @@ -269,7 +267,7 @@ Future enhancements under consideration: ## Version History -- **1.0.0** (2025-10-26) - Instance-based system **[BREAKING CHANGES]** +- **1.0.0** (2024-10-26) - Instance-based system **[BREAKING CHANGES]** - Refactored `SdlSystem` from static class to instance-based - Added `system` parameter to `SdlProcess` creation and initialization - Processes now reference their system instance via `self._system` @@ -277,7 +275,6 @@ Future enhancements under consideration: - Eliminates global state for better testability - All tests updated (242 tests, 83% coverage) - All examples updated to use instance-based API - - See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for upgrade path - **0.0.1** - Initial release - Core actor model implementation diff --git a/docs/api_reference.md b/docs/api_reference.md index 90cad64..51840de 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -79,6 +79,9 @@ Register a process with the system. **Returns:** - `True` if registered successfully, `False` if process is None or already registered +**Raises:** +- `ValidationError`: If process is None or has invalid PID + **Example:** ```python # Usually called automatically by Process.create() @@ -97,7 +100,10 @@ Unregister a process and clean up its resources. - `process`: Process instance to unregister **Returns:** -- `True` if unregistered successfully, `False` if process is None +- `True` (always) + +**Raises:** +- `ValidationError`: If process is None or has invalid PID **Side Effects:** - Removes process from `proc_map` @@ -119,6 +125,10 @@ Add a signal to the system queue. **Parameters:** - `signal`: Signal to enqueue +**Raises:** +- `ValidationError`: If signal is None or invalid +- `QueueError`: If queue operation fails + **Example:** ```python # Usually called by process.input() @@ -137,6 +147,10 @@ Route a signal to its destination process. **Returns:** - `True` if delivered successfully, `False` otherwise +**Raises:** +- `ValidationError`: If signal is None or has no destination +- `SignalDeliveryError`: If signal delivery to destination fails + **Side Effects:** - Adds destination process to `ready_list` - Enqueues signal to process inbox @@ -159,6 +173,10 @@ Start a timer for a process. **Parameters:** - `timer`: Timer to start +**Raises:** +- `ValidationError`: If timer is None +- `TimerError`: If timer has no source PID + **Side Effects:** - Stops timer if already running (prevents duplicates) - Adds timer to `timer_map[pid]` @@ -184,6 +202,9 @@ Stop a specific timer. **Returns:** - `True` if timer was found and stopped, `False` otherwise +**Raises:** +- `ValidationError`: If timer is None + **Side Effects:** - Removes timer from `timer_map[pid]` - Deletes PID entry if no timers remain @@ -201,7 +222,7 @@ system.stopTimer(timer) Run the main event loop. **Returns:** -- Never returns normally (runs until stopped) +- `True` when stopped normally **Behavior:** - Processes signals from queue @@ -1018,7 +1039,7 @@ Find a handler for a state/event combination using priority-based wildcard match handler = fsm.find(state_running, WorkSignal.id()) # Star state match (Priority 2) -handler = fsm.find(state_any, EmergencySignal.id()) # If star handler registered +handler = fsm.find(star, EmergencySignal.id()) # If star handler registered # Star signal match (Priority 3) handler = fsm.find(state_init, unknown_signal.id()) # If star signal handler registered diff --git a/docs/architecture.md b/docs/architecture.md index ce241d2..1a417ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -188,7 +188,7 @@ def _init_state_machine(self): ### Handler Naming Convention -By convention, handlers are named: `{state}_{signal}`: +By convention (not enforced), handlers are often named: `{state}_{signal}`: ```python async def start_StartSignal(self, signal): @@ -200,6 +200,8 @@ async def wait_timeout_TimeoutTimer(self, signal): ... ``` +**Note:** This naming convention is a recommendation for code clarity and consistency, but the framework does not enforce it. You can use any valid Python method name for your handlers. + ### State Transitions State transitions occur via `await self.next_state(new_state)`: @@ -393,27 +395,30 @@ The `SdlSystem.run()` method implements the main event loop: ```python async def run(): while True: - # 1. Get next signal from queue + signal = None + # 1. Get next signal with timeout try: - signal = get_next_signal() - - # 2. Route signal to process + signal = await asyncio.wait_for( + get_next_signal(), timeout=0.01 + ) + except asyncio.TimeoutError: + pass # No signal available + + if signal is not None: + # 2. Route and execute handler process = lookup_proc_map(signal.dst()) handler = process.lookup_transition(signal) - - # 3. Execute handler await handler(signal) - except Empty: - pass - # 4. Check timer expiries + # 3. Check timer expiries await expire(current_time_ms()) - # 5. Check for system stop + # 4. Check for system stop if _stop: loop.stop() + return True - # 6. Yield to other tasks + # 5. Yield to other tasks await asyncio.sleep(0) ``` diff --git a/docs/examples.md b/docs/examples.md index 7118c07..004569f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -22,7 +22,7 @@ A classic example demonstrating two processes sending signals back and forth. ```python import asyncio -from typing import Optional +from typing import Optional, Any from pysdl import ( SdlProcess, SdlSignal, SdlState, SdlSystem, SdlStartSignal, SdlStoppingSignal, SdlLogger @@ -50,9 +50,9 @@ class PingPongProcess(SdlProcess): _count: int = 0 - def __init__(self, parent_pid: Optional[str], peer_pid: Optional[str] = None, system=None): - super().__init__(parent_pid, system=system) - self.peer_pid = peer_pid + def __init__(self, parent_pid: Optional[str], config_data: Optional[Any] = None, system=None): + super().__init__(parent_pid, config_data, system=system) + self.peer_pid = config_data.get("peer_pid") if config_data else None async def start_StartTransition(self, signal: SdlSignal) -> None: """If peer exists, send ping and wait for pong, otherwise wait for ping.""" @@ -107,7 +107,7 @@ async def main(): # Create processes with system p1 = await PingPongProcess.create(None, None, system=system) - p2 = await PingPongProcess.create(None, p1.pid(), system=system) + p2 = await PingPongProcess.create(None, {"peer_pid": p1.pid()}, system=system) # Run the system await system.run() @@ -394,7 +394,7 @@ class SupervisorProcess(SdlProcess): state_managing = SdlState("managing") - def __init__(self, parent_pid: Optional[str], config_data: Optional[Any], system=None): + def __init__(self, parent_pid: Optional[str], config_data: Optional[Any] = None, system=None): super().__init__(parent_pid, config_data, system=system) self.children = SdlChildrenManager() self.work_items = 10 # Total work to distribute @@ -545,7 +545,7 @@ class ClientProcess(SdlProcess): state_waiting = SdlState("waiting") - def __init__(self, parent_pid: Optional[str], config_data: Optional[Any], system=None): + def __init__(self, parent_pid: Optional[str], config_data: Optional[Any] = None, system=None): super().__init__(parent_pid, config_data, system=system) self.client_id = config_data @@ -651,7 +651,7 @@ class ServerProcess(SdlProcess): state_idle = SdlState("idle") state_processing = SdlState("processing") - def __init__(self, parent_pid: Optional[str], config_data: Optional[Any], system=None): + def __init__(self, parent_pid: Optional[str], config_data: Optional[Any] = None, system=None): super().__init__(parent_pid, config_data, system=system) self.current_request = None @@ -710,9 +710,9 @@ class ClientProcess(SdlProcess): state_waiting = SdlState("waiting") - def __init__(self, parent_pid: Optional[str], server_pid: str, system=None): - super().__init__(parent_pid, system=system) - self.server_pid = server_pid + def __init__(self, parent_pid: Optional[str], config_data: Optional[Any] = None, system=None): + super().__init__(parent_pid, config_data, system=system) + self.server_pid = config_data.get("server_pid") if config_data else None self.request_id = random.randint(1000, 9999) async def start_handler(self, signal): @@ -758,9 +758,9 @@ async def main(): server = await ServerProcess.create(None, None, system=system) # Create multiple clients - await ClientProcess.create(None, server.pid(), system=system) - await ClientProcess.create(None, server.pid(), system=system) - await ClientProcess.create(None, server.pid(), system=system) + await ClientProcess.create(None, {"server_pid": server.pid()}, system=system) + await ClientProcess.create(None, {"server_pid": server.pid()}, system=system) + await ClientProcess.create(None, {"server_pid": server.pid()}, system=system) # Run for 10 seconds await asyncio.sleep(10) diff --git a/docs/getting_started.md b/docs/getting_started.md index c2ba9b1..dd1a08b 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -93,8 +93,8 @@ class HelloProcess(SdlProcess): # call start_handler self._event(start, SdlStartSignal, self.start_handler) - # IMPORTANT: Always call _done() at the end of _init_state_machine() - # This finalizes the state machine and prevents undefined behavior + # Call _done() at the end of _init_state_machine() for clarity (recommended) + # This marks completion of the state machine setup self._done() async def start_handler(self, signal): @@ -178,7 +178,7 @@ class GreeterProcess(SdlProcess): async def start_handler(self, signal): """Send greeting to friend.""" - greeting = GreetingSignal.create(data="Hello, friend!") + greeting = GreetingSignal.create("Hello, friend!") await self.output(greeting, self.friend_pid) SdlLogger.info(f"{self.pid()} sent greeting") @@ -207,7 +207,7 @@ class ReceiverProcess(SdlProcess): SdlLogger.info(f"{self.pid()} received: {signal.data}") # Reply back - reply = GreetingSignal.create(data="Hi there!") + reply = GreetingSignal.create("Hi there!") await self.output(reply, signal.src()) # Stop after replying @@ -698,7 +698,7 @@ class ClientProcess(SdlProcess): self.server_pid = config_data.get("server_pid") if config_data else None async def start_handler(self, signal): - request = RequestSignal.create(data={"request_id": 123}) + request = RequestSignal.create({"request_id": 123}) await self.output(request, self.server_pid) await self.next_state(self.state_waiting) diff --git a/docs/logging_configuration.md b/docs/logging_configuration.md index f8a2d09..e15a2e3 100644 --- a/docs/logging_configuration.md +++ b/docs/logging_configuration.md @@ -70,7 +70,7 @@ PySDL supports standard Python logging levels: | INFO | 20 | General informational messages | Production monitoring | | WARNING | 30 | Warning messages for unusual situations | Production monitoring | | ERROR | 40 | Error messages for failures | Production monitoring | -| CRITICAL | 50 | Critical failures | Production (effectively disables logging) | +| CRITICAL | 50 | Critical failures | Disables all framework logging (no built-in methods log at this level) | ### Setting Log Level @@ -171,11 +171,17 @@ For expensive operations, check before logging: ```python from pysdl.logger import SdlLogger, LogCategory -# Check if category is enabled +# Use is_enabled() to avoid expensive operations before logging if SdlLogger.is_enabled(LogCategory.SIGNALS): - # Perform expensive formatting only if needed - expensive_data = format_complex_signal_data() - SdlLogger.debug(expensive_data) + # Perform expensive formatting only if logging is enabled + formatted_data = format_complex_signal_data() + +# Note: Specialized methods like signal() already check categories internally +SdlLogger.signal("SdlSig", signal, process) + +# For general logging, just call the methods directly +SdlLogger.debug("Simple debug message") +SdlLogger.info("System started") ``` ## Performance Considerations @@ -363,20 +369,21 @@ For code that runs frequently, check if logging is enabled to avoid overhead: ```python from pysdl.logger import SdlLogger, LogCategory -# Pattern 1: Check once outside loop (best performance) +# Pattern 1: Optimize when you have expensive PRE-PROCESSING def process_many_signals(signals): signals_enabled = SdlLogger.is_enabled(LogCategory.SIGNALS) for signal in signals: if signals_enabled: - SdlLogger.signal("SdlSig", signal, process) - # Process signal... + # Expensive operation you want to skip if logging is disabled + detailed_context = gather_expensive_context_data(signal) + # signal() already checks internally - this is just for optimization + SdlLogger.signal("SdlSig", signal, process) -# Pattern 2: Check per iteration (use if logging status might change) -def process_many_signals_dynamic(signals): +# Pattern 2: Most common case - just call the method directly +def process_many_signals_simple(signals): for signal in signals: - if SdlLogger.is_enabled(LogCategory.SIGNALS): - SdlLogger.signal("SdlSig", signal, process) - # Process signal... + # signal() checks internally - no manual check needed + SdlLogger.signal("SdlSig", signal, process) ``` ### 6. Reset Between Tests diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e5f1de2..9d230a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -53,21 +53,19 @@ async def debugging_handler(self, signal): SdlLogger.info(f"Current state: {self.current_state().name()}") ``` -#### 2. Calling `_done()` - Optional but Recommended +#### 2. Always Call `_done()` -```python -# Both are valid, but _done() is recommended for consistency -def _init_state_machine(self): - self._event(start, SdlStartSignal, self.start_handler) - self._done() # Recommended: marks completion of FSM setup +The `_done()` method should always be called at the end of `_init_state_machine()` to mark completion of the state machine setup. While not strictly enforced, it's a best practice for code clarity and consistency. -# This also works (as shown in examples/main.py) +```python +# RECOMMENDED PATTERN def _init_state_machine(self): self._event(start, SdlStartSignal, self.start_handler) - # No _done() needed, but less clear + self._event(self.state_idle, MySignal, self.idle_handler) + self._done() # Always call _done() to finalize FSM ``` -**Note:** While `_done()` is not strictly required, it's recommended for code clarity and consistency. The framework will work correctly either way. +**Note:** All documentation examples consistently use `_done()` and it should be considered part of the standard pattern. #### 3. Wrong signal type