Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# include *.md
include fspin_cheatsheet.md
1 change: 1 addition & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This directory contains simple usage examples for **fspin**. Each example demons
| `async_loop_context.py`| Use the `spin` context manager with async functions, showing auto-detection of coroutines and both blocking and non-blocking patterns. |
| `loop_in_place.py` | Use context manager `with spin(...):`. |
| `dynamic_frequency.py` | Change the loop frequency at runtime. |
| `lambda_condition.py` | Use a lambda function as a condition to stop the loop. |

Run any example with `python <file>` to see the behaviour.

Expand Down
51 changes: 51 additions & 0 deletions example/lambda_condition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import time
import sys
import os

# Add the project root to sys.path so we can import fspin
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

from fspin import rate

def lambda_sync_example():
print("--- Synchronous Lambda Condition Example ---")
counter = 0

def work():
nonlocal counter
counter += 1
print(f"Iteration {counter}")
time.sleep(0.1)

# Initialize rate control at 10 Hz
rc = rate(freq=10, is_coroutine=False)

# Use a lambda function as the condition
# The loop will continue as long as the counter is less than 5
rc.spin_sync(work, condition_fn=lambda: counter < 5)

print(f"Loop finished after {counter} iterations.")

async def lambda_async_example():
print("\n--- Asynchronous Lambda Condition Example ---")
counter = 0

async def work():
nonlocal counter
counter += 1
print(f"Iteration {counter}")
await asyncio.sleep(0.1)

# Initialize rate control at 10 Hz
rc = rate(freq=10, is_coroutine=True)

# Use a lambda function as the condition
# The loop will continue as long as the counter is less than 5
await rc.spin_async(work, condition_fn=lambda: counter < 5)

print(f"Loop finished after {counter} iterations.")

if __name__ == "__main__":
import asyncio
lambda_sync_example()
asyncio.run(lambda_async_example())
38 changes: 34 additions & 4 deletions fspin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
"""
fspin: A utility for running Python functions at a fixed rate.

The fspin library provides tools to execute functions or coroutines repeatedly
at a consistent frequency, supporting both synchronous and asynchronous workflows.

Main Features:
- @spin decorator for easy loop creation.
- spin context manager for scoped background loops.
- Automatic detection of sync vs async functions.
- Support for lambda functions as stop conditions (Preferred).
- High-precision rate control with deviation compensation.

Quick Start:
from fspin import spin
import time

counter = 0
# Preferred usage: use a lambda for the condition
@spin(freq=10, condition_fn=lambda: counter < 5)
def my_loop():
nonlocal counter
counter += 1
print(f"Iteration {counter}")

my_loop() # Blocks until counter reaches 5

For detailed documentation and best practices, run 'python -m fspin'
to view the full cheatsheet.
"""
from .rate_control import RateControl as rate
from .decorators import spin as spin_decorator # Original decorator
from .spin_context import spin as spin_context_manager # New context manager
from .loop_context import loop # Keep for backward compatibility
from .unified import spin # Unified entry point that intelligently selects between decorator and context manager
from .decorators import spin as spin_decorator
from .spin_context import spin as spin_context_manager
from .loop_context import loop
from .unified import spin
24 changes: 24 additions & 0 deletions fspin/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import os
import sys

def main():
# Get the directory where this file is located
current_dir = os.path.dirname(os.path.abspath(__file__))

# Path to the cheatsheet (it should be bundled with the package)
# We look for it in the package directory or the project root
cheatsheet_path = os.path.join(current_dir, "fspin_cheatsheet.md")

# Fallback for development environment if not found in package dir
if not os.path.exists(cheatsheet_path):
cheatsheet_path = os.path.join(current_dir, "..", "fspin_cheatsheet.md")
Comment on lines +10 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Package cheatsheet so python -m fspin can find it

This lookup assumes fspin_cheatsheet.md is bundled alongside the installed package, but setup.py doesn’t set include_package_data/package_data, so wheels built via pip won’t contain that file even with MANIFEST.in. In that common install path, python -m fspin will always fall through to “Cheatsheet not found,” so the advertised feature won’t work. Consider shipping the file as package data (or moving it into fspin/ and loading via importlib.resources).

Useful? React with 👍 / 👎.


if os.path.exists(cheatsheet_path):
with open(cheatsheet_path, "r", encoding="utf-8") as f:
print(f.read())
else:
print("fspin Cheatsheet not found.")
print("Please check the online documentation at https://github.com/Suke0811/fspin")

if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion fspin/rate_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,13 +484,15 @@ def get_report(self, output=True):
Returns:
dict: Performance statistics as a dictionary.
"""
if not self.report or not self.iteration_times:
if not self.report or (not self.iteration_times and self.initial_duration is None):
self.logger.output("No iterations were recorded.")
return {}

end_time = self.end_time or time.perf_counter()
total_duration = end_time - self.start_time
total_iterations = len(self.iteration_times)
if self.initial_duration is not None:
total_iterations += 1
avg_function_duration = mean(self.iteration_times) if self.iteration_times else 0
avg_deviation = mean(self.deviations) if self.deviations else 0
max_deviation = max(self.deviations) if self.deviations else 0
Expand Down
21 changes: 15 additions & 6 deletions fspin/spin_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,27 @@ class spin:
RateControl: The RateControl instance managing the spinning.

Example:
>>> counter = 0
>>> def heartbeat():
... nonlocal counter
... counter += 1
... print("Beat")
>>> with spin(heartbeat, freq=5, report=True) as sp:
... time.sleep(1) # Let it run for 1 second
>>> # Automatically stops spinning when exiting the context
>>> # Preferred usage with lambda condition
>>> with spin(heartbeat, freq=5, condition_fn=lambda: counter < 5) as sp:
... while sp.is_running():
... time.sleep(0.1)
>>> # Automatically stops spinning when counter reaching 5 or exiting the context

>>> count = 0
>>> async def async_heartbeat():
... nonlocal count
... count += 1
... print("Async Beat")
... await asyncio.sleep(0)
>>> async with spin(async_heartbeat, freq=5, report=True) as sp:
... await asyncio.sleep(1) # Let it run for 1 second
>>> # Automatically stops spinning when exiting the context
>>> # Preferred usage with lambda condition
>>> async with spin(async_heartbeat, freq=5, condition_fn=lambda: count < 5) as sp:
... await asyncio.sleep(1) # Let it run
>>> # Automatically stops spinning when count reaching 5 or exiting the context

>>> async def background_task():
... print("Running in the background")
Expand Down
41 changes: 29 additions & 12 deletions fspin/unified.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
import inspect
import inspect
import asyncio
import os
from functools import wraps
from .rate_control import RateControl
from .decorators import spin as spin_decorator
from .spin_context import spin as spin_context_manager

class UnifiedSpin:
"""
Unified entry point for fspin that intelligently selects between decorator,
context manager, or direct class usage based on how it's called.

Unified entry point for fspin.

Acts as a decorator, context manager, or functional interface
based on how it is called.

Usage:
# As a decorator
@spin(freq=10)
def my_function():
pass
@spin(freq=10, condition_fn=lambda: True)
def my_func(): ...

with spin(my_func, freq=10):
...
"""

# As a context manager
with spin(my_function, freq=10):
# Code to run while function is spinning
def __init__(self):
# Dynamically load the cheatsheet into the docstring if available
try:
# Look for fspin_cheatsheet.md in the package directory or one level up
base_dir = os.path.dirname(os.path.dirname(__file__))
cheatsheet_path = os.path.join(base_dir, "fspin_cheatsheet.md")
if not os.path.exists(cheatsheet_path):
# Try sibling directory (if installed)
base_dir = os.path.dirname(__file__)
cheatsheet_path = os.path.join(base_dir, "fspin_cheatsheet.md")

if os.path.exists(cheatsheet_path):
with open(cheatsheet_path, "r", encoding="utf-8") as f:
cheatsheet_content = f.read()
self.__class__.__doc__ = (self.__class__.__doc__ or "") + "\n\n" + cheatsheet_content
except Exception:
pass
"""

def __call__(self, *args, **kwargs):
# Determine how spin is being called
Expand Down
67 changes: 34 additions & 33 deletions fspin_cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ fspin is a Python library for running functions at a specified frequency (rate c

- Run functions at a specified frequency (Hz)
- Support for both synchronous and asynchronous functions
- Lambda support for condition functions
- Automatic detection of function type (sync vs async)
- Multiple execution modes: blocking, threaded, and fire-and-forget
- Performance reporting and statistics
Expand Down Expand Up @@ -50,30 +51,28 @@ async def my_async_function_non_blocking():

### 2. `spin` Context Manager

The context manager provides a way to run a function at a specified frequency within a specific scope.
The context manager provides a way to run a function at a specified frequency within a specific scope. Using lambdas for the condition function is preferred for simple logic.

```python
# For synchronous functions (threaded, fire-and-forget)
with spin(my_function, freq=10, report=True, thread=True, wait=False) as rc:
# Function runs in a background thread at 10Hz while inside the context
time.sleep(1) # Let it run for 1 second
# Function stops when exiting the context
# Preferred usage with lambda condition
count = 0
with spin(my_function, freq=10, condition_fn=lambda: count < 5) as rc:
while rc.is_running():
count += 1
time.sleep(0.1)

# For synchronous functions (threaded, blocking)
# When wait=True, entering the with-body is delayed until the loop finishes.
with spin(my_function, freq=10, report=True, thread=True, wait=True) as rc:
with spin(my_function, freq=10, thread=True, wait=True, condition_fn=lambda: count < 10) as rc:
# By the time we get here, the loop has already completed.
pass

# For asynchronous functions (always runs in background while inside the context)
async with spin(my_async_function, freq=5, report=True) as rc:
async with spin(my_async_function, freq=5, condition_fn=lambda: count < 15) as rc:
# Function runs in the background at 5Hz
await asyncio.sleep(1) # Let it run for 1 second
# Function stops when exiting the context

# Pass positional and keyword arguments to the worker each iteration
with spin(my_function_with_args, 20, "reading", unit="°C"):
time.sleep(0.5)
# Function stops when exiting the context or when condition becomes False
```

### 3. `rate` / `RateControl` Class
Expand Down Expand Up @@ -197,44 +196,39 @@ time.sleep(5) # Let it run for 5 seconds
rc.stop_spinning() # Stop the function
```

### 2. Run a function until a condition is met
### 2. Run a function until a condition is met (Prefer Lambdas)

```python
counter = {'count': 0}

def condition():
return counter['count'] < 5 # Stop after 5 iterations
counter = 0

@spin(freq=2, condition_fn=condition, report=True)
# Using a lambda function as a condition (Preferred)
@spin(freq=2, condition_fn=lambda: counter < 5, report=True)
def limited_loop():
counter['count'] += 1
print(f"Iteration {counter['count']}")
nonlocal counter
counter += 1
print(f"Iteration {counter}")

rc = limited_loop() # Runs for 5 iterations then stops
# Report is generated automatically when report=True
```

Async workflows can use coroutine predicates as well:
Async workflows can use lambda predicates as well:

```python
import asyncio
from fspin import spin

state = {"events": [], "checks": 0}

async def async_condition():
state["checks"] += 1
await asyncio.sleep(0)
return state["checks"] < 4
count = 0

@spin(freq=50, condition_fn=async_condition, wait=True)
@spin(freq=50, condition_fn=lambda: count < 4, wait=True)
async def async_limited_loop():
state["events"].append("tick")
nonlocal count
count += 1
print(f"Async tick {count}")

async def main():
rc = await async_limited_loop() # Stops once async_condition returns False
assert len(state["events"]) == 3
assert state["checks"] == 4
rc = await async_limited_loop() # Stops once count reaches 4
assert count == 4
assert rc.status == "stopped"

asyncio.run(main())
Expand Down Expand Up @@ -358,7 +352,14 @@ with spin(heartbeat, freq=5, report=True) as rc:
- Ensure you use asynchronous functions with `is_coroutine=True`
- The library will raise TypeError if there's a mismatch

### 6. Context manager syntax
### 6. Prefer Lambda Functions for Conditions

- Use lambda functions for simple `condition_fn` logic to keep code concise.
- Example: `condition_fn=lambda: counter < 10`
- **Named functions and coroutines** are also supported and recommended for more complex state checks that don't fit in a single line.
- Remember to use `nonlocal` if the lambda or inner function checks a variable modified inside the looped function.

### 7. Context manager syntax

- For synchronous functions: Use `with spin(...)`
- For asynchronous functions: Use `async with spin(...)`
Expand Down
Loading