From abbd4067ae70475dd61013318648e7a56af2f4f9 Mon Sep 17 00:00:00 2001 From: Suke0811 <49264928+Suke0811@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:50:36 -0800 Subject: [PATCH 1/4] Fix total iteration reporting to include warmup --- fspin/rate_control.py | 4 +++- tests/test_ratecontrol.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/fspin/rate_control.py b/fspin/rate_control.py index fa7dfc1..bb81630 100644 --- a/fspin/rate_control.py +++ b/fspin/rate_control.py @@ -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 diff --git a/tests/test_ratecontrol.py b/tests/test_ratecontrol.py index b6e6698..83bae3e 100644 --- a/tests/test_ratecontrol.py +++ b/tests/test_ratecontrol.py @@ -76,6 +76,22 @@ def work(): assert len(rc.iteration_times) == 1 +def test_spin_sync_report_counts_warmup_iteration(): + calls = [] + + def condition(): + return len(calls) < 2 + + @spin(freq=1000, condition_fn=condition, report=True, thread=False) + def work(): + calls.append(time.perf_counter()) + + rc = work() + report = rc.get_report(output=False) + + assert report.get("total_iterations") == len(calls) + + def test_spin_sync_default_condition(): calls = [] From 260d09939a2527529d75e58f1b7332af2d7e36bb Mon Sep 17 00:00:00 2001 From: Suke0811 <49264928+Suke0811@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:01:36 -0800 Subject: [PATCH 2/4] Update: add examples and documentation for lambda-based `condition_fn` usage in sync and async contexts --- example/README.md | 1 + fspin/spin_context.py | 21 ++++++++--- fspin_cheatsheet.md | 67 +++++++++++++++++---------------- readme.md | 87 ++++++++++++++++++++++--------------------- 4 files changed, 95 insertions(+), 81 deletions(-) diff --git a/example/README.md b/example/README.md index 49b5cf3..38f57af 100644 --- a/example/README.md +++ b/example/README.md @@ -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 ` to see the behaviour. diff --git a/fspin/spin_context.py b/fspin/spin_context.py index 835578a..1d79ce0 100644 --- a/fspin/spin_context.py +++ b/fspin/spin_context.py @@ -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") diff --git a/fspin_cheatsheet.md b/fspin_cheatsheet.md index 0e99efb..1d6febb 100644 --- a/fspin_cheatsheet.md +++ b/fspin_cheatsheet.md @@ -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 @@ -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 @@ -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()) @@ -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(...)` diff --git a/readme.md b/readme.md index c234399..a487c41 100644 --- a/readme.md +++ b/readme.md @@ -41,18 +41,18 @@ Give this cheatsheet to your LLM, then it should be able to use and debug the li ## Usage ```python -import time from fspin import spin -@spin(freq=1000, report=True) -def function_to_loop(): - # things to loop - time.sleep(0.0005) # a fake task to take 0.5ms +counter = 0 + +# Using a lambda function as a condition (Preferred) +@spin(freq=10, condition_fn=lambda: counter < 5) +def my_loop(): + nonlocal counter + counter += 1 + print(f"Iteration {counter}") -# call the function -function_to_loop() # this will be blocking, and start looping -# it'll automatically catch the keyboard interrupt -# we have async version too +my_loop() # Blocks until counter reaches 5 ``` ### Sync threaded: blocking vs fire-and-forget @@ -60,26 +60,25 @@ function_to_loop() # this will be blocking, and start looping import time from fspin import spin -counter = {"n": 0} - -def cond(): - return counter["n"] < 5 +counter = 0 # Fire-and-forget: returns immediately while the background thread runs -@spin(freq=50, condition_fn=cond, thread=True, wait=False) +@spin(freq=50, condition_fn=lambda: counter < 5, thread=True, wait=False) def sync_bg(): - counter["n"] += 1 + nonlocal counter + counter += 1 rc = sync_bg() # returns immediately # ... do other work ... rc.stop_spinning() # stop when ready -# Blocking: call does not return until cond() becomes False -@spin(freq=50, condition_fn=cond, thread=True, wait=True) +# Blocking: call does not return until condition becomes False +@spin(freq=50, condition_fn=lambda: counter < 5, thread=True, wait=True) def sync_blocking(): - counter["n"] += 1 + nonlocal counter + counter += 1 -counter["n"] = 0 +counter = 0 rc2 = sync_blocking() # blocks until 5 iterations complete ``` @@ -109,47 +108,51 @@ async def run_both(): import time from fspin import spin -def heartbeat(): - print(f"Heartbeat at {time.strftime('%H:%M:%S')}") +counter = 0 -# Runs in background thread at 2Hz, auto-stops on exit, prints report -with spin(heartbeat, freq=2, report=True, thread=True): - time.sleep(5) # keep the block alive for 5s - print("exiting the loop") -# automatically exit the loop after 5 sec -print("Loop exited") +# Runs in background thread as long as counter < 5 +# condition_fn uses a lambda for concise state checking +with spin(lambda: print("Beat"), freq=10, condition_fn=lambda: counter < 5) as rc: + while rc.is_running(): + counter += 1 + time.sleep(0.1) -# Pass positional/keyword arguments to the worker on every iteration -def log_value(value, *, prefix): - print(f"{prefix}: {value}") - -with spin(log_value, 5, 42, prefix="reading"): - time.sleep(1) +print(f"Loop stopped at counter={counter}") ``` Note: - For synchronous functions with threading, pass `wait=True` to block entering the with-body until the loop completes (the internal thread is joined before returning). With `wait=False` (default here), the loop runs in the background while inside the context. - For asynchronous functions used with `async with`, the `wait` flag is not used; the task runs while inside the context and stops on exit. -- Synchronous contexts require `condition_fn` to be a regular callable returning a truthy value. For async contexts you can supply a coroutine function or other awaitable predicate—fspin will await it automatically before each iteration. +- Using a lambda for `condition_fn` is the preferred way to define simple stop conditions. +- Named functions and coroutines are also fully supported for more complex logic: + ```python + def complex_condition(): + # logic involving multiple variables or state + return some_state.is_valid and counter < 10 + + @spin(freq=10, condition_fn=complex_condition) + def my_loop(): + ... + ``` +- Synchronous contexts require it to be a regular callable. For async contexts you can supply a coroutine or other awaitable predicate—fspin will await it automatically. ### Async predicates for condition_fn ```python import asyncio from fspin import spin -ticks = [] - -async def predicate(): - await asyncio.sleep(0) # simulate async state checks - return len(ticks) < 3 +count = 0 -@spin(freq=100, condition_fn=predicate, wait=True) +# Async workflow using a lambda for the condition +@spin(freq=100, condition_fn=lambda: count < 3, wait=True) async def monitored_task(): - ticks.append("tick") + nonlocal count + count += 1 + print(f"Tick {count}") async def main(): rc = await monitored_task() - assert len(ticks) == 2 + assert count == 3 assert rc.status == "stopped" asyncio.run(main()) From bc13b335e84e6a4a8192bfe16b5c43b9ed243aca Mon Sep 17 00:00:00 2001 From: Suke0811 <49264928+Suke0811@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:53:53 -0800 Subject: [PATCH 3/4] Update: dynamically load cheatsheet into `UnifiedSpin` docstring and include `fspin_cheatsheet.md` in the manifest --- MANIFEST.in | 2 +- fspin/__init__.py | 38 ++++++++++++++++++++++++++++++++++---- fspin/unified.py | 41 +++++++++++++++++++++++++++++------------ 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index ad2e6d5..f63f6d5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -# include *.md +include fspin_cheatsheet.md diff --git a/fspin/__init__.py b/fspin/__init__.py index a609662..4a2e5cc 100644 --- a/fspin/__init__.py +++ b/fspin/__init__.py @@ -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 diff --git a/fspin/unified.py b/fspin/unified.py index a2c9c89..b006ac5 100644 --- a/fspin/unified.py +++ b/fspin/unified.py @@ -1,5 +1,6 @@ -import inspect +import inspect import asyncio +import os from functools import wraps from .rate_control import RateControl from .decorators import spin as spin_decorator @@ -7,20 +8,36 @@ 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 From 811ffdfc00573ca6a78093f7bb1d36f958153208 Mon Sep 17 00:00:00 2001 From: Suke0811 <49264928+Suke0811@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:06:08 -0800 Subject: [PATCH 4/4] Add: examples for lambda-based `condition_fn` and `fspin_cheatsheet.md` loader script --- example/lambda_condition.py | 51 +++++++++++++++++++++++++++++++++++++ fspin/__main__.py | 24 +++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 example/lambda_condition.py create mode 100644 fspin/__main__.py diff --git a/example/lambda_condition.py b/example/lambda_condition.py new file mode 100644 index 0000000..d49c34e --- /dev/null +++ b/example/lambda_condition.py @@ -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()) diff --git a/fspin/__main__.py b/fspin/__main__.py new file mode 100644 index 0000000..3830a99 --- /dev/null +++ b/fspin/__main__.py @@ -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") + + 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()