Repository: ManiMozaffar/aioclock Branch: main Commit: c943c853ac9f Files: 50 Total size: 220.9 KB Directory structure: gitextract_w_f8cus0/ ├── .github/ │ └── workflows/ │ ├── deploy-docs-on-demand.yml │ ├── main.yml │ └── on-release-main.yml ├── .gitignore ├── .python-version ├── LICENSE ├── Makefile ├── README.md ├── aioclock/ │ ├── __init__.py │ ├── api.py │ ├── app.py │ ├── custom_types.py │ ├── exceptions.py │ ├── ext/ │ │ ├── __init__.py │ │ └── fast.py │ ├── group.py │ ├── logger.py │ ├── provider.py │ ├── py.typed │ ├── task.py │ ├── triggers.py │ └── utils.py ├── deploy_docs.py ├── docs/ │ ├── alternative.md │ ├── api/ │ │ ├── external_api.md │ │ ├── getting_started.md │ │ ├── plugin.md │ │ ├── task.md │ │ └── triggers.md │ ├── diagrams/ │ │ └── aioclock.excalidraw │ ├── examples/ │ │ ├── brokers.md │ │ └── fastapi.md │ ├── extra/ │ │ └── tweaks.css │ ├── images/ │ │ └── README.md │ ├── index.md │ ├── overview.md │ └── plugins.py ├── examples/ │ ├── app.py │ ├── awesome_triggers.py │ ├── dependency_injection.py │ └── with_fast_api.py ├── mkdocs.yml ├── pyproject.toml ├── tests/ │ ├── __init__.py │ ├── test_di.py │ ├── test_examples.py │ ├── test_lifespan.py │ ├── test_timeout.py │ └── test_triggers.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/deploy-docs-on-demand.yml ================================================ name: Deploy Docs On Demand on: workflow_dispatch: jobs: deploy-docs-on-demand: runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v3 - name: Set up the environment uses: ./.github/actions/setup-poetry-env - name: Install the latest version of rye uses: eifinger/setup-rye@v2 - name: Install dependencies run: make install - name: Deploy documentation run: rye run python deploy_docs.py ================================================ FILE: .github/workflows/main.yml ================================================ name: Main on: push: branches: - main pull_request: types: [opened, synchronize, reopened] jobs: tox: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11"] fail-fast: false steps: - name: Check out uses: actions/checkout@v3 - name: Set up python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install the latest version of rye uses: eifinger/setup-rye@v2 - name: Pin python-version ${{ matrix.python-version }} run: rye pin ${{ matrix.python-version }} - name: Install dependencies run: make install - name: Run tests run: make test - name: Run check run: make check ================================================ FILE: .github/workflows/on-release-main.yml ================================================ name: release-main on: release: types: [published] branches: [main] jobs: publish: runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v3 - name: Export tag id: vars run: echo tag=${GITHUB_REF#refs/*/} >> $GITHUB_OUTPUT - name: Set up python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install the latest version of rye uses: eifinger/setup-rye@v2 # https://github.com/astral-sh/rye/issues/1180 - name: Patch Rye run: | echo "Patching Rye with Twine 5.1.1" $RYE_HOME/self/bin/pip install twine==5.1.1 - name: Build and publish run: | rye build rye publish --token $PYPI_TOKEN --yes env: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} RELEASE_VERSION: ${{ steps.vars.outputs.tag }} ================================================ FILE: .gitignore ================================================ docs/source # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class .DS_Store # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Vscode config files .vscode/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ ================================================ FILE: .python-version ================================================ 3.11.9 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024, Mani Mozaffar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: install install: ## Install the rye environment @echo "🚀 Creating virtual environment using rye and uv" rye sync .PHONY: check check: ## Run the quality checks on the code @echo "🚀 Running quality checks" rye run ruff . rye run pyright . .PHONY: test test: ## Test the code with pytest @echo "🚀 Testing code: Running pytest" rye run pytest .PHONY: docs docs: ## Build and serve the documentation @echo "🚀 Testing documentation: Building and testing" rye run mkdocs serve .PHONY: deploy-docs deploy-docs: ## Build and serve the documentation @echo "🚀 Deploying documentation" rye run python deploy_docs.py .PHONY: docs-test docs-test: ## Test if documentation can be built without warnings or errors @rye run mkdocs build -s .PHONY: help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' .DEFAULT_GOAL := help ================================================ FILE: README.md ================================================ # aioclock [![Release](https://img.shields.io/github/v/release/ManiMozaffar/aioclock)](https://img.shields.io/github/v/release/ManiMozaffar/aioclock) [![Build status](https://img.shields.io/github/actions/workflow/status/ManiMozaffar/aioclock/main.yml?branch=main)](https://github.com/ManiMozaffar/aioclock/actions/workflows/main.yml?query=branch%3Amain) [![Commit activity](https://img.shields.io/github/commit-activity/m/ManiMozaffar/aioclock)](https://img.shields.io/github/commit-activity/m/ManiMozaffar/aioclock) [![License](https://img.shields.io/github/license/ManiMozaffar/aioclock)](https://img.shields.io/github/license/ManiMozaffar/aioclock) An asyncio-based scheduling framework designed for execution of periodic task with integrated support for dependency injection, enabling efficient and flexiable task management - **Github repository**: ## Features Aioclock offers: - Async: 100% Async, very light, fast and resource friendly - Scheduling: Keep scheduling tasks for you - Group: Group your task, for better code maintainability - Trigger: Already defined and easily extendable triggers, to trigger your scheduler to be started - Easy syntax: Library's syntax is very easy and enjoyable, no confusing hierarchy - Pydantic v2 validation: Validate all your trigger on startup using pydantic 2. Fastest to fail possible! - **Soon**: Running the task dispatcher (scheduler) on different process by default, so CPU intensive stuff on task won't delay the scheduling - **Soon**: Backend support, to allow horizontal scalling, by synchronizing, maybe using Redis ## Getting started To Install aioclock, simply do ``` pip install aioclock ``` ## Help See [documentation](https://ManiMozaffar.github.io/aioclock/) for more details. ## A Sample Example ```python from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import asyncio from aioclock import AioClock, At, Depends, Every, Forever, Once from aioclock.group import Group # groups.py group = Group() def more_useless_than_me(): return "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Every(seconds=10)) async def every(): print("Every 10 seconds, I make a quantum leap. Where will I land next?") @group.task(trigger=Every(seconds=5)) def even_sync_works(): print("I'm a synchronous task. I work even in async world.") @group.task(trigger=At(tz="UTC", hour=0, minute=0, second=0)) async def at(): print( "When the clock strikes midnight... I turn into a pumpkin. Just kidding, I run this task!" ) @group.task(trigger=Forever()) async def forever(val: str = Depends(more_useless_than_me)): await asyncio.sleep(2) print("Heartbeat detected. Still not a zombie. Will check again in a bit.") assert val == "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Once()) async def once(): print("Just once, I get to say something. Here it goes... I love lamp.") @asynccontextmanager async def lifespan(aio_clock: AioClock) -> AsyncGenerator[AioClock]: # starting up print( "Welcome to the Async Chronicles! Did you know a group of unicorns is called a blessing? Well, now you do!" ) yield aio_clock # shuting down print("Going offline. Remember, if your code is running, you better go catch it!") # app.py app = AioClock(lifespan=lifespan) app.include_group(group) # main.py if __name__ == "__main__": asyncio.run(app.serve()) ``` ================================================ FILE: aioclock/__init__.py ================================================ from fast_depends import Depends from aioclock.app import AioClock from aioclock.group import Group from aioclock.triggers import At, Cron, Every, Forever, Once, OnShutDown, OnStartUp, OrTrigger __all__ = [ "Depends", "Once", "OnStartUp", "OnShutDown", "Every", "Forever", "Group", "AioClock", "At", "Cron", "OrTrigger", ] __version__ = "0.3.0" ================================================ FILE: aioclock/api.py ================================================ """ External API of the aioclock package, that can be used to interact with the AioClock instance. This module could be very useful if you intend to use aioclock in a web application or a CLI tool. Other tools and extension are written from this tool. !!! danger "Note when writing to aioclock API and changing its state." Right now the state of AioClock instance is on the memory level, so if you write an API and change a task's trigger time, it will not persist. In the future, we might store the state of AioClock instance in a database, so that it always remains same. But this is a bit tricky and implicit because then your code gets ignored and database is preferred over the codebase. For now, you may consider it as a way to change something without redeploying the application, but it is not very recommended to write. """ import sys from typing import Any, Awaitable, Callable, TypeVar, Union from uuid import UUID from fast_depends import inject from pydantic import BaseModel from aioclock.app import AioClock from aioclock.exceptions import TaskIdNotFound from aioclock.provider import get_provider from aioclock.triggers import TriggerT if sys.version_info < (3, 10): from typing_extensions import ParamSpec else: from typing import ParamSpec T = TypeVar("T") P = ParamSpec("P") class TaskMetadata(BaseModel): """Metadata of the task that is included in the AioClock instance. Attributes: id: UUID: Task ID that is unique for each task, and changes every time you run the aioclock app. In the future, we might store task ID in a database, so that it always remains same. trigger: Union[TriggerT, Any]: Trigger that is used to run the task, type is also any to ease implementing new triggers. task_name: str: Name of the task function. """ id: UUID trigger: Union[TriggerT, Any] task_name: str async def run_specific_task(task_id: UUID, app: AioClock): """Run a specific task immediately by its ID, from the AioClock instance. params: task_id: Task ID that is unique for each task, and changes every time you run the aioclock app. In the future, we might store task ID in a database, so that it always remains same. app: AioClock instance to run the task from. Example: ```python from aioclock import AioClock, Once from aioclock.api import run_specific_task app = AioClock() @app.task(trigger=Once()) async def main(): print("Hello World") async def some_other_func(): await run_specific_task(app._tasks[0].id, app) ``` """ task = next((task for task in app._tasks if task.id == task_id), None) if not task: raise TaskIdNotFound return await run_with_injected_deps(task.func) async def run_with_injected_deps(func: Callable[P, Awaitable[T]]) -> T: """Runs an aioclock decorated function, with all the dependencies injected. Can be used to run a task function with all the dependencies injected. params: func: Function to run with all the dependencies injected. Must be decorated with `@app.task` decorator. Example: ```python from aioclock import Once, AioClock, Depends from aioclock.api import run_with_injected_deps app = AioClock() def some_dependency(): return 1 @app.task(trigger=Once()) async def main(bar: int = Depends(some_dependency)): print("Hello World") return bar async def some_other_func(): foo = await run_with_injected_deps(main) assert foo == 1 ``` """ return await inject(func, dependency_overrides_provider=get_provider())() # type: ignore async def get_metadata_of_all_tasks(app: AioClock) -> list[TaskMetadata]: """Get metadata of all tasks that are included in the AioClock instance. This function can be used to mutate the `TaskMetadata` object, i.e. to change the trigger of a task. But for now it is yet not recommended to do this, as you might experience some unexpected behavior. But in next versions, I'd like to make it more stable and reliable on mutating the data. params: app: AioClock instance to get the metadata of all tasks. Example: ```python from aioclock import AioClock, Once from aioclock.api import get_metadata_of_all_tasks app = AioClock() @app.task(trigger=Once()) async def main(): ... async def some_other_func(): metadata = await get_metadata_of_all_tasks(app) ``` """ return [ TaskMetadata( id=task.id, trigger=task.trigger, task_name=task.func.__name__, ) for task in app._get_tasks(exclude_type=set()) ] ================================================ FILE: aioclock/app.py ================================================ """ To initialize the AioClock instance, you need to import the AioClock class from the aioclock module. AioClock class represent the aioclock, and handle the tasks and groups that will be run by the aioclock. Another way to modularize your code is to use `Group` which is kinda the same idea as router in web frameworks. """ from __future__ import annotations import asyncio import sys from functools import wraps from typing import ( Any, AsyncContextManager, Callable, ContextManager, Optional, TypeVar, Union, ) import anyio if sys.version_info < (3, 10): from typing_extensions import ParamSpec else: from typing import ParamSpec if sys.version_info < (3, 11): from typing_extensions import assert_never else: from typing import assert_never from asyncer import asyncify from fast_depends import inject from aioclock.custom_types import Triggers from aioclock.group import Group, Task from aioclock.provider import get_provider from aioclock.triggers import BaseTrigger from aioclock.utils import flatten_chain T = TypeVar("T") P = ParamSpec("P") class AioClock: """ AioClock is the main class that will be used to run the tasks. It will be responsible for running the tasks in the right order. Example: ```python from aioclock import AioClock, Once app = AioClock() @app.task(trigger=Once()) async def main(): print("Hello World") ``` To run the aioclock final app simply do: Example: ```python from aioclock import AioClock, Once import asyncio app = AioClock() # whatever next comes here asyncio.run(app.serve()) ``` ## Lifespan You can define this startup and shutdown logic using the lifespan parameter of the AioClock instance. It should be as an AsyncContextManager which get AioClock application as argument. You can find the example below. Example: ```python import asyncio from contextlib import asynccontextmanager from aioclock import AioClock ML_MODEL = [] # just some imaginary component that needs to be started and stopped @asynccontextmanager async def lifespan(app: AioClock): ML_MODEL.append(2) print("UP!") yield app ML_MODEL.clear() print("DOWN!") app = AioClock(lifespan=lifespan) if __name__ == "__main__": asyncio.run(app.serve()) ``` Here we are simulating the expensive startup operation of loading the model by putting the (fake) model function in the dictionary with machine learning models before the yield. This code will be executed before the application starts operating, during the startup. And then, right after the yield, we unload the model. This code will be executed after the application finishes handling requests, right before the shutdown. This could, for example, release resources like memory, a GPU or some database connection. It would also happen when you're stopping your application gracefully, for example, when you're shutting down your container. Lifespan could also be synchronous context manager. Check the example below. Example: ```python from contextlib import contextmanager from aioclock import AioClock ML_MODEL = [] @contextmanager def lifespan_sync(sync_app: AioClock): ML_MODEL.append(2) print("UP!") yield sync_app ML_MODEL.clear() print("DOWN!") sync_app = AioClock(lifespan=lifespan_sync) if __name__ == "__main__": asyncio.run(app.serve()) ``` """ def __init__( self, *, lifespan: Optional[ Callable[[AioClock], AsyncContextManager[AioClock] | ContextManager[AioClock]] ] = None, limiter: Optional[anyio.CapacityLimiter] = None, ): """ Initialize AioClock instance. No parameters are needed. Attributes: lifespan: A context manager that will be used to handle the startup and shutdown of the application. If not provided, the application will run without any startup and shutdown logic. To understand it better, check the examples and documentation above. limiter: Anyio CapacityLimiter. capacity limiter to use to limit the total amount of threads running Limiter that will be used to limit the number of tasks that are running at the same time. If not provided, it will fall back to the default limiter set on Application level. If no limiter is set on Application level, it will fall back to the default limiter set by AnyIO. """ self._groups: list[Group] = [] self._app_tasks: list[Task] = [] self._limiter = limiter self.lifespan = lifespan group = Group() group._tasks = self._app_tasks self.include_group(group) _groups: list[Group] """List of groups that will be run by AioClock.""" _app_tasks: list[Task] """List of tasks that will be run by AioClock.""" @property def dependencies(self): """Dependencies provider that will be used to inject dependencies in tasks.""" return get_provider() def override_dependencies( self, original: Callable[..., Any], override: Callable[..., Any] ) -> None: """Override a dependency with a new one. params: original: Original dependency that will be overridden. override: New dependency that will override the original one. Example: ```python from aioclock import AioClock def original_dependency(): return 1 def new_dependency(): return 2 app = AioClock() app.override_dependencies(original=original_dependency, override=new_dependency) ``` """ self.dependencies.override(original, override) def include_group(self, group: Group) -> None: """Include a group of tasks that will be run by AioClock. params: group: Group of tasks that will be run together. Example: ```python from aioclock import AioClock, Group, Once app = AioClock() group = Group() @group.task(trigger=Once()) async def main(): print("Hello World") app.include_group(group) ``` """ self._groups.append(group) return None def task(self, *, trigger: BaseTrigger, timeout: Optional[float] = None): """ Decorator to add a task to the AioClock instance. If decorated function is sync, aioclock will run it in a thread pool executor, using AnyIO. But if you try to run the decorated function, it will run in the same thread, blocking the event loop. It is intended to not change all your `sync functions` to coroutine functions, and they can be used outside aioclock, if needed. params: trigger: BaseTrigger Trigger that will trigger the task to be running. timeout: float | None (defaults to None) Set a timeout for the task. If the task completion took longer than timeout, it will be cancelled and a `TaskTimeoutError` be raised by the Application. Example: ```python from aioclock import AioClock, Once app = AioClock() @app.task(trigger=Once()) async def main(): print("Hello World") ``` Example: ```python from aioclock import AioClock, Once app = AioClock() @app.task(trigger=Once(), timeout=3) async def main(): await some_io_task() ``` """ def decorator(func): @wraps(func) async def wrapped_function(*args, **kwargs): if asyncio.iscoroutinefunction(func): return await func(*args, **kwargs) else: # run in threadpool to make sure it's not blocking the event loop return await asyncify(func, limiter=self._limiter)(*args, **kwargs) self._app_tasks.append( Task( func=inject(wrapped_function, dependency_overrides_provider=get_provider()), trigger=trigger, timeout=timeout, ) ) return wrapped_function return decorator @property def _tasks(self) -> list[Task]: result = flatten_chain([group._tasks for group in self._groups]) return result def _get_shutdown_task(self) -> list[Task]: return [task for task in self._tasks if task.trigger.type_ == Triggers.ON_SHUT_DOWN] def _get_startup_task(self) -> list[Task]: return [task for task in self._tasks if task.trigger.type_ == Triggers.ON_START_UP] def _get_tasks(self, exclude_type: Union[set[Triggers], None] = None) -> list[Task]: exclude_type = ( exclude_type if exclude_type is not None else {Triggers.ON_START_UP, Triggers.ON_SHUT_DOWN} ) return [task for task in self._tasks if task.trigger.type_ not in exclude_type] async def serve(self) -> None: """ Serves AioClock Run the tasks in the right order. First, run the startup tasks, then run the tasks, and finally run the shutdown tasks. """ if self.lifespan is None: await self._run_tasks() return ctx = self.lifespan(self) if isinstance(ctx, AsyncContextManager): async with ctx: await self._run_tasks() elif isinstance(ctx, ContextManager): with ctx: await self._run_tasks() else: assert_never(ctx) async def _run_tasks(self) -> None: try: await asyncio.gather( *(task.run() for task in self._get_startup_task()), return_exceptions=False ) await asyncio.gather( *(task.run() for task in self._get_tasks()), return_exceptions=False ) finally: shutdown_tasks = self._get_shutdown_task() await asyncio.gather(*(task.run() for task in shutdown_tasks), return_exceptions=False) ================================================ FILE: aioclock/custom_types.py ================================================ from enum import auto from typing import Annotated, Literal, Union from annotated_types import Interval from aioclock.utils import StrEnum EveryT = Literal[ "every monday", "every tuesday", "every wednesday", "every thursday", "every friday", "every saturday", "every sunday", "every day", ] SecondT = Annotated[int, Interval(ge=0, le=59)] MinuteT = Annotated[int, Interval(ge=0, le=59)] HourT = Annotated[int, Interval(ge=0, le=24)] PositiveNumber = Annotated[Union[int, float], Interval(ge=0)] class Triggers(StrEnum): CRON = auto() """Cron job trigger.""" EVERY = auto() """Every (x) time units, it gets triggered.""" ONCE = auto() """Trigger once, then stop.""" FOREVER = auto() """Keep running, as long as application is running.""" ON_START_UP = auto() """Trigger on application start up.""" ON_SHUT_DOWN = auto() """Trigger on application shut down.""" AT = auto() """Trigger at a specific time.""" OR = auto() """Trigger when any of the triggers are met.""" ================================================ FILE: aioclock/exceptions.py ================================================ class BaseAioClockException(Exception): """Base exception for aioclock.""" class TaskIdNotFound(BaseAioClockException): """Task not found in the AioClock app.""" class TaskTimeoutError(BaseAioClockException, TimeoutError): """A task took longer than its timeout""" ================================================ FILE: aioclock/ext/__init__.py ================================================ """ Extensions for aioclock. AioClock is very extensible, and you can add your own extensions to it. The extension would allow you to interact with your AioClock instance, from different layers of your application. For instance, the FastAPI plugin allows you to run a specific task immediately from an HTTP API, or see your tasks in an HTTP API, and when they are going to run next. """ ================================================ FILE: aioclock/ext/fast.py ================================================ """FastAPI extension to manage the tasks of the AioClock instance in HTTP Layer. Use cases: - Expose the tasks of the AioClock instance in an HTTP API. - Show to your client which task is going to be run next, and at which time. - Run a specific task from an HTTP API immidiately if needed. To use FastAPI Extension, please make sure you do `pip install aioclock[fastapi]`. """ from typing import Union from uuid import UUID from aioclock.api import TaskMetadata, get_metadata_of_all_tasks, run_specific_task from aioclock.app import AioClock from aioclock.exceptions import TaskIdNotFound try: from fastapi.exceptions import HTTPException from fastapi.routing import APIRouter except ImportError: raise ImportError( "You need to install fastapi to use aioclock with FastAPI. Please run `pip install aioclock[fastapi]`" ) def make_fastapi_router(aioclock: AioClock, router: Union[APIRouter, None] = None): """Make a FastAPI router that exposes the tasks of the AioClock instance and its external python API in HTTP Layer. You can pass a router to this function, and have dependencies injected in the router, or any authorization logic that you want to have. params: aioclock: AioClock instance to get the tasks from. router: FastAPI router to add the routes to. If not provided, a new router will be created. Example: ```python import asyncio from contextlib import asynccontextmanager from fastapi import FastAPI from aioclock import AioClock from aioclock.ext.fast import make_fastapi_router from aioclock.triggers import Every, OnStartUp clock_app = AioClock() @clock_app.task(trigger=OnStartUp()) async def startup(): print("Starting...") @clock_app.task(trigger=Every(seconds=3600)) async def foo(): print("Foo is processing...") @asynccontextmanager async def lifespan(app: FastAPI): task = asyncio.create_task(clock_app.serve()) yield try: task.cancel() await task except asyncio.CancelledError: ... app = FastAPI(lifespan=lifespan) app.include_router(make_fastapi_router(clock_app)) if __name__ == "__main__": import uvicorn # uvicorn.run(app) ``` """ router = router or APIRouter() @router.get("/tasks") async def get_tasks() -> list[TaskMetadata]: return await get_metadata_of_all_tasks(aioclock) @router.post("/task/{task_id}") async def run_task(task_id: UUID): try: await run_specific_task(task_id, aioclock) except TaskIdNotFound: raise HTTPException(status_code=404, detail="Task not found") return router ================================================ FILE: aioclock/group.py ================================================ import asyncio import sys from functools import wraps from typing import Optional, TypeVar from asyncer import asyncify if sys.version_info < (3, 10): from typing_extensions import ParamSpec else: from typing import ParamSpec import anyio from fast_depends import inject from aioclock.provider import get_provider from aioclock.task import Task from aioclock.triggers import BaseTrigger T = TypeVar("T") P = ParamSpec("P") class Group: def __init__( self, *, limiter: Optional[anyio.CapacityLimiter] = None, timeout: Optional[float] = None ): """ Group of tasks that will be run together. Best use case is to have a good modularity and separation of concerns. For example, you can have a group of tasks that are responsible for sending emails. And another group of tasks that are responsible for sending notifications. Params: limiter: Anyio CapacityLimiter. capacity limiter to use to limit the total amount of threads running Limiter that will be used to limit the number of tasks that are running at the same time. If not provided, it will fall back to the default limiter set on Application level. If no limiter is set on Application level, it will fall back to the default limiter set by AnyIO. timeout: General timeout for the group's tasks. If a task overrides this value, the new value will be used for the task. Example: ```python from aioclock import Group, AioClock, Forever email_group = Group() # consider this as different file @email_group.task(trigger=Forever()) async def send_email(): ... # app.py aio_clock = AioClock() aio_clock.include_group(email_group) ``` Example: ```python from aioclock import Group, AioClock, Forever email_group = Group(timeout=5) # consider this as different file @email_group.task(trigger=Forever()) async def send_email(): ... # app.py aio_clock = AioClock() aio_clock.include_group(email_group) ``` """ self._tasks: list[Task] = [] self._limiter = limiter self._timeout = timeout def task(self, *, trigger: BaseTrigger, timeout: Optional[float] = None): """ Decorator to add a task to the group. If decorated function is sync, aioclock will run it in a thread pool executor, using AnyIO. But if you try to run the decorated function, it will run in the same thread, blocking the event loop. It is intended to not change all your `sync functions` to coroutine functions, and they can be used outside aioclock, if needed. params: trigger: BaseTrigger Trigger that will trigger the task to be running. timeout: float | None (defaults to None) Set a timeout for the task. If the task completion took longer than timeout, it will be cancelled and a `TaskTimeoutError` be raised by the Application. Example: ```python from aioclock import AioClock, Group, Once group = Group() @group.task(trigger=Once()) async def main(): print("Hello World") app = AioClock() app.include_group(group) ``` Example: ```python from aioclock import AioClock, Group, Once, Every group = Group(timeout=5) @group.task(trigger=Every(seconds=5)) async def main(): print("Hello World") @group.task(trigger=Once(), timeout=4) # this task will get 4 as timeout async def main(): print("Hello World") app = AioClock() app.include_group(group) ``` """ def decorator(func): @wraps(func) async def wrapped_function(*args, **kwargs): if asyncio.iscoroutinefunction(func): return await func(*args, **kwargs) else: # run in threadpool to make sure it's not blocking the event loop return await asyncify(func, limiter=self._limiter)(*args, **kwargs) to = self._timeout if timeout is not None: to = timeout self._tasks.append( Task( func=inject(wrapped_function, dependency_overrides_provider=get_provider()), trigger=trigger, timeout=to, ) ) return wrapped_function return decorator async def _run(self): """ Just for purpose of being able to run all task in group Private method, should not be used outside the library """ await asyncio.gather( *(task.run() for task in self._tasks), return_exceptions=False, ) ================================================ FILE: aioclock/logger.py ================================================ import logging logger = logging.getLogger("aioclock") ================================================ FILE: aioclock/provider.py ================================================ from functools import lru_cache from fast_depends import Provider @lru_cache def get_provider(): """Return a Provider instance, which is singleton. This singleton is used to inject dependencies in tasks. """ return Provider() ================================================ FILE: aioclock/py.typed ================================================ ================================================ FILE: aioclock/task.py ================================================ """ Aioclock wrap your functions with a task object, and append the task to the list of tasks in the AioClock instance. After collecting all the tasks from decorated functions, aioclock serve them in order it has to be (startup, normal, shutdown). These tasks keep running forever until the trigger's method `should_trigger` returns False. """ import asyncio from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Awaitable, Callable, Optional from uuid import UUID, uuid4 from aioclock.exceptions import TaskTimeoutError from aioclock.logger import logger from aioclock.triggers import BaseTrigger UTC = timezone.utc @dataclass class Task: """Task that will be run by AioClock. Which always has a function and a trigger. This is internally used, when you decorate your function with `aioclock.task`. Attributes: func: Callable[..., Awaitable[Any]]: Decorated function that will be run by AioClock. trigger: BaseTrigger: Trigger that will be used to run the function. id: UUID: Task ID that is unique for each task, and changes every time you run the aioclock app. In the future, we might store task ID in a database, so that it always remains same. """ func: Callable[..., Awaitable[Any]] trigger: BaseTrigger timeout: Optional[float] = None id: UUID = field(default_factory=uuid4) async def run(self): """ Run the task, and handle the exceptions. If the task fails, log the error with exception, but keep running the tasks. """ while self.trigger.should_trigger(): try: next_trigger = await self.trigger.get_waiting_time_till_next_trigger() if next_trigger is not None: logger.info(f"Triggering next task {self.func.__name__} in {next_trigger}") self.trigger.expected_trigger_time = datetime.now(UTC) + timedelta( seconds=next_trigger ) await self.trigger.trigger_next() if self.timeout is not None: logger.debug( f"Running task {self.func.__name__} with timeout of {self.timeout}" ) try: await asyncio.wait_for(self.func(), self.timeout) except asyncio.TimeoutError: raise TaskTimeoutError( f"Task {self.func.__name__!r} took longer than {self.timeout} seconds to run!" ) from None else: logger.debug(f"Running task {self.func.__name__}") await self.func() except Exception as error: # Log the error, but keep running the tasks. # don't crash the whole application. logger.exception(f"Error running task {self.func.__name__}: {error}") self.trigger.expected_trigger_time = None ================================================ FILE: aioclock/triggers.py ================================================ """ Triggers are used to determine when the event should be triggered. It can be based on time, or some other condition. You can create custom triggers by inheriting from `BaseTrigger` class. !!! info "Don't run CPU intensitve or thread-block IO task " AioClock's trigger are all running in async, only on one CPU. So, if you run a CPU intensive task, or a task that blocks the thread, then it will block the entire event loop. If you have a sync IO task, then it's recommended to use `run_in_executor` to run the task in a separate thread. Or use similiar libraries like `asyncer` or `trio` to run the task in a separate thread. """ from __future__ import annotations import asyncio from abc import ABC, abstractmethod from copy import deepcopy from datetime import datetime, timedelta from typing import Annotated, Generic, Literal, TypeVar, Union import zoneinfo from annotated_types import Interval from croniter import croniter from dateutil.relativedelta import relativedelta from pydantic import BaseModel, Field, PositiveInt, model_validator from typing_extensions import deprecated from aioclock.custom_types import PositiveNumber, Triggers TriggerTypeT = TypeVar("TriggerTypeT") WEEKDAY_MAPPER: dict[ Literal[ "every monday", "every tuesday", "every wednesday", "every thursday", "every friday", "every saturday", "every sunday", "every day", ], int, ] = { "every monday": 0, "every tuesday": 1, "every wednesday": 2, "every thursday": 3, "every friday": 4, "every saturday": 5, "every sunday": 6, } class BaseTrigger(BaseModel, ABC, Generic[TriggerTypeT]): """ Base class for all triggers. A trigger is a way to determine when the event should be triggered. It can be based on time, or some other condition. The way trigger are used is as follows: 1. An async function which is a task, is decorated with framework, and trigger is the arguement for the decorator 2. `get_waiting_time_till_next_trigger` is called to get the time in seconds, after which the event should be triggered. 3. If the time is not None, then it logs the time that is predicted for the event to be triggered. 4. `trigger_next` is called immediately after that, which triggers the event. You can create trigger by yourself, by inheriting from `BaseTrigger` class. Example: ```python from aioclock.triggers import BaseTrigger from typing import Literal class Forever(BaseTrigger[Literal["Forever"]]): type_: Literal["Forever"] = "Forever" def should_trigger(self) -> bool: return True async def trigger_next(self) -> None: return None async def get_waiting_time_till_next_trigger(self): if self.should_trigger(): return 0 return None ``` Attributes: type_: Type of the trigger. It is a string, which is used to identify the trigger's name. You can change the type by using `Generic` type when inheriting from `BaseTrigger`. expected_trigger_time: Expected time when the event should be triggered. This gets updated by Task Runner. It can be used on API layer, to know when the event is expected to be triggered. """ type_: TriggerTypeT expected_trigger_time: Union[datetime, None] = None @abstractmethod async def trigger_next(self) -> None: """ `trigger_next` keep track of the event, and triggers the event. The function shall return when the event is triggered and should be executed. """ def should_trigger(self) -> bool: """ `should_trigger` checks if the event should be triggered or not. If not, then the event will not be triggered anymore. You can save the state of the trigger and task inside the instance, and then check if the event should be triggered or not. For instance, in `LoopCounter` trigger, it keeps track of the number of times the event has been triggered, and then checks if the event should be triggered or not. """ return True @abstractmethod async def get_waiting_time_till_next_trigger(self) -> Union[float, None]: """ Returns the time in seconds, after which the event should be triggered. Returns None, if the event should not trigger anymore. """ ... class Forever(BaseTrigger[Literal[Triggers.FOREVER]]): """A trigger that is always triggered immediately. Example: ```python from aioclock import AioClock, Forever app = AioClock() # instead of this: async def my_task(): while True: try: await asyncio.sleep(3) 1/0 except DivisionByZero: pass # use this: @app.task(trigger=Forever()) async def my_task(): await asyncio.sleep(3) 1/0 ``` Attributes: type_: Type of the trigger. It is a string, which is used to identify the trigger's name. You can change the type by using `Generic` type when inheriting from `BaseTrigger`. """ type_: Literal[Triggers.FOREVER] = Triggers.FOREVER def should_trigger(self) -> bool: return True async def trigger_next(self) -> None: return None async def get_waiting_time_till_next_trigger(self): return 0 class LoopController(BaseTrigger, ABC, Generic[TriggerTypeT]): """ Base class for all triggers that have loop control. Attributes: type_: Type of the trigger. It is a string, which is used to identify the trigger's name. You can change the type by using `Generic` type when inheriting from `LoopController`. max_loop_count: The maximum number of times the event should be triggered. If set to 3, then 4th time the event will not be triggered. If set to None, it will keep running forever. This is available for all triggers that inherit from `LoopController`. _current_loop_count: Current loop count, which is used to keep track of the number of times the event has been triggered. Private attribute, should not be accessed directly. This is available for all triggers that inherit from `LoopController`. """ type_: TriggerTypeT _current_loop_count: int = 0 max_loop_count: Union[PositiveInt, None] = None @model_validator(mode="after") def validate_loop_control(self): if "_current_loop_count" in self.model_fields_set: raise ValueError("_current_loop_count is a private attribute, should not be provided.") return self def _increment_loop_counter(self) -> None: self._current_loop_count += 1 def should_trigger(self) -> bool: if self.max_loop_count is None: return True if self.max_loop_count > self._current_loop_count: return True return False async def get_waiting_time_till_next_trigger(self): return 0 class Once(LoopController[Literal[Triggers.ONCE]]): """A trigger that is triggered only once. It is used to trigger the event only once, and then stop. Example: ```python from aioclock import AioClock, Once app = AioClock() app.task(trigger=Once()) async def task(): print("Hello World!") ``` """ type_: Literal[Triggers.ONCE] = Triggers.ONCE max_loop_count: Literal[1] = 1 async def trigger_next(self) -> None: self._increment_loop_counter() return None async def get_waiting_time_till_next_trigger(self): if self._current_loop_count == 0: return 0 return None @deprecated( "Use `lifespan` instead of using Triggers for startup/shutdown events. This feature be removed in version 1.0.0" ) class OnStartUp(LoopController[Literal[Triggers.ON_START_UP]]): """Just like Once, but it triggers the event only once, when the application starts up. Example: ```python from aioclock import AioClock, OnStartUp app = AioClock() app.task(trigger=OnStartUp()) async def task(): print("Hello World!") ``` """ type_: Literal[Triggers.ON_START_UP] = Triggers.ON_START_UP max_loop_count: Literal[1] = 1 async def trigger_next(self) -> None: self._increment_loop_counter() return None async def get_waiting_time_till_next_trigger(self): if self._current_loop_count == 0: return 0 return None @deprecated( "Use `lifespan` instead of using Triggers for startup/shutdown events. This feature be removed in version 1.0.0" ) class OnShutDown(LoopController[Literal[Triggers.ON_SHUT_DOWN]]): """Just like Once, but it triggers the event only once, when the application shuts down. Example: ```python from aioclock import AioClock, OnShutDown app = AioClock() app.task(trigger=OnShutDown()) async def task(): print("Hello World!") ``` """ type_: Literal[Triggers.ON_SHUT_DOWN] = Triggers.ON_SHUT_DOWN max_loop_count: Literal[1] = 1 async def trigger_next(self) -> None: self._increment_loop_counter() return None async def get_waiting_time_till_next_trigger(self): if self._current_loop_count == 0: return 0 return None class Every(LoopController[Literal[Triggers.EVERY]]): """A trigger that is triggered every x time units. Example: ```python from aioclock import AioClock, Every app = AioClock() app.task(trigger=Every(seconds=3)) async def task(): print("Hello World!") ``` Attributes: first_run_strategy: Strategy to use for the first run. If `immediate`, then the event will be triggered immediately, and then wait for the time to trigger the event again. If `wait`, then the event will wait for the time to trigger the event for the first time. seconds: Seconds to wait before triggering the event. minutes: Minutes to wait before triggering the event. hours: Hours to wait before triggering the event. days: Days to wait before triggering the event. weeks: Weeks to wait before triggering the event. max_loop_count: The maximum number of times the event should be triggered. If set to 3, then 4th time the event will not be triggered. If set to None, it will keep running forever. This is available for all triggers that inherit from `LoopController`. """ type_: Literal[Triggers.EVERY] = Triggers.EVERY first_run_strategy: Literal["immediate", "wait"] = "wait" seconds: Union[PositiveNumber, None] = None minutes: Union[PositiveNumber, None] = None hours: Union[PositiveNumber, None] = None days: Union[PositiveNumber, None] = None weeks: Union[PositiveNumber, None] = None max_loop_count: Union[PositiveInt, None] = None @model_validator(mode="after") def validate_time_units(self): if ( self.seconds is None and self.minutes is None and self.hours is None and self.days is None and self.weeks is None ): raise ValueError("At least one time unit must be provided.") return self @property def to_seconds(self) -> float: result = self.seconds or 0 if self.weeks is not None: result += self.weeks * WEEK_TO_SECOND if self.days is not None: result += self.days * DAY_TO_SECOND if self.hours is not None: result += self.hours * HOUR_TO_SECOND if self.minutes is not None: result += self.minutes * MINUTE_TO_SECOND return result async def trigger_next(self) -> None: self._increment_loop_counter() if self._current_loop_count == 1 and self.first_run_strategy == "immediate": return None await asyncio.sleep(self.to_seconds) return None async def get_waiting_time_till_next_trigger(self): # not incremented yet, so the counter is 0 if self._current_loop_count == 0 and self.first_run_strategy == "immediate": return 0 if self.should_trigger(): return self.to_seconds return None MINUTE_TO_SECOND = 60 HOUR_TO_SECOND = 60 * MINUTE_TO_SECOND DAY_TO_SECOND = 24 * HOUR_TO_SECOND WEEK_TO_SECOND = 7 * DAY_TO_SECOND class At(LoopController[Literal[Triggers.AT]]): """A trigger that is triggered at a specific time. Example: ```python from aioclock import AioClock, At app = AioClock() @app.task(trigger=At(hour=12, minute=30, tz="Asia/Kolkata")) async def task(): print("Hello World!") ``` Attributes: second: Second to trigger the event. minute: Minute to trigger the event. hour: Hour to trigger the event. at: Day of week to trigger the event. You would get the in-line typing support when using the trigger. tz: Timezone to use for the event. max_loop_count: The maximum number of times the event should be triggered. If set to 3, then 4th time the event will not be triggered. If set to None, it will keep running forever. This is available for all triggers that inherit from `LoopController`. """ type_: Literal[Triggers.AT] = Triggers.AT max_loop_count: Union[PositiveInt, None] = None second: Annotated[int, Interval(ge=0, le=59)] = 0 minute: Annotated[int, Interval(ge=0, le=59)] = 0 hour: Annotated[int, Interval(ge=0, le=24)] = 0 at: Literal[ "every monday", "every tuesday", "every wednesday", "every thursday", "every friday", "every saturday", "every sunday", "every day", ] = "every day" tz: str @model_validator(mode="after") def validate_time_units(self): if self.second is None and self.minute is None and self.hour is None: raise ValueError("At least one time unit must be provided.") if self.tz is not None: try: zoneinfo.ZoneInfo(self.tz) except Exception as error: raise ValueError(f"Invalid timezone provided: {error}") return self def _shift_to_declared_weekday(self, target_time: datetime, tz_aware_now: datetime): if self.at == "every day": if tz_aware_now > target_time: # if the time is already passed, then shift to next day target_time += timedelta(days=1) return target_time target_weekday: int = WEEKDAY_MAPPER[self.at] if tz_aware_now > target_time: # if the time is already passed, then shift to next week return target_time + relativedelta(weeks=1) days_ahead = abs(target_weekday - tz_aware_now.weekday()) return target_time + timedelta(days_ahead) def _get_next_ts(self, now: datetime) -> float: target_time = deepcopy(now).replace( hour=self.hour, minute=self.minute, second=self.second, microsecond=0 ) target_time = self._shift_to_declared_weekday(target_time, now) return (target_time - now).total_seconds() async def get_waiting_time_till_next_trigger(self, now: Union[datetime, None] = None): if now is None: now = datetime.now(tz=zoneinfo.ZoneInfo(self.tz)) sleep_for = self._get_next_ts(now) return sleep_for async def trigger_next(self) -> None: self._increment_loop_counter() await asyncio.sleep(await self.get_waiting_time_till_next_trigger()) class Cron(LoopController[Literal[Triggers.CRON]]): """A trigger that is triggered at a specific time, using cron job format. If you are not familiar with the cron format, you may read about in [this wikipedia article](https://en.wikipedia.org/wiki/Cron). Or if you need an online tool to generate cron job, you may use [crontab.guru](https://crontab.guru/). Example: ```python from aioclock import AioClock, Cron app = AioClock() @app.task(trigger=Cron(cron="0 12 * * *", tz="Asia/Kolkata")) async def task(): print("Hello World!") ``` Attributes: cron: Cron job format to trigger the event. tz: Timezone to use for the event. max_loop_count: The maximum number of times the event should be triggered. If set to 3, then 4th time the event will not be triggered. If set to None, it will keep running forever. This is available for all triggers that inherit from `LoopController`. """ type_: Literal[Triggers.CRON] = Triggers.CRON max_loop_count: Union[PositiveInt, None] = None cron: str tz: str @model_validator(mode="after") def validate_time_units(self): if self.tz is not None: try: zoneinfo.ZoneInfo(self.tz) except Exception as error: raise ValueError(f"Invalid timezone provided: {error}") if croniter.is_valid(self.cron) is False: raise ValueError("Invalid cron format provided.") return self async def get_waiting_time_till_next_trigger(self, now: Union[datetime, None] = None): if now is None: now = datetime.now(tz=zoneinfo.ZoneInfo(self.tz)) cron_iter = croniter(self.cron, now) next_dt: datetime = cron_iter.get_next(datetime) return (next_dt - now).total_seconds() async def trigger_next(self) -> None: self._increment_loop_counter() await asyncio.sleep(await self.get_waiting_time_till_next_trigger()) class OrTrigger(LoopController[Literal[Triggers.OR]]): """ A trigger that triggers the event if any of the inner triggers are met. Example: ```python from aioclock import AioClock, OrTrigger, Every, At app = AioClock() @app.task(trigger=OrTrigger(triggers=[Every(seconds=3), At(hour=12, minute=30, tz="Asia/Kolkata")])) async def task(): print("Hello World!") ``` Not that any trigger used with OrTrigger, is fully respected, hence if you have two trigger with `max_loop_count=1`, then each trigger will be triggered only once, and then stop, which result in the OrTrigger run only twice. Check example to understand this intended behaviour. Example: ```python from aioclock import AioClock, OrTrigger, Every, At app = AioClock() @app.task(trigger=OrTrigger( # this get triggered 20 times because :... triggers=[ Every(seconds=3, max_loop_count=10), # will trigger the event 10 times At(hour=12, minute=30, tz="Asia/Kolkata", max_loop_count=10) # will trigger the event 10 times ] )) async def task(): print("Hello World!") ``` Attributes: triggers: List of triggers to use. max_loop_count: The maximum number of times the event should be triggered. If set to 3, then 4th time the event will not be triggered. If set to None, it will keep running forever. This is available for all triggers that inherit from `LoopController`. """ type_: Literal[Triggers.OR] = Triggers.OR triggers: list[TriggerT] max_loop_count: Union[PositiveInt, None] = None def should_trigger(self) -> bool: all_triggers = {trigger.should_trigger() for trigger in self.triggers} if all_triggers == {False}: return False # if all inner triggers should not trigger, then this shouldn't too. return super().should_trigger() async def find_closest_trigger(self) -> tuple[BaseTrigger, float | None]: triggers_with_next_trigger: list[tuple[BaseTrigger, float]] = [] for trigger in self.triggers: if trigger.should_trigger(): next_trigger = await trigger.get_waiting_time_till_next_trigger() if next_trigger is None: # just return it as this should be executed immediately return trigger, next_trigger triggers_with_next_trigger.append((trigger, next_trigger)) return min(triggers_with_next_trigger, key=lambda x: x[1]) async def trigger_next(self) -> None: self._increment_loop_counter() next_trigger, _ = await self.find_closest_trigger() await next_trigger.trigger_next() return None async def get_waiting_time_till_next_trigger(self): _, to_sleep = await self.find_closest_trigger() return to_sleep TriggerT = Annotated[ Union[ Forever, Once, Every, At, OnStartUp, OnShutDown, Cron, OrTrigger, ], Field(discriminator="type_"), ] ================================================ FILE: aioclock/utils.py ================================================ from enum import Enum from itertools import chain from typing import Iterable, TypeVar T = TypeVar("T") def flatten_chain(matrix: list[Iterable[T]]) -> list[T]: return list(chain.from_iterable(matrix)) class StrEnum(str, Enum): """ StrEnum subclasses that create variants using `auto()` will have values equal to their names Enums inheriting from this class that set values using `enum.auto()` will have variant values equal to their names """ @staticmethod def _generate_next_value_(name: str, start: int, count: int, last_values: list[str]) -> str: """ Uses the name as the automatic value, rather than an integer See https://docs.python.org/3/library/enum.html#using-automatic-values for reference """ return name def __str__(self) -> str: return str(self.value) ================================================ FILE: deploy_docs.py ================================================ import logging import subprocess import sys from aioclock import __version__ logger = logging.getLogger(__name__) def run_command(command: str) -> None: try: result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True) print(result.stdout) except subprocess.CalledProcessError: logger.exception("Error executing command") sys.exit(1) if __name__ == "__main__": command = "mike set-default latest" run_command(command) command = f"mike deploy --push --update-aliases {__version__} latest" run_command(command) ================================================ FILE: docs/alternative.md ================================================ # AioClock VS Alternatives There are other alternatives for scheduling as well. This section contains comparisons between AioClock and other scheduling tools. Credit to Rocketry library, as the comparison is inspired by that. Features unique for **AioClock**: - **Simplicity**: With being super-simple, it's very easy to extend the library as you wish, which is not the usual case with other solutions! - **Trigger-based scheduling**: Trigger based scheduling allows flexibility, making it very easy to run a task at a certain time in future. - **Dependency Injection System**: Just like FastAPI, AioClock has a very similiar injection system which you can use to decouple your dependency. - **Declarative Syntax**: AioClock promotes declarative syntax which makes the library easy to use. ## AioClock vs Rocketry Rocketry is a modern statement-based scheduling framework for Python. It is simple, clean and extensive. It is suitable for small and big projects. When **AioClock** might be a better choice: - You don't want to be dependent to other unnecessary libraries like [redbird](https://github.com/Miksus/red-bird) - You need a truly light weight solution. - You are using Pydantic v2. - Type safety is important to you. All triggers are type safe, but some statements are stringly typed in rocketry. - You need more reliable and preditcable time based scheduling that logs when the next event is going to be triggered. When **Rocketry** might be a better choice: - You need a task pipelining that is heavily cpu intensive. - You have heavy cpu bound tasks - You are still using Pydantic v1. !!! success "Coming next..." In future versions, aioclock will feature a more advanced architecture, leveraging multiprocessing to handle heavy tasks efficiently. ## AioClock vs Crontab Crontab is a scheduler for Unix-like operating systems. It is light weight and it is able to run tasks (or jobs) periodically, ie. hourly, weekly or on fixed dates. When **AioClock** might be a better choice: - You are building a system and not just running individual scripts. - You need task pipelining. - You need more complex and custom scheduling. - You are not familiar Unix-Linux or you work with Windows. - You need dependency injection on top of your framework layer. When **Crontab** might be a better choice: - If you need a truly light weight solution. - You are not familiar with Python. - You only want to run scripts independently at given periods. ## AioClock vs APScheduler APScheduler is a relatively simple scheduler library for Python. It provides Cron-style scheduling and some interval based scheduling. When **AioClock** might be a better choice: - You are building an automation system. - You need more complex and customized scheduling. - You need to pipeline tasks. - You need dependency injection on top of your framework layer. When **APScheduler** might be a better choice: - You wish to have the tasks stored in a database (and not in Python code) !!! info "You can do this by yourself already..." There is already External APIs from library that you can use, to implement storing task metadata on a database. It is very easy, but aioclock might actually not do it, to not couple library to a dependency. Read about [how to use the external API](api/external_api.md). ## AioClock vs Celery Celery is a task queue system meant for distributed execution and scheduling background tasks for web back-ends. When **AioClock** might be a better choice: - You are building an automation system. - You need more complex and customized scheduling. - You work with Windows. - You want to fully control your broker behavior, and have high flexability. - You need dependency injection on top of your framework layer. When **Celery** might be a better choice: - You are running background tasks for web servers. - You are not very familiar with message brokers, and you need very easy solution that abstract away all details. !!! info "Integrate broker is easier than you can imagine, with aioclock!" Celery works via task queues but such mechanism could be implemented to AioClock as well by creating a `once trigger` that reads from queue. You may make this as decorator and even create new libraries using AioClock. For implementation details, see [how to integrate a broker into AioClock App](examples/brokers.md). ## AioClock vs Airflow Airflow is a a workflow management system used heavily in data pipelines. It has a scheduler and a built-in monitor. When **AioClock** might be a better choice: - You work with Windows. - You need something that is easy to set up and quick to get produtive with. - You are building an application. - You want more customization. When **Airflow** might be a better choice: - You are building standard data pipelines. - You would like to have more out-of-the-box. - You need distributed execution. - You work in data engineering. ## AioClock vs FastStream FastStream is a powerful and easy-to-use Python framework for building asynchronous services interacting with event streams such as Apache Kafka, RabbitMQ, NATS and Redis. When **AioClock** might be a better choice: - You need more complex and customized scheduling. - You need high flexability and low level APIs of your broker. When **FastStream** might be a better choice: - You are not very familiar with message brokers, and you need very easy solution that abstract away all details. - You need auto generated asyncapi documentation - You are building a distributed data streaming application !!! info "They can be used together..." Note that you can use both beside each other, just like FastAPI. All you'd have to do is to serve both application at same time. ================================================ FILE: docs/api/external_api.md ================================================ ::: aioclock.api ================================================ FILE: docs/api/getting_started.md ================================================ ::: aioclock.app ::: aioclock.group ================================================ FILE: docs/api/plugin.md ================================================ ::: aioclock.ext ::: aioclock.ext.fast ================================================ FILE: docs/api/task.md ================================================ ::: aioclock.task ================================================ FILE: docs/api/triggers.md ================================================ # Triggers ::: aioclock.triggers ================================================ FILE: docs/diagrams/aioclock.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "arrow", "version": 1870, "versionNonce": 562660048, "index": "aR", "isDeleted": false, "id": "umIUdT2Pg_dV6Yj3OxRkn", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 631.8702637667776, "y": 56.86698670706318, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 260.23891777104006, "height": 28.060608173103617, "seed": 400598064, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "startBinding": { "elementId": "URbMlcmauXmJNE4154eWu", "focus": -0.14154036538490047, "gap": 10.016775821461238, "fixedPoint": null }, "endBinding": { "elementId": "b3EdqBYxnZK7_xrKoWpEZ", "focus": -0.24081634913640015, "gap": 1.0371662412074443, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 260.23891777104006, 28.060608173103617 ] ], "elbowed": false }, { "type": "text", "version": 646, "versionNonce": 2009813200, "index": "aS", "isDeleted": false, "id": "jGMhw9Odj78Yt6mAP27K3", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.09974755060020257, "x": 665.3042807320827, "y": 36.30932819124689, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 171.17919658226998, "height": 21.098102858351695, "seed": 3246640, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757251178, "link": null, "locked": false, "fontSize": 16.878482286681354, "fontFamily": 5, "text": "def include_group()", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "def include_group()", "autoResize": false, "lineHeight": 1.25 }, { "type": "rectangle", "version": 1610, "versionNonce": 2058722512, "index": "aa4", "isDeleted": false, "id": "b3EdqBYxnZK7_xrKoWpEZ", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 893.1463477790252, "y": -116.12620250637502, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "width": 891.0632487039327, "height": 383.0330657293356, "seed": 734090960, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "umIUdT2Pg_dV6Yj3OxRkn", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1399, "versionNonce": 1357949136, "index": "aa8", "isDeleted": false, "id": "T8_mi4ZCb-ZGtGclGYrQb", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 969.6874368433648, "y": -100.65652553224646, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 96.32254493525791, "height": 41.85028122224372, "seed": 1468254768, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 33.48022497779499, "fontFamily": 5, "text": "Group", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Group", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 1336, "versionNonce": 344133328, "index": "aaG", "isDeleted": false, "id": "1-nChtDmy9xBSz25x5Tdy", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 919.9022234788671, "y": -34.98141186613114, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 172.41095637580733, "height": 78, "seed": 1465639632, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "Ug96RmiLOJxVdqw8JLOl4", "type": "text" }, { "id": "ICGWc2_oP7ooflk821IPB", "type": "arrow" }, { "id": "dREYVkyhboXiS53A17F-t", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1340, "versionNonce": 1155052752, "index": "aaO", "isDeleted": false, "id": "Ug96RmiLOJxVdqw8JLOl4", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 968.5321079585611, "y": -10.460148756903092, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 74.94570922851562, "height": 28.957473781543904, "seed": 1418761776, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Task 1", "textAlign": "center", "verticalAlign": "middle", "containerId": "1-nChtDmy9xBSz25x5Tdy", "originalText": "Task 1", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 1640, "versionNonce": 995960528, "index": "aaV", "isDeleted": false, "id": "679z3nf1w9xVLbf3f8AdN", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1233.3981057694639, "y": -67.91630887978769, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 190.0690779031065, "height": 78, "seed": 446189616, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "ZovWm7KTns9gYTw6Alczq", "type": "text" }, { "id": "gBeFANJTXm0QDmAM0WzrH", "type": "arrow" }, { "id": "WYORfi6MQ5vqe_kGKqXHJ", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1612, "versionNonce": 951045328, "index": "aad", "isDeleted": false, "id": "ZovWm7KTns9gYTw6Alczq", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1287.7811818248304, "y": -43.39504577055965, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 81.26838684082031, "height": 28.957473781543904, "seed": 1300154928, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Task 2", "textAlign": "center", "verticalAlign": "middle", "containerId": "679z3nf1w9xVLbf3f8AdN", "originalText": "Task 2", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 1669, "versionNonce": 2020294352, "index": "aal", "isDeleted": false, "id": "vsA_CIfMv5pa_E8ntMeiT", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1340.6872159208458, "y": 55.18372300040164, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 180.59766388142287, "height": 85.51233077137486, "seed": 65450544, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "h9vDlzUov3hi0exBJmXql", "type": "text" }, { "id": "QCOc4HbolRRMUaYbYgevA", "type": "arrow" }, { "id": "43_r9mRXWwrQYCAcyRAFA", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1633, "versionNonce": 1547363536, "index": "aat", "isDeleted": false, "id": "h9vDlzUov3hi0exBJmXql", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1391.2677994937405, "y": 83.5830688024734, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 79.13766479492188, "height": 28.957473781543904, "seed": 45149232, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Task 3", "textAlign": "center", "verticalAlign": "middle", "containerId": "vsA_CIfMv5pa_E8ntMeiT", "originalText": "Task 3", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 1231, "versionNonce": 296572624, "index": "ab", "isDeleted": false, "id": "PEU_XU8Sh3D4biyxvy4HU", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1514.9518100115115, "y": -110.71170442618455, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 1514127408, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "RZxT1str5sU0IsNoY2jST", "type": "text" }, { "id": "WYORfi6MQ5vqe_kGKqXHJ", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1264, "versionNonce": 897187024, "index": "ab8", "isDeleted": false, "id": "RZxT1str5sU0IsNoY2jST", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 1555.6078490084465, "y": -96.8786225664656, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 106.81385803222656, "height": 28.957473781543904, "seed": 950688304, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Trigger 2", "textAlign": "center", "verticalAlign": "middle", "containerId": "PEU_XU8Sh3D4biyxvy4HU", "originalText": "Trigger 2", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 1391, "versionNonce": 1597016624, "index": "abG", "isDeleted": false, "id": "7nMEXGNJzk7AaKIUHZYgN", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1080.8975851818275, "y": 99.49504911229374, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 1550810160, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "AagPjJF25n7fL0j1dkYKb", "type": "text" }, { "id": "dREYVkyhboXiS53A17F-t", "type": "arrow" } ], "updated": 1723757450575, "link": null, "locked": false }, { "type": "text", "version": 1424, "versionNonce": 117425200, "index": "abV", "isDeleted": false, "id": "AagPjJF25n7fL0j1dkYKb", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 1124.7149629849148, "y": 113.3281309720127, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 100.49118041992188, "height": 28.957473781543904, "seed": 596670000, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757450575, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Trigger 1", "textAlign": "center", "verticalAlign": "middle", "containerId": "7nMEXGNJzk7AaKIUHZYgN", "originalText": "Trigger 1", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 1266, "versionNonce": 2073164496, "index": "abl", "isDeleted": false, "id": "L1oF-vTjx-r7uE4ozO6yW", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1578.0877317826946, "y": 109.78177027891613, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 1582500912, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "7w2g1VYZInR5vuWaicPT-", "type": "text" }, { "id": "QCOc4HbolRRMUaYbYgevA", "type": "arrow" } ], "updated": 1723757446539, "link": null, "locked": false }, { "type": "text", "version": 1306, "versionNonce": 1126834384, "index": "ac", "isDeleted": false, "id": "7w2g1VYZInR5vuWaicPT-", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 1619.8091318025788, "y": 123.61485213863509, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 104.68313598632812, "height": 28.957473781543904, "seed": 1115817520, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Trigger 3", "textAlign": "center", "verticalAlign": "middle", "containerId": "L1oF-vTjx-r7uE4ozO6yW", "originalText": "Trigger 3", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 2589, "versionNonce": 618118704, "index": "ad", "isDeleted": false, "id": "dREYVkyhboXiS53A17F-t", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1052.2934151720121, "y": 31.6871389549543, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 72.65101292680833, "height": 71.20679435860492, "seed": 1357628464, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757450575, "link": null, "locked": false, "startBinding": { "elementId": "1-nChtDmy9xBSz25x5Tdy", "focus": 0.021328620465046026, "gap": 8.713122096692402, "fixedPoint": null }, "endBinding": { "elementId": "7nMEXGNJzk7AaKIUHZYgN", "focus": -0.2495745298142544, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 72.65101292680833, 71.20679435860492 ] ], "elbowed": false }, { "type": "arrow", "version": 3150, "versionNonce": 1788521008, "index": "ae", "isDeleted": false, "id": "QCOc4HbolRRMUaYbYgevA", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1505.9138290121784, "y": 115.04897709364587, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 68.45677489333957, "height": 21.345873788386115, "seed": 1524351024, "groupIds": [ "kz6Om3WP3ZIzj3WwTqs5f" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446553, "link": null, "locked": false, "startBinding": { "elementId": "vsA_CIfMv5pa_E8ntMeiT", "focus": -0.14628403082450295, "gap": 8.885249593051313, "fixedPoint": null }, "endBinding": { "elementId": "L1oF-vTjx-r7uE4ozO6yW", "focus": -0.7039819943540665, "gap": 3.843006315232685, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 68.45677489333957, 21.345873788386115 ] ], "elbowed": false }, { "type": "diamond", "version": 1324, "versionNonce": 1520916176, "index": "at", "isDeleted": false, "id": "mp5cf4rOe7-TBZhYPXY93", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.006345883568884325, "x": 644.4979240201953, "y": 359.25214630499306, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 173.20703124999997, "height": 79.8671875, "seed": 426496208, "groupIds": [ "EzkF_X72G6C04wpKYmGYo" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "xyEhwiCsaF_GlM_qi4kL_" }, { "id": "jtmxjvYMT8IyTg6YsEhEo", "type": "arrow" }, { "id": "qdG1HK_ivqJvkFxfXu_bj", "type": "arrow" }, { "id": "HRt4IaHAZ1RGroBskO7Y4", "type": "arrow" } ], "updated": 1723757424418, "link": null, "locked": false }, { "type": "text", "version": 1261, "versionNonce": 628108336, "index": "au", "isDeleted": false, "id": "xyEhwiCsaF_GlM_qi4kL_", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.006345883568884325, "x": 697.3597022794727, "y": 386.71894317999306, "strokeColor": "#1e1e1e", "backgroundColor": "#ff8787", "width": 67.87995910644531, "height": 25, "seed": 1935226576, "groupIds": [ "EzkF_X72G6C04wpKYmGYo" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757418188, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Task 4", "textAlign": "center", "verticalAlign": "middle", "containerId": "mp5cf4rOe7-TBZhYPXY93", "originalText": "Task 4", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 1341, "versionNonce": 383682096, "index": "av", "isDeleted": false, "id": "l1-m9PMckNOmTsLOCicxB", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.03810438733463428, "x": 861.8797925835937, "y": 327.34831220020493, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 162.48975984463084, "height": 49, "seed": 917101776, "groupIds": [ "EzkF_X72G6C04wpKYmGYo" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rmTtNCvSYi_osiDfmjP7W" }, { "id": "qdG1HK_ivqJvkFxfXu_bj", "type": "arrow" } ], "updated": 1723757412156, "link": null, "locked": false }, { "type": "text", "version": 1315, "versionNonce": 260980784, "index": "aw", "isDeleted": false, "id": "rmTtNCvSYi_osiDfmjP7W", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0.03810438733463428, "x": 898.2059039024226, "y": 339.5241960611345, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 89.93992614746094, "height": 25, "seed": 1425392336, "groupIds": [ "EzkF_X72G6C04wpKYmGYo" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757412156, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Trigger 4", "textAlign": "center", "verticalAlign": "middle", "containerId": "l1-m9PMckNOmTsLOCicxB", "originalText": "Trigger 4", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 2670, "versionNonce": 890691632, "index": "ax", "isDeleted": false, "id": "qdG1HK_ivqJvkFxfXu_bj", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.024769842411049, "x": 792.7955223699694, "y": 376.2767822063056, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 87.57268219796742, "height": 0.5282969080340081, "seed": 419426352, "groupIds": [ "EzkF_X72G6C04wpKYmGYo" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757418188, "link": null, "locked": false, "startBinding": { "elementId": "mp5cf4rOe7-TBZhYPXY93", "focus": 0.11419552780004952, "gap": 1.1291855844196732, "fixedPoint": null }, "endBinding": { "elementId": "l1-m9PMckNOmTsLOCicxB", "focus": -0.23292659823893222, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 87.57268219796742, 0.5282969080340081 ] ], "elbowed": false }, { "type": "ellipse", "version": 742, "versionNonce": 599753264, "index": "b05", "isDeleted": false, "id": "WhZAKrfIBY1WfWup23z04", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1539.2381134992843, "y": 8.612451809277985, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 754706128, "groupIds": [ "i5wOMyEkj5AdEKIlQn9ba" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "r-dwj1uZUT6jpFw5M_7eg", "type": "text" }, { "id": "gBeFANJTXm0QDmAM0WzrH", "type": "arrow" } ], "updated": 1723753697539, "link": null, "locked": false }, { "type": "text", "version": 793, "versionNonce": 1324334128, "index": "b06", "isDeleted": false, "id": "r-dwj1uZUT6jpFw5M_7eg", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 1579.4772747496372, "y": 22.445533668996934, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 107.64761352539062, "height": 28.957473781543904, "seed": 141987024, "groupIds": [ "i5wOMyEkj5AdEKIlQn9ba" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723753697539, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Callable 2", "textAlign": "center", "verticalAlign": "middle", "containerId": "WhZAKrfIBY1WfWup23z04", "originalText": "Callable 2", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 907, "versionNonce": 12432080, "index": "b07", "isDeleted": false, "id": "gBeFANJTXm0QDmAM0WzrH", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1412.1801059262384, "y": -14.87264932207451, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 126.85949483626405, "height": 47.810475354948395, "seed": 1199146192, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "startBinding": { "elementId": "679z3nf1w9xVLbf3f8AdN", "focus": -0.4492016684540374, "gap": 8.707041440834715, "fixedPoint": null }, "endBinding": { "elementId": "WhZAKrfIBY1WfWup23z04", "focus": -0.7112738678621304, "gap": 1.1025252940076768, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 126.85949483626405, 47.810475354948395 ] ], "elbowed": false }, { "type": "ellipse", "version": 926, "versionNonce": 256996400, "index": "b08", "isDeleted": false, "id": "s89xH-4yu5r6IAP8S9KE8", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 921.0041842510022, "y": 175.33420387831111, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 840094768, "groupIds": [ "7T3F0-rwS5jf_PHTJroJR" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "ZF1CqhL5fwb4Ws-zY_XLW", "type": "text" }, { "id": "ICGWc2_oP7ooflk821IPB", "type": "arrow" } ], "updated": 1723757221361, "link": null, "locked": false }, { "type": "text", "version": 981, "versionNonce": 1583729872, "index": "b09", "isDeleted": false, "id": "ZF1CqhL5fwb4Ws-zY_XLW", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 964.4046843075075, "y": 189.16728573803007, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 101.32493591308594, "height": 28.957473781543904, "seed": 453561904, "groupIds": [ "7T3F0-rwS5jf_PHTJroJR" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757221361, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Callable 1", "textAlign": "center", "verticalAlign": "middle", "containerId": "s89xH-4yu5r6IAP8S9KE8", "originalText": "Callable 1", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 854, "versionNonce": 801233104, "index": "b0A", "isDeleted": false, "id": "KWb-DkqaKPnpVL7f6V4bL", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1370.069653799254, "y": 191.40622146641806, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 188.21171841881053, "height": 56.75664861182606, "seed": 332013104, "groupIds": [ "PkHndZTqTLVxxXgSDZxvW" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "-Iu1CEC1DMo3CeL80AL-H", "type": "text" }, { "id": "43_r9mRXWwrQYCAcyRAFA", "type": "arrow" } ], "updated": 1723753719986, "link": null, "locked": false }, { "type": "text", "version": 911, "versionNonce": 609235504, "index": "b0B", "isDeleted": false, "id": "-Iu1CEC1DMo3CeL80AL-H", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 1411.374176072556, "y": 205.23930332613702, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 105.51689147949219, "height": 28.957473781543904, "seed": 457627696, "groupIds": [ "PkHndZTqTLVxxXgSDZxvW" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723753715755, "link": null, "locked": false, "fontSize": 23.165979025235124, "fontFamily": 5, "text": "Callable 3", "textAlign": "center", "verticalAlign": "middle", "containerId": "KWb-DkqaKPnpVL7f6V4bL", "originalText": "Callable 3", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 621, "versionNonce": 1737071312, "index": "b0C", "isDeleted": false, "id": "ICGWc2_oP7ooflk821IPB", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1000.4847504534591, "y": 44.85883247767296, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 1.021474072241972, "height": 126.31065788274074, "seed": 1934117584, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "startBinding": { "elementId": "1-nChtDmy9xBSz25x5Tdy", "focus": 0.06905856012815444, "gap": 3.99435700483167, "fixedPoint": null }, "endBinding": { "elementId": "s89xH-4yu5r6IAP8S9KE8", "focus": -0.14186582145959503, "gap": 4.479043609614649, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 1.021474072241972, 126.31065788274074 ] ], "elbowed": false }, { "type": "arrow", "version": 49, "versionNonce": 1153557200, "index": "b0D", "isDeleted": false, "id": "WYORfi6MQ5vqe_kGKqXHJ", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1423.6927813940347, "y": -35.91522196403305, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 93.20078197532757, "height": 37.93587818765758, "seed": 969630768, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "startBinding": { "elementId": "679z3nf1w9xVLbf3f8AdN", "focus": 0.815379469998369, "gap": 6.560548878821066, "fixedPoint": null }, "endBinding": { "elementId": "PEU_XU8Sh3D4biyxvy4HU", "focus": 0.6089969123958293, "gap": 1.9020541028933877, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 93.20078197532757, -37.93587818765758 ] ], "elbowed": false }, { "type": "arrow", "version": 52, "versionNonce": 2009329872, "index": "b0E", "isDeleted": false, "id": "43_r9mRXWwrQYCAcyRAFA", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1437.9674926622693, "y": 140.24593205610267, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 9.254842268716857, "height": 52.1801369969441, "seed": 1634053840, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757446539, "link": null, "locked": false, "startBinding": { "elementId": "vsA_CIfMv5pa_E8ntMeiT", "focus": 0.005781863342302937, "gap": 2.5808713031511914, "fixedPoint": null }, "endBinding": { "elementId": "KWb-DkqaKPnpVL7f6V4bL", "focus": -0.12844787041016997, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 9.254842268716857, 52.1801369969441 ] ], "elbowed": false }, { "type": "ellipse", "version": 1354, "versionNonce": 1998775504, "index": "b0F", "isDeleted": false, "id": "g3vzES2f7nTxgtOAQHxlD", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.08267458081857892, "x": 883.3984270653652, "y": 445.3473162712001, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 172.18810636886062, "height": 51.92460878852706, "seed": 602337488, "groupIds": [ "Z2WNd7VHjMQwMoXLdzYAJ" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "meTPYjzpBqS9qJaNPrOwm" }, { "id": "jtmxjvYMT8IyTg6YsEhEo", "type": "arrow" } ], "updated": 1723757403784, "link": null, "locked": false }, { "type": "text", "version": 1352, "versionNonce": 424861392, "index": "b0G", "isDeleted": false, "id": "meTPYjzpBqS9qJaNPrOwm", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0.08267458081857892, "x": 921.58768418448, "y": 458.205425502508, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 96.05421447753906, "height": 26.492147341085236, "seed": 1097881296, "groupIds": [ "Z2WNd7VHjMQwMoXLdzYAJ" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757402200, "link": null, "locked": false, "fontSize": 21.193717872868188, "fontFamily": 5, "text": "Callable 4", "textAlign": "center", "verticalAlign": "middle", "containerId": "g3vzES2f7nTxgtOAQHxlD", "originalText": "Callable 4", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 698, "versionNonce": 1161193168, "index": "b0H", "isDeleted": false, "id": "jtmxjvYMT8IyTg6YsEhEo", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 762.4465004005942, "y": 423.5712587597044, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 123.52352373817519, "height": 38.4442066961376, "seed": 20683984, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757421788, "link": null, "locked": false, "startBinding": { "elementId": "mp5cf4rOe7-TBZhYPXY93", "focus": 0.3656434333139051, "gap": 1, "fixedPoint": null }, "endBinding": { "elementId": "g3vzES2f7nTxgtOAQHxlD", "focus": -0.506230526903665, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 123.52352373817519, 38.4442066961376 ] ], "elbowed": false }, { "type": "ellipse", "version": 661, "versionNonce": 383714512, "index": "b0N", "isDeleted": false, "id": "_ov2Za4vMppZrq6E8DrGP", "fillStyle": "hachure", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 151.76561742716592, "y": 952.6445385915624, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 222.1171875, "height": 153.02734374999997, "seed": 1113625136, "groupIds": [ "bkA1fovZjPU8kOsGeKQ3-" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "TaKI_Zsm5EtJ2RAoG-piT", "type": "arrow" } ], "updated": 1723754636194, "link": null, "locked": false }, { "type": "text", "version": 681, "versionNonce": 803213520, "index": "b0O", "isDeleted": false, "id": "1mKnrf8qs4l8Oo-0yvtPA", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 161.04686742716592, "y": 1016.0898510915624, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 192.65985107421875, "height": 25, "seed": 384748592, "groupIds": [ "bkA1fovZjPU8kOsGeKQ3-" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754636194, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "AioClock Application", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "AioClock Application", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 600, "versionNonce": 1145432784, "index": "b0P", "isDeleted": false, "id": "TaKI_Zsm5EtJ2RAoG-piT", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 366.722107585323, "y": 1070.018365180686, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 201.3027018317324, "height": 88.58508972177424, "seed": 1313068752, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754636194, "link": null, "locked": false, "startBinding": { "elementId": "_ov2Za4vMppZrq6E8DrGP", "focus": -0.05354174431970359, "gap": 7.743665962249281, "fixedPoint": null }, "endBinding": { "elementId": "pFHt3Tj9bXJpp0z2vze_z", "focus": -0.05824999527775626, "gap": 6.017602848102115, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 201.3027018317324, 88.58508972177424 ] ], "elbowed": false }, { "type": "rectangle", "version": 363, "versionNonce": 1642239184, "index": "b0U", "isDeleted": false, "id": "pFHt3Tj9bXJpp0z2vze_z", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 490.9445948885584, "y": 1164.6210577505624, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 192.7956882911392, "height": 126.8196202531644, "seed": 761130192, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "97hUh-7kc56-J9lFD3Dlz" }, { "id": "TaKI_Zsm5EtJ2RAoG-piT", "type": "arrow" }, { "id": "HUMibXR2jjlLuG3kA-AVo", "type": "arrow" } ], "updated": 1723754634921, "link": null, "locked": false }, { "type": "text", "version": 392, "versionNonce": 268047568, "index": "b0V", "isDeleted": false, "id": "97hUh-7kc56-J9lFD3Dlz", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 499.052514412546, "y": 1190.5308678771446, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 176.57984924316406, "height": 75, "seed": 1653197520, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754844423, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Collect all \nassociated tasks \nto group or itself", "textAlign": "center", "verticalAlign": "middle", "containerId": "pFHt3Tj9bXJpp0z2vze_z", "originalText": "Collect all associated tasks to group or itself", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 355, "versionNonce": 379821616, "index": "b0W", "isDeleted": false, "id": "PP-ng3MaLs-Vkm56oZm-j", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 66.92703154630999, "y": 1245.7362012582953, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 215.98101265822788, "height": 220, "seed": 729412304, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "HUMibXR2jjlLuG3kA-AVo", "type": "arrow" }, { "type": "text", "id": "sHlsZZl8Ld-3G8tEpmfC8" }, { "id": "tFQlXQLJobY8BmOyCFy-C", "type": "arrow" }, { "id": "bSYZ54fDSJlCiYxFIoqY3", "type": "arrow" } ], "updated": 1723754297337, "link": null, "locked": false }, { "type": "text", "version": 148, "versionNonce": 1402237136, "index": "b0WV", "isDeleted": false, "id": "sHlsZZl8Ld-3G8tEpmfC8", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 130.18230973528102, "y": 1305.7362012582953, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 89.47994995117188, "height": 100, "seed": 1549808848, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754034163, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Is there \nany \nstartup \ntask?", "textAlign": "center", "verticalAlign": "middle", "containerId": "PP-ng3MaLs-Vkm56oZm-j", "originalText": "Is there any startup task?", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 434, "versionNonce": 1276378672, "index": "b0X", "isDeleted": false, "id": "HUMibXR2jjlLuG3kA-AVo", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 479.3000141923558, "y": 1233.7891803622645, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 259.49965686083056, "height": 31.466995486930045, "seed": 890192080, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756062790, "link": null, "locked": false, "startBinding": { "elementId": "pFHt3Tj9bXJpp0z2vze_z", "focus": -0.024001291159690904, "gap": 11.644580696202638, "fixedPoint": null }, "endBinding": { "elementId": "PP-ng3MaLs-Vkm56oZm-j", "focus": -0.7375903662329082, "gap": 18.35325627629757, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -132.86603922833922, 5.100516049794578 ], [ -259.49965686083056, 31.466995486930045 ] ], "elbowed": false }, { "type": "arrow", "version": 281, "versionNonce": 1258562608, "index": "b0Y", "isDeleted": false, "id": "tFQlXQLJobY8BmOyCFy-C", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 287.56539981706385, "y": 1370.7373442086102, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 186.4989873674889, "height": 78.94752973813888, "seed": 73693232, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756074327, "link": null, "locked": false, "startBinding": { "elementId": "PP-ng3MaLs-Vkm56oZm-j", "focus": -0.2971292009789168, "gap": 13.832646064717963, "fixedPoint": null }, "endBinding": { "elementId": "n9ToP1sTho6bZwvzaSX1v", "focus": 0.24979688288014204, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 186.4989873674889, 78.94752973813888 ] ], "elbowed": false }, { "type": "rectangle", "version": 178, "versionNonce": 1493577776, "index": "b0Z", "isDeleted": false, "id": "n9ToP1sTho6bZwvzaSX1v", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 474.5694147504952, "y": 1446.1572874752337, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 186.3429588607594, "height": 141.38647151898752, "seed": 526215728, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "tFQlXQLJobY8BmOyCFy-C", "type": "arrow" }, { "type": "text", "id": "xqy3QNcMbTQdr-7Y-CAPu" }, { "id": "K6tLFhiw5Uin1d-gyZMMa", "type": "arrow" } ], "updated": 1723755086801, "link": null, "locked": false }, { "type": "text", "version": 277, "versionNonce": 1410606288, "index": "b0ZG", "isDeleted": false, "id": "xqy3QNcMbTQdr-7Y-CAPu", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 487.16095031533723, "y": 1491.8505232347275, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 161.1598877310753, "height": 50, "seed": 1089100336, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723755913354, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Run the task, \nwith task runner", "textAlign": "center", "verticalAlign": "middle", "containerId": "n9ToP1sTho6bZwvzaSX1v", "originalText": "Run the task, with task runner", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 7, "versionNonce": 1431708368, "index": "b0a", "isDeleted": false, "id": "toXI5aAPeghGS-UojT5Dr", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 498.18197616656346, "y": 1544.9994528405732, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 0, "height": 0, "seed": 45031120, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754060374, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 0, 0 ] ], "elbowed": false }, { "type": "text", "version": 189, "versionNonce": 1948207152, "index": "b0b", "isDeleted": false, "id": "HEyjXhFgmGSg4l_cVGwpo", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.38252949035119954, "x": 367.22159326534563, "y": 1362.8041428944932, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 40.7856642036145, "height": 31.587420437145056, "seed": 1679624912, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756076678, "link": null, "locked": false, "fontSize": 25.26993634971605, "fontFamily": 5, "text": "Yes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Yes", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 262, "versionNonce": 108425776, "index": "b0c", "isDeleted": false, "id": "bSYZ54fDSJlCiYxFIoqY3", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 120.14961629042963, "y": 1427.5564598860278, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 30.311807014930793, "height": 138.16917102811112, "seed": 1295594544, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754482167, "link": null, "locked": false, "startBinding": { "elementId": "PP-ng3MaLs-Vkm56oZm-j", "focus": 0.6529344937699867, "gap": 12.334950998376627, "fixedPoint": null }, "endBinding": { "elementId": "62AnXeQAIeDdvmIAHLsql", "focus": 0.18626692128435784, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 30.311807014930793, 138.16917102811112 ] ], "elbowed": false }, { "type": "text", "version": 225, "versionNonce": 1193407536, "index": "b0d", "isDeleted": false, "id": "UB0T4WsJRhDst_ROu99CV", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.193731223285352, "x": 69.15357436383988, "y": 1481.907016430885, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 35.288696305989404, "height": 35.68840598676002, "seed": 1811917360, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754219032, "link": null, "locked": false, "fontSize": 28.550724789408026, "fontFamily": 5, "text": "No", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "No", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 202, "versionNonce": 1599069744, "index": "b0g", "isDeleted": false, "id": "K6tLFhiw5Uin1d-gyZMMa", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 473.5694147504952, "y": 1577.2840394159837, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 222.31777593586875, "height": 74.27226068666982, "seed": 658627120, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723755086801, "link": null, "locked": false, "startBinding": { "elementId": "n9ToP1sTho6bZwvzaSX1v", "focus": -0.2845466033490556, "gap": 1, "fixedPoint": null }, "endBinding": { "elementId": "62AnXeQAIeDdvmIAHLsql", "focus": 0.09532978924614155, "gap": 7.742822789853733, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -222.31777593586875, 74.27226068666982 ] ], "elbowed": false }, { "type": "text", "version": 522, "versionNonce": 1066451664, "index": "b0i", "isDeleted": false, "id": "NTr0E-uAaLqNxcqR-y62m", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 5.950908548183436, "x": 259.0621979655733, "y": 1572.151673564935, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 204.45667309363057, "height": 24.853187128468736, "seed": 393975504, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754267288, "link": null, "locked": false, "fontSize": 19.88254970277498, "fontFamily": 5, "text": "Eventually it finishes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Eventually it finishes", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 415, "versionNonce": 1831616048, "index": "b0j", "isDeleted": false, "id": "62AnXeQAIeDdvmIAHLsql", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 46.010126614696446, "y": 1563.5479955472367, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 215.98101265822788, "height": 220, "seed": 2045036240, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "-ThMjAXaf2xYE8np1dZMC" }, { "id": "bSYZ54fDSJlCiYxFIoqY3", "type": "arrow" }, { "id": "K6tLFhiw5Uin1d-gyZMMa", "type": "arrow" }, { "id": "F8dKp3225ek0XJx7j5nTi", "type": "arrow" } ], "updated": 1723754482166, "link": null, "locked": false }, { "type": "text", "version": 215, "versionNonce": 1130690608, "index": "b0k", "isDeleted": false, "id": "-ThMjAXaf2xYE8np1dZMC", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 106.71540282479327, "y": 1636.0479955472367, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 94.57995390892029, "height": 75, "seed": 88304848, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754482166, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Is there \nany other\ntask?", "textAlign": "center", "verticalAlign": "middle", "containerId": "62AnXeQAIeDdvmIAHLsql", "originalText": "Is there any other task?", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 73, "versionNonce": 1371776560, "index": "b0l", "isDeleted": false, "id": "F8dKp3225ek0XJx7j5nTi", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 261.20140736398764, "y": 1700.7333461944968, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 177.29937870676588, "height": 111.56354900062706, "seed": 1441168592, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754482167, "link": null, "locked": false, "startBinding": { "elementId": "62AnXeQAIeDdvmIAHLsql", "focus": -0.36608614517760474, "gap": 18.481385916632377, "fixedPoint": null }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 177.29937870676588, 111.56354900062706 ] ], "elbowed": false }, { "type": "rectangle", "version": 208, "versionNonce": 978137296, "index": "b0m", "isDeleted": false, "id": "pTPLz0pnwCGZc1m9XPrVU", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 444.23109718020464, "y": 1790.604336075719, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 186.3429588607594, "height": 141.38647151898752, "seed": 1098873904, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "Jx4puZXo9muudZA7Is-ul" }, { "id": "hsgEKlKrummvl8bI0o12-", "type": "arrow" } ], "updated": 1723755916084, "link": null, "locked": false }, { "type": "text", "version": 284, "versionNonce": 1694913744, "index": "b0n", "isDeleted": false, "id": "Jx4puZXo9muudZA7Is-ul", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 456.8226327450467, "y": 1836.2975718352127, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 161.1598877310753, "height": 50, "seed": 1721792048, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723755917136, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Run the task, \nwith task runner", "textAlign": "center", "verticalAlign": "middle", "containerId": "pTPLz0pnwCGZc1m9XPrVU", "originalText": "Run the task, with task runner", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 233, "versionNonce": 785521712, "index": "b0o", "isDeleted": false, "id": "AZ8Lb1Qf-UBPg__N6Ni3k", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.5690110473277814, "x": 354.8587843212615, "y": 1727.2910370892278, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 40.7856642036145, "height": 31.587420437145056, "seed": 471380016, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754472533, "link": null, "locked": false, "fontSize": 25.26993634971605, "fontFamily": 5, "text": "Yes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Yes", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 308, "versionNonce": 1701565648, "index": "b0p", "isDeleted": false, "id": "tgY3B-VJozPI8jwqQqydk", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 155.4336876594124, "y": 1780.766675660775, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 30.611100340859906, "height": 139.6509163254666, "seed": 257645264, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754456616, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 30.611100340859906, 139.6509163254666 ] ], "elbowed": false }, { "type": "text", "version": 272, "versionNonce": 1220066000, "index": "b0q", "isDeleted": false, "id": "PcRpuw1zwSsRVNUhXieye", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.193731223285352, "x": 104.43764573282266, "y": 1835.1172322056318, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 35.288696305989404, "height": 35.68840598676002, "seed": 1455915216, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754456617, "link": null, "locked": false, "fontSize": 28.550724789408026, "fontFamily": 5, "text": "No", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "No", "autoResize": true, "lineHeight": 1.25 }, { "type": "diamond", "version": 479, "versionNonce": 1734766640, "index": "b0r", "isDeleted": false, "id": "j6CjjAOcI4Pq1MVQ1M24r", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 66.74944636156056, "y": 1929.4260765331521, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 215.98101265822788, "height": 220, "seed": 909420752, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "n5-9AXrgT7uZ3e_HhcKwI" }, { "id": "hsgEKlKrummvl8bI0o12-", "type": "arrow" }, { "id": "8-EVaSqf6ZR64L11bet06", "type": "arrow" } ], "updated": 1723754520983, "link": null, "locked": false }, { "type": "text", "version": 299, "versionNonce": 515520560, "index": "b0s", "isDeleted": false, "id": "n5-9AXrgT7uZ3e_HhcKwI", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 128.39473156957456, "y": 1989.4260765331521, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 92.69993591308594, "height": 100, "seed": 826740432, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754452537, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Is there \nany \nshutdown\ntask?", "textAlign": "center", "verticalAlign": "middle", "containerId": "j6CjjAOcI4Pq1MVQ1M24r", "originalText": "Is there any shutdown task?", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 264, "versionNonce": 1454587088, "index": "b0t", "isDeleted": false, "id": "hsgEKlKrummvl8bI0o12-", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 487.1046231869103, "y": 1939.953951382775, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 199.08000455324208, "height": 98.80596639902069, "seed": 371455696, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723755916084, "link": null, "locked": false, "startBinding": { "elementId": "pTPLz0pnwCGZc1m9XPrVU", "focus": -0.45916668906629854, "gap": 7.963143788068464, "fixedPoint": null }, "endBinding": { "elementId": "j6CjjAOcI4Pq1MVQ1M24r", "focus": 0.5126982685313142, "gap": 5.335906061696036, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -199.08000455324208, 98.80596639902069 ] ], "elbowed": false }, { "type": "text", "version": 697, "versionNonce": 572080176, "index": "b0u", "isDeleted": false, "id": "yww7vnZfDtkn23qZV_9MI", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 5.847034041833071, "x": 250.532589632297, "y": 1953.4169380927945, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 204.45667309363057, "height": 24.853187128468736, "seed": 840735952, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754414914, "link": null, "locked": false, "fontSize": 19.88254970277498, "fontFamily": 5, "text": "Eventually it finishes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Eventually it finishes", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 399, "versionNonce": 361233616, "index": "b0w", "isDeleted": false, "id": "X-0iInYecUG1BjmK_DZBY", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 5.811901839719698, "x": 298.9133895614428, "y": 2003.2861778230986, "strokeColor": "#e03131", "backgroundColor": "#fff9db", "width": 229.47476490363073, "height": 25.90321232416628, "seed": 1632365104, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756400828, "link": null, "locked": false, "fontSize": 20.722569859333024, "fontFamily": 5, "text": "Or graceful shutdown", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Or graceful shutdown", "autoResize": false, "lineHeight": 1.25 }, { "type": "arrow", "version": 382, "versionNonce": 1475396304, "index": "b0x", "isDeleted": false, "id": "8-EVaSqf6ZR64L11bet06", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 179.27769129424638, "y": 2147.664404904931, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 17.031897197660243, "height": 135.17219951404422, "seed": 1723002064, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723754619242, "link": null, "locked": false, "startBinding": { "elementId": "j6CjjAOcI4Pq1MVQ1M24r", "focus": 0.13740406747958717, "gap": 2.003952060075946, "fixedPoint": null }, "endBinding": { "elementId": "mqiZadO7gUjCdwEG95rN-", "focus": 0.08481186725898551, "gap": 19.5792698927537, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 17.031897197660243, 135.17219951404422 ] ], "elbowed": false }, { "type": "text", "version": 328, "versionNonce": 874861264, "index": "b0y", "isDeleted": false, "id": "sliZS3n4NM5VWGWLk07qL", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.193731223285352, "x": 131.1640305530422, "y": 2195.4529447085906, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 35.288696305989404, "height": 35.68840598676002, "seed": 1096602320, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754523120, "link": null, "locked": false, "fontSize": 28.550724789408026, "fontFamily": 5, "text": "No", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "No", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 611, "versionNonce": 1697792048, "index": "b0z", "isDeleted": false, "id": "jlL5CJ4DT5zCd_RjrZb1x", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 259.14555139206345, "y": 2089.8461994879863, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 185.6005502713267, "height": 92.47327948141628, "seed": 1638863568, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723755096802, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "SLDezLygbRJ2VvbEepnBf", "focus": 0.2602874090619763, "gap": 1, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 185.6005502713267, 92.47327948141628 ] ], "elbowed": false }, { "type": "rectangle", "version": 452, "versionNonce": 1390644272, "index": "b10", "isDeleted": false, "id": "SLDezLygbRJ2VvbEepnBf", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 434.2855099184445, "y": 2183.3194789694026, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 186.3429588607594, "height": 141.38647151898752, "seed": 367278288, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "jlL5CJ4DT5zCd_RjrZb1x", "type": "arrow" }, { "type": "text", "id": "QA47B66xhfSSWURPoI2Wj" }, { "id": "I3Wcv2heCBjVL8wJ7cxju", "type": "arrow" } ], "updated": 1723755096802, "link": null, "locked": false }, { "type": "text", "version": 525, "versionNonce": 778602544, "index": "b11", "isDeleted": false, "id": "QA47B66xhfSSWURPoI2Wj", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 446.87704548328657, "y": 2229.0127147288963, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 161.1598877310753, "height": 50, "seed": 344235728, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723755919085, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Run the task, \nwith task runner", "textAlign": "center", "verticalAlign": "middle", "containerId": "SLDezLygbRJ2VvbEepnBf", "originalText": "Run the task, with task runner", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 339, "versionNonce": 1846783696, "index": "b12", "isDeleted": false, "id": "kcV9WES9r3Hgum1vq3lSr", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.49425269310474107, "x": 399.1123171761782, "y": 2116.967520578905, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 40.7856642036145, "height": 31.587420437145056, "seed": 1348176592, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754505234, "link": null, "locked": false, "fontSize": 25.26993634971605, "fontFamily": 5, "text": "Yes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Yes", "autoResize": true, "lineHeight": 1.25 }, { "type": "rectangle", "version": 398, "versionNonce": 915588656, "index": "b13", "isDeleted": false, "id": "mqiZadO7gUjCdwEG95rN-", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 120.53834260665997, "y": 2302.415874311729, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 157.7848984637119, "height": 127.39363519702648, "seed": 699762384, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "Wn6LI4a9xruvOuYApEe8l" }, { "id": "8-EVaSqf6ZR64L11bet06", "type": "arrow" }, { "id": "I3Wcv2heCBjVL8wJ7cxju", "type": "arrow" } ], "updated": 1723754621541, "link": null, "locked": false }, { "type": "text", "version": 472, "versionNonce": 1023728848, "index": "b14", "isDeleted": false, "id": "Wn6LI4a9xruvOuYApEe8l", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 178.48080251966826, "y": 2353.6126919102426, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 41.89997863769531, "height": 25, "seed": 2096901328, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754619242, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Exit", "textAlign": "center", "verticalAlign": "middle", "containerId": "mqiZadO7gUjCdwEG95rN-", "originalText": "Exit", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 399, "versionNonce": 257604144, "index": "b15", "isDeleted": false, "id": "I3Wcv2heCBjVL8wJ7cxju", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 520.6298569442847, "y": 2334.4068649622322, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 228.38910390384757, "height": 45.70347845481592, "seed": 1391728848, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723755096802, "link": null, "locked": false, "startBinding": { "elementId": "SLDezLygbRJ2VvbEepnBf", "focus": -0.8845951098115866, "gap": 9.700914473842204, "fixedPoint": null }, "endBinding": { "elementId": "mqiZadO7gUjCdwEG95rN-", "focus": 0.4096596647723168, "gap": 13.917511970065277, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -228.38910390384757, 45.70347845481592 ] ], "elbowed": false }, { "type": "text", "version": 841, "versionNonce": 89012432, "index": "b16", "isDeleted": false, "id": "UP2QFcgsa8fPXFOeQWORR", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.056032201216738, "x": 324.87050612396354, "y": 2388.028964675776, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 204.45667309363057, "height": 24.853187128468736, "seed": 1687715536, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754533104, "link": null, "locked": false, "fontSize": 19.88254970277498, "fontFamily": 5, "text": "Eventually it finishes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Eventually it finishes", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 225, "versionNonce": 705319984, "index": "b17", "isDeleted": false, "id": "AdDzyQ-BQk5kpMri0E72R", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.3899896189126908, "x": 389.28497787797653, "y": 1072.2854853849283, "strokeColor": "#e03131", "backgroundColor": "#fff9db", "width": 238.16266248983007, "height": 29.68216517673118, "seed": 684030672, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723754643543, "link": null, "locked": false, "fontSize": 23.74573214138495, "fontFamily": 5, "text": "await app.serve()", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "await app.serve()", "autoResize": false, "lineHeight": 1.25 }, { "type": "rectangle", "version": 832, "versionNonce": 1154620112, "index": "b1X", "isDeleted": false, "id": "IQuQeqT5jHaGToLEJeScY", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1698.9602512692113, "y": 871.1782058755693, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 349.58787444021567, "height": 241.12794293132154, "seed": 737447472, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "-GFO_l2VPtxG5Bc-I2kuy", "type": "text" } ], "updated": 1723756979113, "link": null, "locked": false }, { "type": "text", "version": 890, "versionNonce": 795230416, "index": "b1XV", "isDeleted": false, "id": "-GFO_l2VPtxG5Bc-I2kuy", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1816.0542373174442, "y": 879.2421773412301, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 115.39990234375, "height": 225, "seed": 1029764816, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756979113, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Task runner\n\n\n\n\n\n\n\n", "textAlign": "center", "verticalAlign": "middle", "containerId": "IQuQeqT5jHaGToLEJeScY", "originalText": "Task runner\n\n\n\n\n\n\n\n", "autoResize": true, "lineHeight": 1.25 }, { "type": "rectangle", "version": 1207, "versionNonce": 209652432, "index": "b1XZ", "isDeleted": false, "id": "CqiCDYYzdeFbt7hIPh6Ej", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1705.7889969339021, "y": 918.7187504179244, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 263.3592677540476, "height": 184, "seed": 1630399024, "groupIds": [ "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "fDd6ctG9MLdC4BAe3UjnL" }, { "id": "fXI-ZUZHdOLNfBJ2k9IaU", "type": "arrow" } ], "updated": 1723756982309, "link": null, "locked": false }, { "type": "text", "version": 1228, "versionNonce": 80235728, "index": "b1Xd", "isDeleted": false, "id": "fDd6ctG9MLdC4BAe3UjnL", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1813.535235425184, "y": 923.7484501700878, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 47.866790771484375, "height": 173.940600495673, "seed": 1530374864, "groupIds": [ "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756979114, "link": null, "locked": false, "fontSize": 19.878925770934057, "fontFamily": 5, "text": "Task\n\n\n\n\n\n", "textAlign": "center", "verticalAlign": "middle", "containerId": "CqiCDYYzdeFbt7hIPh6Ej", "originalText": "Task\n\n\n\n\n\n", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 2108, "versionNonce": 902181584, "index": "b1Xh", "isDeleted": false, "id": "y2kyTs8L2fDyChRo01Siv", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.0790450627558883, "x": 1791.3734901428243, "y": 974.7988184591122, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 161.5060937244159, "height": 49, "seed": 641814224, "groupIds": [ "N_rYhw0CcYfJ8hWSOuPFa", "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "7Q0nkPX6LxcrTKt8mS743" } ], "updated": 1723756979114, "link": null, "locked": false }, { "type": "text", "version": 2137, "versionNonce": 437737680, "index": "b1Xl", "isDeleted": false, "id": "7Q0nkPX6LxcrTKt8mS743", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0.0790450627558883, "x": 1836.7662050861936, "y": 987.050373713208, "strokeColor": "#1e1e1e", "backgroundColor": "#12b886", "width": 70.51860976219177, "height": 24.848657213667572, "seed": 80671952, "groupIds": [ "N_rYhw0CcYfJ8hWSOuPFa", "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756979114, "link": null, "locked": false, "fontSize": 19.878925770934057, "fontFamily": 5, "text": "Callable", "textAlign": "center", "verticalAlign": "middle", "containerId": "y2kyTs8L2fDyChRo01Siv", "originalText": "Callable", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 2022, "versionNonce": 1699987152, "index": "b1Xp", "isDeleted": false, "id": "XfSzjMLNwUPk9AVXRq5-c", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.039362398350647254, "x": 1710.740412101144, "y": 1026.350749168779, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 161.5060937244159, "height": 49, "seed": 79611952, "groupIds": [ "6zcuGwXIAAuVEqpx03rbO", "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "lt43g2NolrgpCin3XiLSR" } ], "updated": 1723756979114, "link": null, "locked": false }, { "type": "text", "version": 2045, "versionNonce": 403924176, "index": "b1Xt", "isDeleted": false, "id": "lt43g2NolrgpCin3XiLSR", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0.039362398350647254, "x": 1756.5007388103759, "y": 1038.6023044228748, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "width": 69.78338623046875, "height": 24.848657213667572, "seed": 471220784, "groupIds": [ "6zcuGwXIAAuVEqpx03rbO", "c6BWuvFGG24TyGmFHy9p8" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756979114, "link": null, "locked": false, "fontSize": 19.878925770934057, "fontFamily": 5, "text": "Trigger", "textAlign": "center", "verticalAlign": "middle", "containerId": "XfSzjMLNwUPk9AVXRq5-c", "originalText": "Trigger", "autoResize": true, "lineHeight": 1.25 }, { "type": "rectangle", "version": 879, "versionNonce": 1474880560, "index": "b1i", "isDeleted": false, "id": "HyP7BZJVo6tJy8wPmNzbl", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1554.5497932146895, "y": 1158.5511408762293, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 239.06313174761198, "height": 126.8196202531644, "seed": 1474292272, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "LbyFqCoQTmvTdC5VlJe9P", "type": "arrow" }, { "id": "iOCvXYA2L16R_vT2aAdFu", "type": "text" }, { "id": "fXI-ZUZHdOLNfBJ2k9IaU", "type": "arrow" } ], "updated": 1723757164556, "link": null, "locked": false }, { "type": "text", "version": 993, "versionNonce": 1000136240, "index": "b1j", "isDeleted": false, "id": "iOCvXYA2L16R_vT2aAdFu", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1585.3614189029486, "y": 1196.9609510028115, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 177.43988037109375, "height": 50, "seed": 1925391408, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757164556, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Get a task as an \nargument", "textAlign": "center", "verticalAlign": "middle", "containerId": "HyP7BZJVo6tJy8wPmNzbl", "originalText": "Get a task as an argument", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 849, "versionNonce": 985765936, "index": "b1m", "isDeleted": false, "id": "LbyFqCoQTmvTdC5VlJe9P", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1696.6044829064658, "y": 1288.841905217944, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 0.8193286928735688, "height": 53.678002679886276, "seed": 1633266224, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757164556, "link": null, "locked": false, "startBinding": { "elementId": "HyP7BZJVo6tJy8wPmNzbl", "focus": -0.17844289194268018, "gap": 3.471144088550318, "fixedPoint": null }, "endBinding": { "elementId": "FCwcIAfeLbeJg6Zi8BVKM", "focus": -0.009745033877159017, "gap": 3.402997661688971, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 0.8193286928735688, 53.678002679886276 ] ], "elbowed": false }, { "type": "diamond", "version": 701, "versionNonce": 463820848, "index": "b1s", "isDeleted": false, "id": "FCwcIAfeLbeJg6Zi8BVKM", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1536.3487797743226, "y": 1345.0150380286043, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 327.0918259068212, "height": 192.18989248354546, "seed": 490909392, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "XIj9uOXTaxVQuFWHAh8t2" }, { "id": "STikmhs29bpStOM3nvBoD", "type": "arrow" }, { "id": "3rHRwfJtVAuBBE1kyGmdt", "type": "arrow" }, { "id": "2hRCzj2mZlHhWGt_WPdv1", "type": "arrow" }, { "id": "LbyFqCoQTmvTdC5VlJe9P", "type": "arrow" } ], "updated": 1723756891654, "link": null, "locked": false }, { "type": "text", "version": 449, "versionNonce": 308836560, "index": "b1t", "isDeleted": false, "id": "XIj9uOXTaxVQuFWHAh8t2", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1630.9617936240747, "y": 1403.5625111494905, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 138.31988525390625, "height": 75, "seed": 1420055760, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756890860, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Check if task \nstill can be \ntriggered", "textAlign": "center", "verticalAlign": "middle", "containerId": "FCwcIAfeLbeJg6Zi8BVKM", "originalText": "Check if task still can be triggered", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 1103, "versionNonce": 1760627760, "index": "b1u", "isDeleted": false, "id": "3rHRwfJtVAuBBE1kyGmdt", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1850.808229807619, "y": 1461.3902677755318, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 282.17700712382043, "height": 305.359935601738, "seed": 1682829008, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756891655, "link": null, "locked": false, "startBinding": { "elementId": "FCwcIAfeLbeJg6Zi8BVKM", "focus": -0.12884470113660396, "gap": 11.085840774530027, "fixedPoint": null }, "endBinding": { "elementId": "w7B3jgdlWOsnxlvPngEQD", "focus": 0.25339453076017165, "gap": 9.103550868112734, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 282.17700712382043, 61.07043151696416 ], [ 273.33977612188505, 305.359935601738 ] ], "elbowed": false }, { "type": "text", "version": 517, "versionNonce": 263527120, "index": "b1y", "isDeleted": false, "id": "jkUc63EEwi0tX_KG4pnmP", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.06053892542880668, "x": 1987.9618247165897, "y": 1449.2329700567975, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 24.720000326633453, "height": 25, "seed": 1910569520, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756836518, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "No", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "No", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 811, "versionNonce": 1171962416, "index": "b1z", "isDeleted": false, "id": "FFibWCRbM0ac15VquIccI", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0.13743315829613412, "x": 1856.1019957293356, "y": 1506.6127252537526, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 294.84916554571714, "height": 66.19013114735047, "seed": 77767216, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "3rHRwfJtVAuBBE1kyGmdt", "type": "arrow" } ], "updated": 1723756836518, "link": null, "locked": false, "fontSize": 17.650701639293455, "fontFamily": 5, "text": "1. graceful shutdown\n2. Sometimes task are n time run \nonly (like startup task run once)", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "1. graceful shutdown\n2. Sometimes task are n time run only (like startup task run once)", "autoResize": false, "lineHeight": 1.25 }, { "type": "rectangle", "version": 913, "versionNonce": 1087998512, "index": "b21", "isDeleted": false, "id": "w7B3jgdlWOsnxlvPngEQD", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1996.7259811735935, "y": 1775.8537542453826, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 198.5683942456583, "height": 116.9088698549408, "seed": 438287920, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "7XMxNA8RXkBPcSKKEXWIO" }, { "id": "3rHRwfJtVAuBBE1kyGmdt", "type": "arrow" } ], "updated": 1723756836518, "link": null, "locked": false }, { "type": "text", "version": 1045, "versionNonce": 1529802448, "index": "b22", "isDeleted": false, "id": "7XMxNA8RXkBPcSKKEXWIO", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 2004.1902527497766, "y": 1809.308189172853, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 183.63985109329224, "height": 50, "seed": 99121200, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756836519, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Return in function.\nTask is finished", "textAlign": "center", "verticalAlign": "middle", "containerId": "w7B3jgdlWOsnxlvPngEQD", "originalText": "Return in function.\nTask is finished", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 136, "versionNonce": 1856546512, "index": "b23", "isDeleted": false, "id": "Uq1j2EwKkxp5mcs2g9yQf", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1629.8841019714973, "y": 1563.2230944946575, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 49.21613603094943, "height": 25, "seed": 1579427536, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756836518, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Yes", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Yes", "autoResize": false, "lineHeight": 1.25 }, { "type": "rectangle", "version": 804, "versionNonce": 2007851728, "index": "b26", "isDeleted": false, "id": "eYJWFucNr-z4ZnArvQ-Zr", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1571.2363939435845, "y": 1626.8059576040923, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 273.58068072975556, "height": 160, "seed": 886513712, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "irv12xCSy44n3nsd-C1hX" }, { "id": "2hRCzj2mZlHhWGt_WPdv1", "type": "arrow" }, { "id": "yd_IMWqbdG2nufqbzBVma", "type": "arrow" } ], "updated": 1723756836518, "link": null, "locked": false }, { "type": "text", "version": 1028, "versionNonce": 1988840656, "index": "b27", "isDeleted": false, "id": "irv12xCSy44n3nsd-C1hX", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1579.7768258611966, "y": 1644.3059576040923, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 256.49981689453125, "height": 125, "seed": 1466145328, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756836518, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Wait until the task get \ntriggered \n(e.x if cron job, then \nshould sleep for next cron\ninterval)", "textAlign": "center", "verticalAlign": "middle", "containerId": "eYJWFucNr-z4ZnArvQ-Zr", "originalText": "Wait until the task get triggered \n(e.x if cron job, then should sleep for next cron interval)", "autoResize": true, "lineHeight": 1.25 }, { "type": "rectangle", "version": 897, "versionNonce": 1387080752, "index": "b2A", "isDeleted": false, "id": "JxDqrhu671kLDTdcOGeyZ", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1598.090109389605, "y": 1892.713229888806, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 222.06468710355526, "height": 101.80408496883595, "seed": 2117406928, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "fGfM8hH-3f8NYSSbkKzuB" }, { "id": "yd_IMWqbdG2nufqbzBVma", "type": "arrow" }, { "id": "STikmhs29bpStOM3nvBoD", "type": "arrow" } ], "updated": 1723756836518, "link": null, "locked": false }, { "type": "text", "version": 1171, "versionNonce": 1999188688, "index": "b2B", "isDeleted": false, "id": "fGfM8hH-3f8NYSSbkKzuB", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1643.99249168917, "y": 1931.115272373224, "strokeColor": "#1e1e1e", "backgroundColor": "#fff9db", "width": 130.25992250442505, "height": 25, "seed": 1828353744, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723756836518, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Run the task", "textAlign": "center", "verticalAlign": "middle", "containerId": "JxDqrhu671kLDTdcOGeyZ", "originalText": "Run the task", "autoResize": true, "lineHeight": 1.25 }, { "type": "arrow", "version": 210, "versionNonce": 1231029808, "index": "b2C", "isDeleted": false, "id": "2hRCzj2mZlHhWGt_WPdv1", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1695.1683092267951, "y": 1535.5876864571458, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 2.505653222510091, "height": 86.9826664940208, "seed": 1489365040, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756891655, "link": null, "locked": false, "startBinding": { "elementId": "FCwcIAfeLbeJg6Zi8BVKM", "focus": 0.04554036978314176, "gap": 1.0000000000001137, "fixedPoint": null }, "endBinding": { "elementId": "eYJWFucNr-z4ZnArvQ-Zr", "focus": -0.05698450771445858, "gap": 4.235604652925758, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 2.505653222510091, 86.9826664940208 ] ], "elbowed": false }, { "type": "arrow", "version": 309, "versionNonce": 274568240, "index": "b2D", "isDeleted": false, "id": "yd_IMWqbdG2nufqbzBVma", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1693.8171318957795, "y": 1797.3645905520277, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 3.90076845842691, "height": 84.28241401267951, "seed": 1069469744, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756836520, "link": null, "locked": false, "startBinding": { "elementId": "eYJWFucNr-z4ZnArvQ-Zr", "focus": 0.1380173553300253, "gap": 10.558632947935394, "fixedPoint": null }, "endBinding": { "elementId": "JxDqrhu671kLDTdcOGeyZ", "focus": -0.07528587805114698, "gap": 11.066225324098781, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 3.90076845842691, 84.28241401267951 ] ], "elbowed": false }, { "type": "arrow", "version": 403, "versionNonce": 933941808, "index": "b2E", "isDeleted": false, "id": "STikmhs29bpStOM3nvBoD", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1587.0238840655074, "y": 1944.041078311416, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 134.6236982149312, "height": 499.71671706185407, "seed": 1382304976, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723756891655, "link": null, "locked": false, "startBinding": { "elementId": "JxDqrhu671kLDTdcOGeyZ", "focus": -0.8775469865039849, "gap": 11.06622532409752, "fixedPoint": null }, "endBinding": { "elementId": "FCwcIAfeLbeJg6Zi8BVKM", "focus": 0.989821931698346, "gap": 2.461394250283675, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -134.6236982149312, -241.50757913609573 ], [ -50.063194075934234, -499.71671706185407 ] ], "elbowed": false }, { "type": "text", "version": 532, "versionNonce": 1101911760, "index": "b2G", "isDeleted": false, "id": "Fo90EBjtmjCdnFISTCbzQ", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.278177951354555, "x": 1258.6235230693567, "y": 1663.7011971998286, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 212.59902644016609, "height": 28.165738862651683, "seed": 886943792, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723755863197, "link": null, "locked": false, "fontSize": 22.532591090121358, "fontFamily": 5, "text": "In a while loop....", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "In a while loop....", "autoResize": false, "lineHeight": 1.25 }, { "type": "arrow", "version": 72, "versionNonce": 691999280, "index": "b2I", "isDeleted": false, "id": "fXI-ZUZHdOLNfBJ2k9IaU", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1765.7332540847406, "y": 1117.859883036091, "strokeColor": "#1e1e1e", "backgroundColor": "#d0bfff", "width": 8.997444931522296, "height": 38.654428294499894, "seed": 2004644048, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757164556, "link": null, "locked": false, "startBinding": { "elementId": "CqiCDYYzdeFbt7hIPh6Ej", "focus": 0.42645184962372484, "gap": 15.141132618166694, "fixedPoint": null }, "endBinding": { "elementId": "HyP7BZJVo6tJy8wPmNzbl", "focus": 0.5020483085399844, "gap": 2.036829545638284, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ -8.997444931522296, 38.654428294499894 ] ], "elbowed": false }, { "type": "rectangle", "version": 96, "versionNonce": 995565616, "index": "b2IG", "isDeleted": false, "id": "URbMlcmauXmJNE4154eWu", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 251.14182229334358, "y": -74.41583597634695, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 370.7116656519728, "height": 263.40256087233087, "seed": 1862224944, "groupIds": [ "5K2ah22q0bV8H2rPle8Rd" ], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "Zv7ejmqs54GHJIXOGOmWE" }, { "id": "umIUdT2Pg_dV6Yj3OxRkn", "type": "arrow" } ], "updated": 1723757378149, "link": null, "locked": false }, { "type": "text", "version": 125, "versionNonce": 1580595760, "index": "b2IV", "isDeleted": false, "id": "Zv7ejmqs54GHJIXOGOmWE", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 341.4177295822206, "y": -30.21455554018152, "strokeColor": "#1e1e1e", "backgroundColor": "#ffc9c9", "width": 190.15985107421875, "height": 175, "seed": 1425153232, "groupIds": [ "5K2ah22q0bV8H2rPle8Rd" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757375833, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Aioclock Application\n\n\n\n\n\n", "textAlign": "center", "verticalAlign": "middle", "containerId": "URbMlcmauXmJNE4154eWu", "originalText": "Aioclock Application\n\n\n\n\n\n", "autoResize": true, "lineHeight": 1.25 }, { "type": "ellipse", "version": 1064, "versionNonce": 1690098896, "index": "b2J", "isDeleted": false, "id": "r8Q1v_XO3gRI7vmMfsEXw", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 325.2229077704774, "y": 50.56508754088625, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 213.32236457653954, "height": 120, "seed": 1818493488, "groupIds": [ "5K2ah22q0bV8H2rPle8Rd" ], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "id": "N6Qx_9If8fvD9nNkz3hcc", "type": "text" }, { "id": "HRt4IaHAZ1RGroBskO7Y4", "type": "arrow" } ], "updated": 1723757384370, "link": null, "locked": false }, { "type": "text", "version": 954, "versionNonce": 1047887920, "index": "b2K", "isDeleted": false, "id": "N6Qx_9If8fvD9nNkz3hcc", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 375.60329101223635, "y": 73.1386806696934, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 112.71990752220154, "height": 75, "seed": 2131910352, "groupIds": [ "5K2ah22q0bV8H2rPle8Rd" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757375832, "link": null, "locked": false, "fontSize": 20, "fontFamily": 5, "text": "Dependency\nInject\nSystem", "textAlign": "center", "verticalAlign": "middle", "containerId": "r8Q1v_XO3gRI7vmMfsEXw", "originalText": "Dependency\nInject\nSystem", "autoResize": true, "lineHeight": 1.25 }, { "type": "text", "version": 1687, "versionNonce": 473918000, "index": "b2L", "isDeleted": false, "id": "HC4jNhimd9IUoFr2xipV5", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 6.240271949811893, "x": 441.847757819976, "y": 380.6251438274418, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 182.67791703977647, "height": 22.515338081998202, "seed": 2112564944, "groupIds": [ "t7l2d8FW9kPWixWqQCSlC" ], "frameId": null, "roundness": null, "boundElements": [], "updated": 1723757435723, "link": null, "locked": false, "fontSize": 18.01227046559856, "fontFamily": 5, "text": "@aioclock.task", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "@aioclock.task", "autoResize": false, "lineHeight": 1.25 }, { "type": "arrow", "version": 2177, "versionNonce": 812487728, "index": "b2M", "isDeleted": false, "id": "HRt4IaHAZ1RGroBskO7Y4", "fillStyle": "hachure", "strokeWidth": 1, "strokeStyle": "dashed", "roughness": 1, "opacity": 100, "angle": 0, "x": 414.225741641744, "y": 196.96627523669537, "strokeColor": "#1e1e1e", "backgroundColor": "#3bc9db", "width": 233.81396450213708, "height": 212.0236995742859, "seed": 1735212080, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1723757428340, "link": null, "locked": false, "startBinding": { "elementId": "r8Q1v_XO3gRI7vmMfsEXw", "focus": 0.23301788409604482, "gap": 27.124023203196707, "fixedPoint": null }, "endBinding": { "elementId": "mp5cf4rOe7-TBZhYPXY93", "focus": -0.31217152478063914, "gap": 7.871960812977093, "fixedPoint": null }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "arrow", "points": [ [ 0, 0 ], [ 17.144056910559357, 205.07407135371665 ], [ 233.81396450213708, 212.0236995742859 ] ], "elbowed": false } ], "appState": { "gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/examples/brokers.md ================================================ You can basically run any tasks on aioclock, it could be your redis broker or other kind of brokers listening to a queue. The benefit of doing so, is that you don't need to worry about dependency injection, shutdown or startup event. AioClock offer you a unique easy way to spin up new services, without any overhead or perfomance issue! ```python from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from aioclock import AioClock, Forever, Depends from functools import lru_cache from typing import NewType BrokerType = NewType("BrokerType", ...) # your broker type ... # your singleton redis instance @lru_cache def get_redis(): ... @asynccontextmanager async def lifespan(aio_clock: AioClock, redis: BrokerType = Depends(get_redis)) -> AsyncGenerator[AioClock]: yield aio_clock await redis.disconnect() app = AioClock(lifespan=lifespan) @app.task(trigger=Forever()) async def read_message_queue(redis: BrokerType = Depends(get_redis)): async for message in redis.listen("..."): ... ``` One other way to do this, is to implement a trigger that automatically execute the function. But to do so, I basically need to wrap redis in my own library, and that's not good for some reasons: 1. Complexity of framework increases. 2. Is not realy flexible, because native library and client are always way more flexible. I end up writing something like `Celery`. 3. The architecture I choose to handle interactions with broker may not satisfy your requirement. [This repository is an example how you can write a message queue in aioclock.](https://github.com/ManiMozaffar/typed-redis) ================================================ FILE: docs/examples/fastapi.md ================================================ To run AioClock with FastAPI, you can run it in the background with FastAPI lifespan, next to your asgi. ```python from aioclock import AioClock from fastapi import FastAPI import asyncio from contextlib import asynccontextmanager clock_app = AioClock() @asynccontextmanager async def lifespan(app: FastAPI): task = asyncio.create_task(clock_app.serve()) yield try: task.cancel() await task except asyncio.CancelledError: ... app = FastAPI(lifespan=lifespan) # now serve this with uvicorn or anything else ``` !!! danger "This setup is not recommended at all" Running AioClock with FastAPI is not a good practice in General, because: FastAPI is a framework to write stateless API, but aioclock is still stateful component in your architecture. In simpler terms, it means if you have 5 instances of aioclock running, they produce 5x tasks than you intended. So you cannot easily scale up horizontally by adding more aioclock power! Even in this case, if you serve FastAPI with multiple processes, you end up having one aioclock per process! What I suggest doing is to spin one new service, that is responsible for processing the periodic tasks. Try to avoid periodic tasks in general, but sometimes it's not easy to do so. ================================================ FILE: docs/extra/tweaks.css ================================================ /* Revert hue value to that of pre mkdocs-material v9.4.0 */ [data-md-color-scheme='slate'] { --md-hue: 230; --md-default-bg-color: hsla(230, 15%, 21%, 1); } ================================================ FILE: docs/images/README.md ================================================ Please check th diagrams folder if you're wondering where diagrams come from. You can load them in [excalidraw](https://excalidraw.com/) website. ================================================ FILE: docs/index.md ================================================ # AioClock ## The Principle Scheduling is annoying, stateful and hard to scale. But not anymore! AioClock is here as an asyncio-based scheduling framework designed for execution of periodic task with integrated support for dependency injection, enabling efficient and flexiable task management. Aioclock offers: - Async: 100% Async, very light, fast and resource friendly - Scheduling: Keep scheduling tasks for you - Group: Group your task, for better code maintainability - Trigger: Already defined and easily extendable triggers, to trigger your scheduler to be started - Easy syntax: Library's syntax is very easy and enjoyable, no confusing hierarchy - Pydantic v2 validation: Validate all your trigger on startup using pydantic 2. Fastest to fail possible! - **Soon**: Running the task dispatcher (scheduler) on different process by default, so CPU intensive stuff on task won't delay the scheduling - **Soon**: Backend support, to allow horizontal scalling, by synchronizing, maybe using Redis ## Getting started To Install aioclock, simply do ``` pip install aioclock ``` AioClock is very user friendly and easy to use, it's type stated library to use easily. AioClock always have a trigger, that trigger the events. ```python from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import asyncio from aioclock import AioClock, At, Depends, Every, Forever, Once from aioclock.group import Group # groups.py group = Group() def more_useless_than_me(): return "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Every(seconds=10)) async def every(): print("Every 10 seconds, I make a quantum leap. Where will I land next?") @group.task(trigger=Every(seconds=5)) def even_sync_works(): print("I'm a synchronous task. I work even in async world.") @group.task(trigger=At(tz="UTC", hour=0, minute=0, second=0)) async def at(): print( "When the clock strikes midnight... I turn into a pumpkin. Just kidding, I run this task!" ) @group.task(trigger=Forever()) async def forever(val: str = Depends(more_useless_than_me)): await asyncio.sleep(2) print("Heartbeat detected. Still not a zombie. Will check again in a bit.") assert val == "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Once()) async def once(): print("Just once, I get to say something. Here it goes... I love lamp.") @asynccontextmanager async def lifespan(aio_clock: AioClock) -> AsyncGenerator[AioClock]: print( "Welcome to the Async Chronicles! Did you know a group of unicorns is called a blessing? Well, now you do!" ) yield aio_clock print("Going offline. Remember, if your code is running, you better go catch it!") app = AioClock(lifespan=lifespan) app.include_group(group) # main.py if __name__ == "__main__": asyncio.run(app.serve()) ``` ================================================ FILE: docs/overview.md ================================================ # AioClock: Overview ## Introduction AioClock is a lightweight, asynchronous task scheduling framework designed for Python applications. It provides a structured approach to managing, scheduling, and executing tasks with integrated dependency injection, making it highly modular and testable. ## Key Components ### 1. AioClock Application The central orchestrator for all tasks. It initializes, manages, and executes tasks based on their defined triggers. ### 2. Dependency Injection System A built-in system for injecting dependencies into tasks, enabling loose coupling and improving testability. It uses [FastDepends](https://lancetnik.github.io/FastDepends/) internally, which is very fimiliar with [FastAPI Dependency Injection System](https://fastapi.tiangolo.com/tutorial/dependencies/) ### 3. Task A unit of work that can be scheduled and executed asynchronously. Tasks are defined using specific triggers that determine their execution conditions. ### 4. Trigger Conditions or events that initiate task execution. It includes - **Every**: Repeats a task at regular intervals. - **At**: Executes a task at a specified time. - **Once**: Runs a task a single time. - **OnStartUp**: Runs when the application starts. (DEPRECATED in favor of lifespan) - **OnShutDown**: Executes during application shutdown. (DEPRECATED in favor of lifespan) - **Forever**: Continuously runs a task in an infinite loop. - **Cron**: Uses cron syntax for scheduling. - **OrTrigger**: Initiate with a list of triggers, and executes when at least one of the included triggers activate. ### 5. Group A logical collection of tasks. Groups allow related tasks to be bundled and managed together, simplifying task organization and execution. Group allow you to code with aioclock in modular way. ### 6. Task Runner The engine that monitors and executes tasks according to their triggers, ensuring tasks run in the correct order and at the right time. ### 7. Serve The entry point that starts the AioClock application. It initiates the task runner, which monitors and executes tasks based on their triggers. ### 8. Lifespan A context manager that will be used to handle the startup and shutdown of the application. It is used inside AioClock application. ### 9. Callable A function or method associated with a task, executed when the task's trigger condition is met. ## Diagrams ### Ownership ![Ownership Diagram](images/ownership-diagram.png) In AioClock, tasks are managed through clear ownership within groups, using dependency injection. Groups encapsulate related tasks, each with specific triggers and callables. The include_group() function integrates these groups into the AioClock application, while standalone tasks are managed with decorators like @aioclock.task. This structure ensures that tasks are organized, maintainable, and easy to scale, with each component having a defined responsibility within the application. The dependency injection system in AioClock allows you to override a callable with another through the application interface, facilitating testing. For example, instead of returning a session from a PostgreSQL database with get_session, you can override it to use get_sqlite_session, which provides a SQLite session instead. This flexibility makes it easier to swap out components for testing or other purposes without changing the core logic. ### Aioclock LifeCycle ![Aioclock LifeCycle](images/lifecycle-diagram.png) This diagram shows the lifecycle of an AioClock application. It starts with the app.serve() call, which gathers all tasks and groups. The application then checks for startup tasks, runs them, and proceeds to other tasks. If a shutdown task is detected, it's executed before the application gracefully exits. If no shutdown tasks are present, the application simply exits. The diagram ensures a clear, step-by-step process for task management within the AioClock framework, ensuring tasks are executed in the proper order. P.S: Since startup and shutdown task are deprcated, lifespan has same side effect as them, with extra benefit of having them with a shared memory state. Please reffer to lifespan API Documentation to understand it better with examples. ### Task Runner Execution Flow ![Task Runner Execution Flow](images/task-runner-diagram.png) This diagram breaks down how a task is handled in the AioClock system. The task runner first receives the task, checks if it’s still valid to run, and then either waits for the appropriate trigger (like a scheduled time) or finishes if it’s no longer needed. If valid, the task is executed; otherwise, the system gracefully exits. This loop continues until all tasks are either completed or stopped, ensuring everything runs smoothly and on time. ================================================ FILE: docs/plugins.py ================================================ import os import re from typing import Match from mkdocs.config import Config from mkdocs.structure.files import Files from mkdocs.structure.pages import Page try: import pytest except ImportError: pytest = None def on_pre_build(config: Config): pass def on_files(files: Files, config: Config) -> Files: return remove_files(files) def remove_files(files: Files) -> Files: to_remove = [] for file in files: if file.src_path in {"plugins.py"}: to_remove.append(file) elif file.src_path.startswith("__pycache__/"): to_remove.append(file) for f in to_remove: files.remove(f) return files def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: markdown = remove_code_fence_attributes(markdown) return add_version(markdown, page) def add_version(markdown: str, page: Page) -> str: if page.file.src_uri == "index.md": version_ref = os.getenv("GITHUB_REF") if version_ref and version_ref.startswith("refs/tags/"): version = re.sub("^refs/tags/", "", version_ref.lower()) url = f"https://ManiMozaffar.github.io/aioclock/releases/tag/{version}" version_str = f"Documentation for version: [{version}]({url})" elif sha := os.getenv("GITHUB_SHA"): sha = sha[:7] url = f"https://ManiMozaffar.github.io/aioclock/commit/{sha}" version_str = f"Documentation for development version: [{sha}]({url})" else: version_str = "Documentation for development version" markdown = re.sub(r"{{ *version *}}", version_str, markdown) return markdown def remove_code_fence_attributes(markdown: str) -> str: """ There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use `py key="value"` to provide attributes to pytest-examples, then remove those attributes here. https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/ """ def remove_attrs(match: Match[str]) -> str: suffix = re.sub( r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', "", match.group(2), flags=re.M, ) return f"{match.group(1)}{suffix}" return re.sub(r"^( *``` *py)(.*)", remove_attrs, markdown, flags=re.M) ================================================ FILE: examples/app.py ================================================ import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import threading from time import sleep from typing import Annotated from aioclock import AioClock, Depends, Every, Group # service1.py group = Group() def dependency(): return "Hello from thread: " @group.task(trigger=Every(seconds=2)) def sync_task_1(val: str = Depends(dependency)): print(f"{val} `sync_task_1` {threading.current_thread().ident}") sleep(1) # some blocking operation @group.task(trigger=Every(seconds=2.01)) def sync_task_2(val: Annotated[str, Depends(dependency)]): print(f"{val} `sync_task_2` {threading.current_thread().ident}") sleep(1) # some blocking operation return "3" print(sync_task_2("Aioclock won't color your functions!")) @group.task(trigger=Every(seconds=2)) async def async_task(val: str = Depends(dependency)): print(f"{val} `async_task` {threading.current_thread().ident}") @asynccontextmanager async def lifespan(aio_clock: AioClock) -> AsyncGenerator[AioClock]: print("Welcome!") yield aio_clock print("Bye!") # app.py app = AioClock(lifespan=lifespan) app.include_group(group) if __name__ == "__main__": asyncio.run(app.serve()) ================================================ FILE: examples/awesome_triggers.py ================================================ import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from aioclock import AioClock, At, Depends, Every, Forever, Once from aioclock.group import Group # groups.py group = Group() def more_useless_than_me(): return "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Every(seconds=10)) async def every(): print("Every 10 seconds, I make a quantum leap. Where will I land next?") @group.task(trigger=Every(seconds=5)) def even_sync_works(): print("I'm a synchronous task. I work even in async world.") @group.task(trigger=At(tz="UTC", hour=0, minute=0, second=0)) async def at(): print( "When the clock strikes midnight... I turn into a pumpkin. Just kidding, I run this task!" ) @group.task(trigger=Forever()) async def forever(val: str = Depends(more_useless_than_me)): await asyncio.sleep(2) print("Heartbeat detected. Still not a zombie. Will check again in a bit.") assert val == "I'm a dependency. I'm more useless than a screen door on a submarine." @group.task(trigger=Once()) async def once(): print("Just once, I get to say something. Here it goes... I love lamp.") @asynccontextmanager async def lifespan(aio_clock: AioClock) -> AsyncGenerator[AioClock]: print( "Welcome to the Async Chronicles! Did you know a group of unicorns is called a blessing? Well, now you do!" ) yield aio_clock print("Going offline. Remember, if your code is running, you better go catch it!") # app.py app = AioClock(lifespan=lifespan) app.include_group(group) # main.py if __name__ == "__main__": asyncio.run(app.serve()) ================================================ FILE: examples/dependency_injection.py ================================================ import asyncio from aioclock import AioClock, Depends, Every, Group # service1.py group = Group() def dependency(): return "Hello, world!" def overwritten_dependency(): return "Goodbye, world!" @group.task(trigger=Every(seconds=1)) async def my_task(val: str = Depends(dependency)): print(val) # app.py app = AioClock() app.include_group(group) app.override_dependencies(dependency, overwritten_dependency) if __name__ == "__main__": asyncio.run(app.serve()) ================================================ FILE: examples/with_fast_api.py ================================================ import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from fastapi import FastAPI from aioclock import AioClock from aioclock.ext.fast import make_fastapi_router from aioclock.triggers import Every @asynccontextmanager async def aioclock_lifespan(aio_clock: AioClock) -> AsyncGenerator[AioClock]: print("Starting aiolcok app...") yield aio_clock print("Closing aiolcok app...") clock_app = AioClock(lifespan=aioclock_lifespan) @clock_app.task(trigger=Every(seconds=3600)) async def foo(): print("Foo is processing...") @asynccontextmanager async def lifespan(app: FastAPI): task = asyncio.create_task(clock_app.serve()) yield try: task.cancel() await task except asyncio.CancelledError: ... app = FastAPI(lifespan=lifespan) app.include_router(make_fastapi_router(clock_app)) if __name__ == "__main__": import uvicorn uvicorn.run(app) ================================================ FILE: mkdocs.yml ================================================ site_name: AioClock site_description: An asyncio-based scheduling framework designed for execution of periodic task with integrated support for dependency injection, enabling efficient and flexiable task management repo_url: https://github.com/ManiMozaffar/aioclock site_url: https://ManiMozaffar.github.io/aioclock site_author: Mani Mozaffar repo_name: ManiMozaffar/aioclock copyright: Maintained by Mani Mozaffar. theme: name: "material" palette: - media: "(prefers-color-scheme)" toggle: icon: material/link name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default primary: indigo accent: indigo toggle: icon: material/toggle-switch name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: black accent: indigo toggle: icon: material/toggle-switch-off name: Switch to system preference features: - navigation.tabs - navigation.instant - content.code.annotate - content.tabs.link - content.code.copy - announce.dismiss - search.suggest - search.highlight # logo: assets/logo-white.svg # favicon: assets/favicon.png edit_uri: "" # https://www.mkdocs.org/user-guide/configuration/#validation validation: omitted_files: warn absolute_links: warn unrecognized_links: warn extra: navigation: next: true previous: true extra_css: - "extra/tweaks.css" markdown_extensions: - toc: permalink: true - admonition - pymdownx.details - pymdownx.extra - pymdownx.superfences - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.snippets - attr_list - md_in_html watch: - aioclock plugins: - mike: alias_type: symlink canonical_version: latest - search: - mkdocstrings: handlers: python: paths: - aioclock options: members_order: source separate_signature: true docstring_options: ignore_init_summary: true merge_init_into_class: true show_signature_annotations: true signature_crossrefs: true - mkdocs-simple-hooks: hooks: on_pre_build: "docs.plugins:on_pre_build" on_files: "docs.plugins:on_files" on_page_markdown: "docs.plugins:on_page_markdown" nav: - Introduction: index.md - Documentation: - Overview: overview.md - Basic Usage: - AioClock Application: api/getting_started.md - Triggers: api/triggers.md - Advance Usage: - Task: api/task.md - External API: api/external_api.md - Plugins (FastAPI included): api/plugin.md - Using beside FastAPI: examples/fastapi.md - Using beside Message Brokers: examples/brokers.md - Alternatives: alternative.md ================================================ FILE: pyproject.toml ================================================ [project] name = "aioclock" version = "0.3.1" description = "An asyncio-based scheduling framework designed for execution of periodic task with integrated support for dependency injection, enabling efficient and flexible task management" authors = [{ name = "Mani Mozaffar", email = "mani.mozaffar@gmail.com" }] readme = "README.md" requires-python = ">= 3.8" dependencies = [ "pydantic[timezone]>=2.9.0", "fast-depends>=2.4.0", "asyncer>=0.0.7", "croniter>=2.0.5", ] license = 'MIT' [project.urls] repository = "https://github.com/ManiMozaffar/aioclock" Homepage = 'https://github.com/ManiMozaffar/aioclock' Documentation = 'https://github.com/ManiMozaffar/aioclock' Source = 'https://github.com/ManiMozaffar/aioclock' [project.optional-dependencies] fastapi = ["fastapi"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.rye] dev-dependencies = [ "pytest>=7.2.0", "pytest-asyncio>=0.23.6", "tox>=4.11.1", "rich>=13.7.1", "pyright>=1.1.350", "mike==2.0.0", "pytest-asyncio>=0.23.7", "mkdocs>=1.4.2", "mkdocs-material>=9.2.7", "mkdocstrings[python]>=0.25.1", "mkdocs-simple-hooks>=0.1.5", "black>=24.4.2", "fastapi>=0.90", "ruff>=0.3.5", "mkdocs-material-extensions>=1.3.1", ] [tool.behavior] use-uv = true [tool.hatch.metadata] allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["aioclock"] [tool.setuptools.package-data] "aioclock" = ["py.typed"] [tool.ruff] lint.fixable = [ "A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT", ] lint.unfixable = [] lint.exclude = [ ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".mypy_cache", ".nox", ".pants.d", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "venv", ] line-length = 100 [tool.ruff.format] preview = true [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] [tool.pyright] typeCheckingMode = "basic" [tool.pytest.ini_options] testpaths = ["tests"] ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/test_di.py ================================================ import pytest from aioclock import AioClock, Depends, Once from aioclock.api import run_with_injected_deps app = AioClock() def some_dependency(): return 1 @app.task(trigger=Once()) async def main(bar: int = Depends(some_dependency)): print("Hello World") return bar @pytest.mark.asyncio async def test_run_manual(): foo = await run_with_injected_deps(main) assert foo == 1 ================================================ FILE: tests/test_examples.py ================================================ import importlib.util import inspect import logging import os import sys import textwrap logger = logging.getLogger(__name__) def extract_code_blocks(docstring: str) -> list[str]: """Extract code blocks from a docstring.""" code_blocks = [] in_code_block = False code_block = [] for line in docstring.split("\n"): if line.strip().startswith("```python"): in_code_block = True continue elif line.strip().startswith("```"): in_code_block = False if code_block: code_blocks.append("\n".join(code_block)) code_block = [] continue if in_code_block: code_block.append(line) return code_blocks def exec_example(example: str) -> None: """Execute a code example.""" example = textwrap.dedent(example) namespace = {"__name__": "__not_main__"} try: code = compile(example, "", "exec") exec(code, namespace) except Exception: raise Exception(f"Failed to execute example:\n\n{example}") def process_object(name: str, obj: object, module_name: str, library_name: str) -> None: """Process a single object, extracting and executing code examples.""" docstring = inspect.getdoc(obj) if docstring: examples = extract_code_blocks(docstring) for example in examples: exec_example(example) # Recursively process class methods only if they belong to the same library if inspect.isclass(obj): for method_name, method_obj in inspect.getmembers(obj, inspect.isfunction): if method_obj.__module__ and method_obj.__module__.startswith(library_name): process_object(f"{name}.{method_name}", method_obj, module_name, library_name) def process_module(module_path: str) -> None: """Process a single Python module.""" module_name = os.path.splitext(os.path.basename(module_path))[0] spec = importlib.util.spec_from_file_location(module_name, module_path) if spec is None or spec.loader is None: raise Exception(f"Could not load module: {module_path}") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) library_name = "aioclock" for name, obj in inspect.getmembers( module, lambda o: inspect.isfunction(o) or inspect.isclass(o) ): if obj.__module__ and obj.__module__.startswith(library_name): process_object(name, obj, module_name, library_name) def process_markdown(markdown_path: str) -> None: """Process a markdown file and execute code blocks.""" with open(markdown_path, "r") as file: content = file.read() code_blocks = extract_code_blocks(content) for example in code_blocks: exec_example(example) def traverse_library(library_path: str) -> None: """Traverse the library directory and process each Python module.""" original_sys_path = sys.path.copy() sys.path.insert(0, os.path.abspath(library_path)) # Prioritize the library path for root, _, files in os.walk(library_path): for file in files: if file.endswith(".py"): module_path = os.path.join(root, file) process_module(module_path) sys.path = original_sys_path # Restore the original sys.path def traverse_docs(docs_path: str) -> None: """Traverse the docs directory and process each markdown file.""" for root, _, files in os.walk(docs_path): for file in files: if file.endswith(".md"): markdown_path = os.path.join(root, file) process_markdown(markdown_path) def test_examples(): docs_path = "../aioclock/docs" traverse_docs(docs_path) library_path = "../aioclock/aioclock" traverse_library(library_path) ================================================ FILE: tests/test_lifespan.py ================================================ from contextlib import asynccontextmanager, contextmanager import pytest from aioclock import AioClock from aioclock.triggers import Once ML_MODEL_ASYNC = [] RAN_ONCE_TASK_ASYNC = False @asynccontextmanager async def lifespan(app: AioClock): ML_MODEL_ASYNC.append(2) yield app ML_MODEL_ASYNC.clear() app = AioClock(lifespan=lifespan) @app.task(trigger=Once()) async def main(): assert len(ML_MODEL_ASYNC) == 1 global RAN_ONCE_TASK_ASYNC RAN_ONCE_TASK_ASYNC = True @pytest.mark.asyncio async def test_lifespan_e2e_async(): assert len(ML_MODEL_ASYNC) == 0 assert RAN_ONCE_TASK_ASYNC is False await app.serve() # asserts are in the task assert len(ML_MODEL_ASYNC) == 0 # clean up done assert RAN_ONCE_TASK_ASYNC is True # task ran ML_MODEL_SYNC = [] # just some imaginary component that needs to be started and stopped RAN_ONCE_TASK_SYNC = False @contextmanager def lifespan_sync(sync_app: AioClock): ML_MODEL_SYNC.append(2) yield sync_app ML_MODEL_SYNC.clear() sync_app = AioClock(lifespan=lifespan_sync) @sync_app.task(trigger=Once()) def sync_main(): assert len(ML_MODEL_SYNC) == 1 global RAN_ONCE_TASK_SYNC RAN_ONCE_TASK_SYNC = True @pytest.mark.asyncio async def test_lifespan_e2e_sync(): assert len(ML_MODEL_SYNC) == 0 assert RAN_ONCE_TASK_SYNC is False await sync_app.serve() # asserts are in the task assert len(ML_MODEL_SYNC) == 0 # clean up done assert RAN_ONCE_TASK_SYNC is True # task ran ================================================ FILE: tests/test_timeout.py ================================================ import asyncio from contextlib import contextmanager from datetime import datetime import pytest from aioclock import AioClock, Once app = AioClock() @contextmanager def assert_execution_time_below(target: float): start_time = datetime.now() yield assert (datetime.now() - start_time).total_seconds() < target @app.task(trigger=Once(), timeout=0.1) async def main(): await asyncio.sleep(10) @pytest.mark.asyncio async def test_run_manual(): task_with_timeout = app._get_tasks()[0] with assert_execution_time_below(0.5): await task_with_timeout.run() ================================================ FILE: tests/test_triggers.py ================================================ from datetime import datetime, timedelta import pytest import zoneinfo from aioclock.triggers import At, Cron, Every, Forever, LoopController, Once, OrTrigger def test_at_trigger(): # test this sunday trigger = At(at="every sunday", hour=14, minute=1, second=0, tz="Europe/Istanbul") val = trigger._get_next_ts( datetime( year=2024, month=3, day=31, hour=14, minute=00, second=0, tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"), ) ) assert val == 60 # test next week trigger = At(at="every sunday", hour=14, second=59, tz="Europe/Istanbul") val = trigger._get_next_ts( datetime( year=2024, month=3, day=31, hour=14, minute=0, second=0, tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"), ) ) assert val == 59 # test every day trigger = At(at="every day", hour=14, second=59, tz="Europe/Istanbul") this_sunday = datetime( year=2024, month=3, day=31, hour=14, minute=0, second=0, tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"), ) val = trigger._get_next_ts(this_sunday) assert val == 59 # test next week trigger = At(at="every saturday", hour=14, second=0, tz="Europe/Istanbul") val = trigger._get_next_ts(this_sunday) assert val == 86400 # right NOW trigger = At(at="every sunday", hour=14, tz="Europe/Istanbul") val = trigger._get_next_ts(this_sunday) assert val == 0 # next week but 1h before than now trigger = At(at="every sunday", hour=13, tz="Europe/Istanbul") val = trigger._get_next_ts(this_sunday) assert val == timedelta(days=7).total_seconds() - timedelta(hours=1).total_seconds() def test_cross_timezone(): # `NOW` is same day as the trigger, but trigger should be next week with different timezone trigger = At(hour=3, tz="Europe/Berlin", at="every saturday") assert ( trigger._get_next_ts( datetime.fromtimestamp(1720296232, tz=zoneinfo.ZoneInfo("Europe/Istanbul")) ) == 532568 ) @pytest.mark.asyncio async def test_loop_controller(): # since once trigger is triggered, it should not trigger again. trigger = Once() assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is False class IterateFiveTime(LoopController): type_: str = "foo" async def trigger_next(self) -> None: self._increment_loop_counter() return None trigger = IterateFiveTime(max_loop_count=5) for _ in range(5): assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is False @pytest.mark.asyncio async def test_forever(): trigger = Forever() assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is True @pytest.mark.asyncio async def test_every(): # wait should always wait for the period on first run trigger = Every(seconds=1, first_run_strategy="wait") assert await trigger.get_waiting_time_till_next_trigger() == 1 # immediate should always execute immediately, but wait for the period from second run. trigger = Every(seconds=1, first_run_strategy="immediate") assert await trigger.get_waiting_time_till_next_trigger() == 0 trigger._increment_loop_counter() assert await trigger.get_waiting_time_till_next_trigger() == 1 @pytest.mark.asyncio async def test_cron(): # it's dumb idea to test library, but I don't trust it 100%, and it might drop it in the future. trigger = Cron(cron="* * * * *", tz="UTC") val = await trigger.get_waiting_time_till_next_trigger( datetime( year=2024, month=3, day=31, hour=14, minute=0, second=0, tzinfo=zoneinfo.ZoneInfo("UTC"), ) ) assert val == 60 trigger = Cron(cron="2-10 * * * *", tz="UTC") assert ( await trigger.get_waiting_time_till_next_trigger( datetime( year=2024, month=3, day=31, hour=11, minute=48, second=0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"), ) ) == 14 * 60 ) with pytest.raises(ValueError): Cron(cron="* * * * 65", tz="UTC") @pytest.mark.asyncio async def test_or_trigger_state(): trigger = OrTrigger(triggers=[Once(), Once()]) assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is True await trigger.trigger_next() assert trigger.should_trigger() is False @pytest.mark.asyncio async def test_or_trigger_next(): trigger = OrTrigger( triggers=[Every(seconds=0, max_loop_count=2), Every(seconds=0, max_loop_count=2)] ) for _ in range(4): assert trigger.should_trigger() is True assert (await trigger.get_waiting_time_till_next_trigger()) == 0 await trigger.trigger_next() assert trigger.should_trigger() is False ================================================ FILE: tox.ini ================================================ [tox] skipsdist = true envlist = py39, py310, py311 [gh-actions] python = 3.9: py39 3.10: py310 3.11: py311 [testenv:py39] passenv = PYTHON_VERSION allowlist_externals = rye,pytest,pyright commands = rye pin 3.9 rye sync rye run pytest tests [testenv:py310] passenv = PYTHON_VERSION allowlist_externals = rye,pytest,pyright commands = rye pin 3.10 rye sync rye run pytest tests [testenv:py311] passenv = PYTHON_VERSION allowlist_externals = rye,pytest,pyright commands = rye pin 3.11 rye sync rye run pytest tests