├── tests ├── __init__.py ├── pyventus │ ├── __init__.py │ ├── core │ │ ├── __init__.py │ │ ├── utils │ │ │ ├── __init__.py │ │ │ └── test_repr_utils.py │ │ ├── collections │ │ │ └── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── test_pyventus_exception.py │ │ │ └── test_pyventus_import_exception.py │ │ ├── loggers │ │ │ ├── __init__.py │ │ │ └── test_logger.py │ │ ├── processing │ │ │ ├── __init__.py │ │ │ ├── redis │ │ │ │ ├── __init__.py │ │ │ │ └── test_redis_processing_service.py │ │ │ ├── asyncio │ │ │ │ ├── __init__.py │ │ │ │ └── test_asyncio_processing_service.py │ │ │ ├── celery │ │ │ │ ├── __init__.py │ │ │ │ └── test_celery_processing_service.py │ │ │ ├── executor │ │ │ │ ├── __init__.py │ │ │ │ └── test_executor_processing_service.py │ │ │ ├── fastapi │ │ │ │ ├── __init__.py │ │ │ │ └── test_fastapi_processing_service.py │ │ │ └── processing_service_test.py │ │ └── subscriptions │ │ │ ├── __init__.py │ │ │ └── test_subscription.py │ ├── events │ │ ├── __init__.py │ │ ├── emitters │ │ │ ├── __init__.py │ │ │ └── test_event_emitter_utils.py │ │ ├── linkers │ │ │ └── __init__.py │ │ └── subscribers │ │ │ └── __init__.py │ └── reactive │ │ ├── __init__.py │ │ ├── observables │ │ ├── __init__.py │ │ └── test_observable_utils.py │ │ └── subscribers │ │ ├── __init__.py │ │ └── test_subscriber.py ├── utils │ ├── __init__.py │ └── object_utils.py ├── benchmarks │ └── __init__.py └── fixtures │ ├── __init__.py │ └── event_fixtures.py ├── src └── pyventus │ ├── reactive │ ├── observers │ │ ├── __init__.py │ │ └── observer.py │ ├── subscribers │ │ └── __init__.py │ ├── observables │ │ ├── __init__.py │ │ └── observable_utils.py │ └── __init__.py │ ├── core │ ├── __init__.py │ ├── collections │ │ └── __init__.py │ ├── constants │ │ ├── __init__.py │ │ └── stdout_colors.py │ ├── loggers │ │ ├── __init__.py │ │ └── logger.py │ ├── processing │ │ ├── redis │ │ │ ├── __init__.py │ │ │ └── redis_processing_service.py │ │ ├── asyncio │ │ │ ├── __init__.py │ │ │ └── asyncio_processing_service.py │ │ ├── celery │ │ │ ├── __init__.py │ │ │ └── celery_processing_service.py │ │ ├── fastapi │ │ │ ├── __init__.py │ │ │ └── fastapi_processing_service.py │ │ ├── executor │ │ │ ├── __init__.py │ │ │ └── executor_processing_service.py │ │ ├── __init__.py │ │ └── processing_service.py │ ├── exceptions │ │ ├── __init__.py │ │ ├── pyventus_exception.py │ │ └── pyventus_import_exception.py │ ├── subscriptions │ │ ├── __init__.py │ │ ├── unsubscribable.py │ │ └── subscription.py │ └── utils │ │ ├── __init__.py │ │ └── repr_utils.py │ ├── events │ ├── handlers │ │ ├── __init__.py │ │ └── event_handler.py │ ├── linkers │ │ └── __init__.py │ ├── subscribers │ │ ├── __init__.py │ │ └── event_subscriber.py │ ├── emitters │ │ ├── __init__.py │ │ └── event_emitter_utils.py │ └── __init__.py │ └── __init__.py ├── docs ├── api │ ├── events │ │ ├── emitters │ │ │ ├── index.md │ │ │ └── event_emitter_utils.md │ │ ├── linkers │ │ │ └── event_linker.md │ │ ├── handlers │ │ │ └── event_handler.md │ │ ├── subscribers │ │ │ └── event_subscriber.md │ │ └── index.md │ ├── reactive │ │ ├── observers │ │ │ └── observer.md │ │ ├── observables │ │ │ ├── index.md │ │ │ ├── observable_utils.md │ │ │ └── observable_task.md │ │ ├── subscribers │ │ │ └── subscriber.md │ │ └── index.md │ ├── core │ │ ├── collections │ │ │ └── multi_bidict.md │ │ ├── index.md │ │ ├── processing │ │ │ ├── index.md │ │ │ ├── redis_processing_service.md │ │ │ ├── celery_processing_service.md │ │ │ ├── asyncio_processing_service.md │ │ │ ├── executor_processing_service.md │ │ │ └── fastapi_processing_service.md │ │ ├── subscriptions │ │ │ ├── subscription.md │ │ │ ├── unsubscribable.md │ │ │ └── subscription_context.md │ │ └── exceptions │ │ │ ├── pyventus_exception.md │ │ │ └── pyventus_import_exception.md │ └── index.md ├── images │ ├── logo │ │ ├── pyventus-logo.png │ │ ├── pyventus-logo-black.png │ │ ├── pyventus-logo-name.png │ │ ├── pyventus-logo-white.png │ │ └── pyventus-logo-name-slogan.png │ ├── favicon │ │ └── pyventus-logo.ico │ └── examples │ │ ├── debug-mode-example.png │ │ ├── black-circuit-board.jpg │ │ ├── events-debug-mode-example.png │ │ └── rack-of-electronic-equipment.jpg ├── .overrides │ ├── outdated.html │ ├── main.html │ ├── announce.html │ └── 404.html ├── learn │ ├── reactive │ │ └── index.md │ ├── index.md │ ├── events │ │ ├── index.md │ │ └── emitters │ │ │ ├── asyncio.md │ │ │ ├── fastapi.md │ │ │ ├── executor.md │ │ │ ├── redis.md │ │ │ └── index.md │ └── upgrade_guide.md ├── javascripts │ ├── announcement.js │ └── mathjax.js ├── stylesheets │ ├── search-bar.css │ └── announcement.css └── getting-started.md ├── .github ├── CONTRIBUTING.md ├── SECURITY.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── publish-to-pypi.yml │ ├── deploy-docs.yml │ └── run-tests.yml └── CODE_OF_CONDUCT.md ├── CITATION.cff ├── LICENSE ├── .gitignore └── mkdocs.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/reactive/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/collections/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/loggers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/events/emitters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/events/linkers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/redis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/events/subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/reactive/observables/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/reactive/subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/celery/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/executor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pyventus/reactive/observers/__init__.py: -------------------------------------------------------------------------------- 1 | from .observer import Observer 2 | -------------------------------------------------------------------------------- /src/pyventus/core/__init__.py: -------------------------------------------------------------------------------- 1 | """The essential building blocks of Pyventus.""" 2 | -------------------------------------------------------------------------------- /src/pyventus/core/collections/__init__.py: -------------------------------------------------------------------------------- 1 | from .multi_bidict import MultiBidict 2 | -------------------------------------------------------------------------------- /src/pyventus/core/constants/__init__.py: -------------------------------------------------------------------------------- 1 | from .stdout_colors import StdOutColors 2 | -------------------------------------------------------------------------------- /src/pyventus/events/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_handler import EventHandler 2 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .object_utils import get_private_attr, has_private_attr 2 | -------------------------------------------------------------------------------- /tests/benchmarks/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_emitter_benchmark import EventEmitterBenchmark 2 | -------------------------------------------------------------------------------- /docs/api/events/emitters/index.md: -------------------------------------------------------------------------------- 1 | # `EventEmitter` class 2 | 3 | ::: pyventus.events.EventEmitter 4 | -------------------------------------------------------------------------------- /docs/api/reactive/observers/observer.md: -------------------------------------------------------------------------------- 1 | # `Observer` class 2 | 3 | ::: pyventus.reactive.Observer 4 | -------------------------------------------------------------------------------- /docs/api/events/linkers/event_linker.md: -------------------------------------------------------------------------------- 1 | # `EventLinker` class 2 | 3 | ::: pyventus.events.EventLinker 4 | -------------------------------------------------------------------------------- /docs/api/reactive/observables/index.md: -------------------------------------------------------------------------------- 1 | # `Observable` class 2 | 3 | ::: pyventus.reactive.Observable 4 | -------------------------------------------------------------------------------- /src/pyventus/events/linkers/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_linker import EventLinker, SubscribableEventType 2 | -------------------------------------------------------------------------------- /docs/api/events/handlers/event_handler.md: -------------------------------------------------------------------------------- 1 | # `EventHandler` class 2 | 3 | ::: pyventus.events.EventHandler 4 | -------------------------------------------------------------------------------- /docs/api/reactive/subscribers/subscriber.md: -------------------------------------------------------------------------------- 1 | # `Subscriber` class 2 | 3 | ::: pyventus.reactive.Subscriber 4 | -------------------------------------------------------------------------------- /src/pyventus/core/loggers/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import Logger 2 | from .stdout_logger import StdOutLogger 3 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/redis/__init__.py: -------------------------------------------------------------------------------- 1 | from .redis_processing_service import RedisProcessingService 2 | -------------------------------------------------------------------------------- /docs/api/core/collections/multi_bidict.md: -------------------------------------------------------------------------------- 1 | # `MultiBidict` class 2 | 3 | ::: pyventus.core.collections.MultiBidict 4 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | from .asyncio_processing_service import AsyncIOProcessingService 2 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/celery/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery_processing_service import CeleryProcessingService 2 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .fastapi_processing_service import FastAPIProcessingService 2 | -------------------------------------------------------------------------------- /docs/api/core/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - feedback 4 | --- 5 | 6 | # `Core` module 7 | 8 | ::: pyventus.core 9 | -------------------------------------------------------------------------------- /docs/api/core/processing/index.md: -------------------------------------------------------------------------------- 1 | # `ProcessingService` class 2 | 3 | ::: pyventus.core.processing.ProcessingService 4 | -------------------------------------------------------------------------------- /docs/api/events/subscribers/event_subscriber.md: -------------------------------------------------------------------------------- 1 | # `EventSubscriber` class 2 | 3 | ::: pyventus.events.EventSubscriber 4 | -------------------------------------------------------------------------------- /docs/images/logo/pyventus-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/logo/pyventus-logo.png -------------------------------------------------------------------------------- /src/pyventus/core/processing/executor/__init__.py: -------------------------------------------------------------------------------- 1 | from .executor_processing_service import ExecutorProcessingService 2 | -------------------------------------------------------------------------------- /docs/api/core/subscriptions/subscription.md: -------------------------------------------------------------------------------- 1 | # `Subscription` class 2 | 3 | ::: pyventus.core.subscriptions.Subscription 4 | -------------------------------------------------------------------------------- /docs/images/favicon/pyventus-logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/favicon/pyventus-logo.ico -------------------------------------------------------------------------------- /src/pyventus/core/processing/__init__.py: -------------------------------------------------------------------------------- 1 | from .processing_service import ProcessingService, ProcessingServiceCallbackType 2 | -------------------------------------------------------------------------------- /docs/api/core/subscriptions/unsubscribable.md: -------------------------------------------------------------------------------- 1 | # `Unsubscribable` class 2 | 3 | ::: pyventus.core.subscriptions.Unsubscribable 4 | -------------------------------------------------------------------------------- /docs/images/logo/pyventus-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/logo/pyventus-logo-black.png -------------------------------------------------------------------------------- /docs/images/logo/pyventus-logo-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/logo/pyventus-logo-name.png -------------------------------------------------------------------------------- /docs/images/logo/pyventus-logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/logo/pyventus-logo-white.png -------------------------------------------------------------------------------- /docs/api/core/exceptions/pyventus_exception.md: -------------------------------------------------------------------------------- 1 | # `PyventusException` class 2 | 3 | ::: pyventus.core.exceptions.PyventusException 4 | -------------------------------------------------------------------------------- /docs/images/examples/debug-mode-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/examples/debug-mode-example.png -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from .callable_fixtures import CallableMock, DummyCallable 2 | from .event_fixtures import EventFixtures 3 | -------------------------------------------------------------------------------- /docs/images/examples/black-circuit-board.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/examples/black-circuit-board.jpg -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please read the [Contributing](https://mdapena.github.io/pyventus/latest/contributing/) guidelines in the documentation site. -------------------------------------------------------------------------------- /docs/api/core/subscriptions/subscription_context.md: -------------------------------------------------------------------------------- 1 | # `SubscriptionContext` class 2 | 3 | ::: pyventus.core.subscriptions.SubscriptionContext 4 | -------------------------------------------------------------------------------- /docs/images/logo/pyventus-logo-name-slogan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/logo/pyventus-logo-name-slogan.png -------------------------------------------------------------------------------- /docs/api/core/exceptions/pyventus_import_exception.md: -------------------------------------------------------------------------------- 1 | # `PyventusImportException` class 2 | 3 | ::: pyventus.core.exceptions.PyventusImportException 4 | -------------------------------------------------------------------------------- /docs/images/examples/events-debug-mode-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/examples/events-debug-mode-example.png -------------------------------------------------------------------------------- /docs/api/core/processing/redis_processing_service.md: -------------------------------------------------------------------------------- 1 | # `RedisProcessingService` class 2 | 3 | ::: pyventus.core.processing.redis.RedisProcessingService 4 | -------------------------------------------------------------------------------- /docs/images/examples/rack-of-electronic-equipment.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdapena/pyventus/HEAD/docs/images/examples/rack-of-electronic-equipment.jpg -------------------------------------------------------------------------------- /docs/api/core/processing/celery_processing_service.md: -------------------------------------------------------------------------------- 1 | # `CeleryProcessingService` class 2 | 3 | ::: pyventus.core.processing.celery.CeleryProcessingService 4 | -------------------------------------------------------------------------------- /src/pyventus/core/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .pyventus_exception import PyventusException 2 | from .pyventus_import_exception import PyventusImportException 3 | -------------------------------------------------------------------------------- /docs/.overrides/outdated.html: -------------------------------------------------------------------------------- 1 | You're not viewing the latest version. 2 | 3 | Click here to go to latest. 4 | -------------------------------------------------------------------------------- /docs/api/core/processing/asyncio_processing_service.md: -------------------------------------------------------------------------------- 1 | # `AsyncIOProcessingService` class 2 | 3 | ::: pyventus.core.processing.asyncio.AsyncIOProcessingService 4 | -------------------------------------------------------------------------------- /docs/api/core/processing/executor_processing_service.md: -------------------------------------------------------------------------------- 1 | # `ExecutorProcessingService` class 2 | 3 | ::: pyventus.core.processing.executor.ExecutorProcessingService 4 | -------------------------------------------------------------------------------- /docs/api/core/processing/fastapi_processing_service.md: -------------------------------------------------------------------------------- 1 | # `FastAPIProcessingService` class 2 | 3 | ::: pyventus.core.processing.fastapi.FastAPIProcessingService 4 | -------------------------------------------------------------------------------- /docs/api/events/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - feedback 4 | --- 5 | 6 | # `Events` module 7 | 8 | ::: pyventus.events 9 | options: 10 | members: 11 | - __doc__ 12 | -------------------------------------------------------------------------------- /src/pyventus/reactive/subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | from .subscriber import ( 2 | CompleteCallbackType, 3 | ErrorCallbackType, 4 | NextCallbackType, 5 | Subscriber, 6 | ) 7 | -------------------------------------------------------------------------------- /docs/api/reactive/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - feedback 4 | --- 5 | 6 | # `Reactive` module 7 | 8 | ::: pyventus.reactive 9 | options: 10 | members: 11 | - __doc__ 12 | -------------------------------------------------------------------------------- /src/pyventus/core/subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .subscription import Subscription 2 | from .subscription_context import SubscriptionContext 3 | from .unsubscribable import Unsubscribable 4 | -------------------------------------------------------------------------------- /docs/api/reactive/observables/observable_utils.md: -------------------------------------------------------------------------------- 1 | ::: pyventus.reactive.observables.observable_utils 2 | options: 3 | show_root_toc_entry: false 4 | members: 5 | - as_observable_task -------------------------------------------------------------------------------- /src/pyventus/events/subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_subscriber import ( 2 | EventCallbackType, 3 | EventSubscriber, 4 | FailureCallbackType, 5 | SuccessCallbackType, 6 | ) 7 | -------------------------------------------------------------------------------- /docs/learn/reactive/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | - feedback 5 | --- 6 | 7 | # Reactive Programming 8 | 9 | !!! warning "🏗️ Work in Progress" 10 | 11 | This section is currently a work in progress. 12 | -------------------------------------------------------------------------------- /docs/.overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | {% include 'outdated.html' %} 5 | {% endblock %} 6 | 7 | {% block announce %} 8 | {% include 'announce.html' ignore missing %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/pyventus/reactive/observables/__init__.py: -------------------------------------------------------------------------------- 1 | from .observable import Completed, Observable 2 | from .observable_task import ObservableTask, ObservableTaskCallbackReturnType, ObservableTaskCallbackType 3 | from .observable_utils import as_observable_task 4 | -------------------------------------------------------------------------------- /docs/api/reactive/observables/observable_task.md: -------------------------------------------------------------------------------- 1 | # `ObservableTask` class 2 | 3 | ::: pyventus.reactive.ObservableTask 4 | options: 5 | filters: ["!^(_.*$)", "^(__init__|__init_subclass__|__call__|__enter__|__exit__)$", "!^ObservableSubCtx$", "!^Completed$"] 6 | -------------------------------------------------------------------------------- /src/pyventus/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .callable_utils import ( 2 | CallableWrapper, 3 | get_callable_name, 4 | is_callable_async, 5 | is_callable_generator, 6 | validate_callable, 7 | ) 8 | from .repr_utils import attributes_repr, formatted_repr, hex_id_repr, summarized_repr 9 | -------------------------------------------------------------------------------- /src/pyventus/__init__.py: -------------------------------------------------------------------------------- 1 | """A powerful Python library for event-driven and reactive programming.""" 2 | 3 | __version__ = "0.7.2" 4 | 5 | from .core.exceptions import PyventusException, PyventusImportException 6 | 7 | __all__ = [ 8 | "PyventusException", 9 | "PyventusImportException", 10 | ] 11 | -------------------------------------------------------------------------------- /src/pyventus/events/emitters/__init__.py: -------------------------------------------------------------------------------- 1 | from .event_emitter import EmittableEventType, EventEmitter 2 | from .event_emitter_utils import ( 3 | AsyncIOEventEmitter, 4 | CeleryEventEmitter, 5 | ExecutorEventEmitter, 6 | ExecutorEventEmitterCtx, 7 | FastAPIEventEmitter, 8 | RedisEventEmitter, 9 | ) 10 | -------------------------------------------------------------------------------- /docs/api/events/emitters/event_emitter_utils.md: -------------------------------------------------------------------------------- 1 | ::: pyventus.events.emitters.event_emitter_utils 2 | options: 3 | show_root_toc_entry: false 4 | members: 5 | - AsyncIOEventEmitter 6 | - CeleryEventEmitter 7 | - ExecutorEventEmitter 8 | - ExecutorEventEmitterCtx 9 | - FastAPIEventEmitter 10 | - RedisEventEmitter 11 | -------------------------------------------------------------------------------- /docs/javascripts/announcement.js: -------------------------------------------------------------------------------- 1 | const texts = document.querySelectorAll(".announcement-subtitle"); 2 | let currentIndex = 0; 3 | 4 | if (texts.length > 1) { 5 | setInterval(() => { 6 | texts[currentIndex].classList.remove("active"); // Hide current text 7 | currentIndex = (currentIndex + 1) % texts.length; // Move to the next text 8 | texts[currentIndex].classList.add("active"); // Show next text 9 | }, 5000); // Change text every 5 seconds 10 | } 11 | -------------------------------------------------------------------------------- /docs/javascripts/mathjax.js: -------------------------------------------------------------------------------- 1 | window.MathJax = { 2 | tex: { 3 | inlineMath: [["\\(", "\\)"]], 4 | displayMath: [["\\[", "\\]"]], 5 | processEscapes: true, 6 | processEnvironments: true 7 | }, 8 | options: { 9 | ignoreHtmlClass: ".*|", 10 | processHtmlClass: "arithmatex" 11 | } 12 | }; 13 | 14 | document$.subscribe(() => { 15 | MathJax.startup.output.clearCache() 16 | MathJax.typesetClear() 17 | MathJax.texReset() 18 | MathJax.typesetPromise() 19 | }) -------------------------------------------------------------------------------- /docs/stylesheets/search-bar.css: -------------------------------------------------------------------------------- 1 | .md-search__form { 2 | border-radius: 1rem; 3 | } 4 | 5 | .md-search__form { 6 | border-top-right-radius: 1rem !important; 7 | border-top-left-radius: 1rem !important; 8 | } 9 | 10 | .md-search__output { 11 | border-bottom-left-radius: 1rem !important; 12 | border-bottom-right-radius: 1rem !important; 13 | } 14 | 15 | @media screen and (max-width: 59.9844em) { 16 | .md-search__form { 17 | border-top-right-radius: inherit !important; 18 | border-top-left-radius: inherit !important; 19 | } 20 | .md-search__output { 21 | border-bottom-left-radius: inherit !important; 22 | border-bottom-right-radius: inherit !important; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/pyventus/core/subscriptions/unsubscribable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class Unsubscribable(ABC): 5 | """An abstract base class representing an object that can be unsubscribed from a subscribed source.""" 6 | 7 | # Allow subclasses to define __slots__ 8 | __slots__ = () 9 | 10 | @abstractmethod 11 | def unsubscribe(self) -> bool: 12 | """ 13 | Release or clean up any resources associated with the subscribed source. 14 | 15 | :return: `True` if the unsubscribe operation was successful; `False` if it was already unsubscribed. 16 | :raises Exception: Any exception raised during the unsubscription process. 17 | """ 18 | pass 19 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you think you have identified a security issue in Pyventus, please **do not open a public issue or discussion**. Instead, follow these steps to securely report the vulnerability: 4 | 5 | 1. Navigate to the `Security` tab of the repository. 6 | 2. Click on `Report a vulnerability` to submit a new report. 7 | 3. Provide a detailed description of the vulnerability, including steps to reproduce if possible. 8 | 4. Include any additional information that would be helpful in understanding and addressing the issue. 9 | 10 | By following this process, you will make sure that these types of issues are handled discreetly and efficiently, keeping both Pyventus and its community secure. 11 | 12 | Thanks for your help! 13 | -------------------------------------------------------------------------------- /src/pyventus/reactive/__init__.py: -------------------------------------------------------------------------------- 1 | """The reactive programming module of Pyventus.""" 2 | 3 | from .observables import ( 4 | Completed, 5 | Observable, 6 | ObservableTask, 7 | ObservableTaskCallbackReturnType, 8 | ObservableTaskCallbackType, 9 | as_observable_task, 10 | ) 11 | from .observers import Observer 12 | from .subscribers import ( 13 | CompleteCallbackType, 14 | ErrorCallbackType, 15 | NextCallbackType, 16 | Subscriber, 17 | ) 18 | 19 | __all__ = [ 20 | "Completed", 21 | "Observable", 22 | "ObservableTask", 23 | "ObservableTaskCallbackReturnType", 24 | "ObservableTaskCallbackType", 25 | "as_observable_task", 26 | "Observer", 27 | "CompleteCallbackType", 28 | "ErrorCallbackType", 29 | "NextCallbackType", 30 | "Subscriber", 31 | ] 32 | -------------------------------------------------------------------------------- /src/pyventus/core/exceptions/pyventus_exception.py: -------------------------------------------------------------------------------- 1 | class PyventusException(Exception): 2 | """ 3 | A custom exception class for the Pyventus package. 4 | 5 | **Notes:** 6 | 7 | - This class provides a robust mechanism for handling and identifying potential 8 | exceptions within the Pyventus package. 9 | 10 | - This class inherits from the base `Exception` class in Python, allowing it to be 11 | raised as needed. 12 | """ 13 | 14 | def __init__(self, errors: str | list[str] | None = None): 15 | """ 16 | Initialize an instance of `PyventusException`. 17 | 18 | :param errors: The error messages associated with the exception. Defaults to `None`. 19 | """ 20 | self.errors: str | list[str] = errors if errors else self.__class__.__name__ 21 | super().__init__(errors) 22 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | - feedback 5 | --- 6 | 7 | # API Reference 8 | 9 |

10 |   Welcome to the Pyventus API Reference, a comprehensive guide that provides detailed information about the classes, functions, parameters, attributes, and other components available in Pyventus. 11 |

12 | 13 |

14 |   In the API Reference, you will find detailed documentation for each component, including clear explanations, parameter details, and return values. You can navigate through the reference using the search functionality or by browsing the different sections and categories to find the specific information you need. 15 |

16 | 17 |
18 |
19 | 20 |

21 | *Let's explore the Pyventus API Reference!* 22 |

23 | -------------------------------------------------------------------------------- /docs/learn/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | - feedback 5 | --- 6 | 7 | # Learn 8 | 9 |

10 |   Welcome to the Learning Zone of Pyventus! In this section, you will find a variety of educational resources that will not only help you grasp the key concepts and core abstractions of the library, but also show you how to effectively apply them within your projects. 11 |

12 | 13 |

14 |   Whether you are new to Pyventus or looking to deepen your knowledge, this section is designed to support your learning process across all levels of experience. You will find tutorials, examples, and introductory content that will help you get started and make the most of Pyventus. 15 |

16 | 17 |
18 |
19 | 20 |

21 | Let's kickstart your Pyventus experience! 22 |

23 | -------------------------------------------------------------------------------- /docs/stylesheets/announcement.css: -------------------------------------------------------------------------------- 1 | .announcement-subtitle-container { 2 | display: grid; 3 | justify-items: start; 4 | align-items: start; 5 | } 6 | 7 | .announcement-subtitle-container > * { 8 | grid-column-start: 1; 9 | grid-row-start: 1; 10 | } 11 | 12 | .announcement-subtitle { 13 | opacity: 0; /* Start hidden */ 14 | transition: opacity 1s ease; /* Smooth transition for opacity */ 15 | pointer-events: none; /* Allow clicks to pass through */ 16 | font-style: italic; 17 | font-weight: bold; 18 | font-size: 80%; 19 | } 20 | 21 | .announcement-subtitle.active { 22 | opacity: 0.75; /* Fully visible when active */ 23 | pointer-events: auto; /* Allow clicks on active text */ 24 | } 25 | 26 | @keyframes announcementSubtitleAnimation { 27 | 0% { 28 | opacity: 0; /* Start hidden */ 29 | } 30 | 50% { 31 | opacity: 0.75; /* Fully visible */ 32 | } 33 | 100% { 34 | opacity: 0; /* Fade out */ 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/utils/object_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def get_private_attr(obj: object, name: str) -> Any: 5 | """ 6 | Retrieve a private attribute from an object. 7 | 8 | :param obj: The object from which to retrieve the attribute. 9 | :param name: The name of the attribute to retrieve. 10 | :return: The value of the requested attribute. 11 | """ 12 | # Access the private attribute using name mangling 13 | return getattr(obj, f"_{type(obj).__name__}{name}") 14 | 15 | 16 | def has_private_attr(obj: object, name: str) -> bool: 17 | """ 18 | Determine if an object has a private attribute. 19 | 20 | :param obj: The object to check for the private attribute. 21 | :param name: The name of the private attribute to check for. 22 | :return: `True` if the private attribute exists, `False` otherwise. 23 | """ 24 | # Check if the object has the private attribute using name mangling 25 | return hasattr(obj, f"_{type(obj).__name__}{name}") 26 | -------------------------------------------------------------------------------- /src/pyventus/events/__init__.py: -------------------------------------------------------------------------------- 1 | """The event-driven programming module of Pyventus.""" 2 | 3 | from .emitters import ( 4 | AsyncIOEventEmitter, 5 | CeleryEventEmitter, 6 | EmittableEventType, 7 | EventEmitter, 8 | ExecutorEventEmitter, 9 | ExecutorEventEmitterCtx, 10 | FastAPIEventEmitter, 11 | RedisEventEmitter, 12 | ) 13 | from .handlers import EventHandler 14 | from .linkers import EventLinker, SubscribableEventType 15 | from .subscribers import EventCallbackType, EventSubscriber, FailureCallbackType, SuccessCallbackType 16 | 17 | __all__ = [ 18 | "AsyncIOEventEmitter", 19 | "CeleryEventEmitter", 20 | "EmittableEventType", 21 | "EventEmitter", 22 | "ExecutorEventEmitter", 23 | "ExecutorEventEmitterCtx", 24 | "FastAPIEventEmitter", 25 | "RedisEventEmitter", 26 | "EventHandler", 27 | "EventLinker", 28 | "SubscribableEventType", 29 | "EventCallbackType", 30 | "EventSubscriber", 31 | "FailureCallbackType", 32 | "SuccessCallbackType", 33 | ] 34 | -------------------------------------------------------------------------------- /tests/pyventus/core/utils/test_repr_utils.py: -------------------------------------------------------------------------------- 1 | from pyventus.core.utils import attributes_repr, formatted_repr 2 | 3 | 4 | class TestReprUtils: 5 | # ================================= 6 | # Test Cases for formatted_repr 7 | # ================================= 8 | 9 | def test_formatted_repr(self) -> None: 10 | # Arrange 11 | class Inner: ... 12 | 13 | inner = Inner() 14 | expected: str = f"<{Inner.__name__} at 0x{id(inner):016X}>" 15 | 16 | # Act 17 | res = formatted_repr(inner) 18 | 19 | # Assert 20 | assert res == expected 21 | 22 | # ================================= 23 | # Test Cases for attributes_repr 24 | # ================================= 25 | 26 | def test_attributes_repr(self) -> None: 27 | # Arrange 28 | expected: str = "attr1='val1', attr2=b'val2', attr3=None" 29 | 30 | # Act 31 | res = attributes_repr(attr1="val1", attr2=b"val2", attr3=None) 32 | 33 | # Assert 34 | assert res == expected 35 | -------------------------------------------------------------------------------- /docs/.overrides/announce.html: -------------------------------------------------------------------------------- 1 | 🚀 Pyventus v0.7 is now live! — 2 | Don't miss the latest updates, 3 | featuring reactive programming, 4 | major optimizations, and more! 5 |
6 | 7 | Working with Python 3.14? Pyventus is now officially compatible, 8 | so you can enjoy the latest features and improvements! 9 | 10 | 11 | Upgrading an existing app? Check out the Upgrade Guide 12 | for essential changes from Pyventus v0.6! 13 | 14 | 15 | Ready to master event-driven programming in Pyventus? Check out the new 16 | Events learning section! 17 | 18 |
-------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Initial Checks 11 | 12 | - [ ] I have added a clear and descriptive title to this issue. 13 | - [ ] I have searched Google & GitHub for similar requests and couldn't find anything. 14 | - [ ] I have searched the project documentation thoroughly using the integrated search tool. 15 | 16 | ### Proposed Feature 17 | 18 | Briefly summarize the proposed new feature in 1-2 sentences. 19 | 20 | ### Description 21 | 22 | Provide a more detailed description of the proposed feature and how it would function. Explain what problem this feature 23 | aims to solve and how it may benefit users. 24 | 25 | > ***Known Limitations*** 26 | > Note any potential edge cases, limitations or technical challenges that may arise with implementing the proposed 27 | > approach. Suggest possible ways to address these issues if applicable. 28 | 29 | ### Additional Comments 30 | 31 | Include any other relevant details, considerations or questions. 32 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # This CITATION.cff file was generated with cffinit. 2 | # Visit https://bit.ly/cffinit to generate yours today! 3 | 4 | cff-version: 1.2.0 5 | title: Pyventus 6 | message: >- 7 | If you use this software, please cite it using the 8 | metadata from this file. 9 | type: software 10 | authors: 11 | - given-names: Manuel 12 | family-names: Da Pena 13 | email: dapensoft@gmail.com 14 | repository-code: 'https://github.com/mdapena/pyventus' 15 | url: 'https://mdapena.github.io/pyventus/' 16 | abstract: >- 17 | A powerful Python library for event-driven and 18 | reactive programming. 19 | keywords: 20 | - event 21 | - events 22 | - event driven 23 | - event-driven 24 | - event driven programming 25 | - event-driven programming 26 | - EventEmitter 27 | - event emitter 28 | - event emitters 29 | - reactive 30 | - reactive programming 31 | - reactive-programming 32 | - observer pattern 33 | - observable 34 | - observables 35 | - observer 36 | - observers 37 | - asynchronous 38 | - asynchronous programming 39 | - asynchronous-programming 40 | license: MIT 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Initial Checks 11 | 12 | - [ ] I have thoroughly checked that this issue has not already been reported. 13 | - [ ] I have searched the project documentation and forums for related issues or solutions. 14 | - [ ] I have checked if the issue can be reproduced with the latest version of the software. 15 | - [ ] I understand that security-related issues should not be reported in public and will submit privately instead. 16 | 17 | ### Summary 18 | 19 | Provide a brief summary of the issue. 20 | 21 | ### Description 22 | 23 | - Include exact commands, code snippets, or actions to reproduce the unexpected behavior. 24 | - Describe what should occur based on how the software is intended to function. 25 | - Detail precisely what went wrong or what unexpected results were observed. 26 | 27 | ### Environment 28 | 29 | - Operating System. 30 | - Pyventus Version. 31 | - Python Version. 32 | 33 | ### Additional context 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Manuel Da Pena 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Initial Checks 2 | 3 | - [ ] The code compiles successfully without any errors or warnings. 4 | - [ ] I have personally tested these changes on my local environment. 5 | - [ ] I have ensured that relevant documentation has been added or updated. 6 | - [ ] I adhere to the project structure and code standards defined in the documentation. 7 | - [ ] My code follows the established coding style guidelines. 8 | - [ ] I have added tests for the new code (if applicable). 9 | - [ ] All existing tests have passed successfully. 10 | 11 | ## Description 12 | 13 | Please provide a detailed explanation of the modifications made in this pull request. 14 | > **Note**: 15 | > Include specific information about the problem or feature being addressed, the approach taken to solve it, and any 16 | > notable considerations or implications of the changes. 17 | 18 | ## Related Issues (if applicable) 19 | 20 | Link any related issues or pull requests using GitHub references (e.g., #123). 21 | 22 | ## Screenshots (if applicable) 23 | 24 | Add any relevant screenshots or images to assist with the review process. 25 | 26 | ## Additional Notes 27 | 28 | Add any additional information or notes for reviewers. -------------------------------------------------------------------------------- /tests/fixtures/event_fixtures.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from types import EllipsisType 3 | from typing import Any, Final, final 4 | 5 | from pyventus import PyventusException 6 | 7 | 8 | @final 9 | class EventFixtures: 10 | """Event Fixtures containing various data classes and exceptions.""" 11 | 12 | Str: Final[str] = "StringEvent" 13 | 14 | Exc: Final[type[ValueError]] = ValueError 15 | 16 | class CustomExc(ValueError): 17 | def __init__(self) -> None: 18 | super().__init__("Custom Exception!") 19 | 20 | @dataclass 21 | class EmptyDtc: 22 | pass 23 | 24 | @dataclass 25 | class DtcImmutable: 26 | attr1: str 27 | attr2: tuple[str, ...] 28 | 29 | @dataclass 30 | class DtcMutable: 31 | attr1: list[str] 32 | attr2: dict[str, str] 33 | 34 | @dataclass 35 | class DtcWithVal: 36 | attr1: str # It must be at least 3 characters. 37 | attr2: Any 38 | 39 | def __post_init__(self) -> None: 40 | if len(self.attr1) < 3: 41 | raise PyventusException(f"[{type(self).__name__}] Error: 'attr1' must be at least 3 characters.") 42 | 43 | class NonDtc: 44 | pass 45 | 46 | All: EllipsisType = Ellipsis 47 | -------------------------------------------------------------------------------- /tests/pyventus/core/loggers/test_logger.py: -------------------------------------------------------------------------------- 1 | from logging import DEBUG 2 | from typing import Any 3 | 4 | import pytest 5 | from pyventus.core.loggers import Logger, StdOutLogger 6 | 7 | 8 | class TestLogger: 9 | # ================================= 10 | # Test Cases for creation 11 | # ================================= 12 | 13 | @pytest.mark.parametrize( 14 | ["source"], 15 | [ 16 | (None,), 17 | ("String",), 18 | (object,), 19 | (object(),), 20 | ], 21 | ) 22 | def test_creation(self, source: Any) -> None: 23 | # Arrange | Act 24 | StdOutLogger.config(level=DEBUG) 25 | logger = Logger(source=source, debug=True) 26 | 27 | # Assert 28 | assert logger and logger.debug_enabled 29 | logger.critical(action="Action:", msg="Critical %(levelcolor)slevel%(defaultcolor)s message.") 30 | logger.error(action="Action:", msg="Error %(levelcolor)slevel%(defaultcolor)s message.") 31 | logger.warning(action="Action:", msg="Warning %(levelcolor)slevel%(defaultcolor)s message.") 32 | logger.info(action="Action:", msg="Info %(levelcolor)slevel%(defaultcolor)s message.") 33 | logger.debug(action="Action:", msg="Debug %(levelcolor)slevel%(defaultcolor)s message.") 34 | -------------------------------------------------------------------------------- /tests/pyventus/core/exceptions/test_pyventus_exception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyventus import PyventusException 3 | 4 | 5 | class TestPyventusException: 6 | # ================================= 7 | # Test Cases for creation 8 | # ================================= 9 | 10 | def test_creation_without_arguments(self) -> None: 11 | # Arrange/Act 12 | exception = PyventusException() 13 | 14 | # Assert 15 | assert exception is not None 16 | assert isinstance(exception, PyventusException) 17 | assert exception.errors == exception.__class__.__name__ 18 | 19 | # ================================= 20 | 21 | def test_creation_with_arguments(self) -> None: 22 | # Arrange 23 | errors: str | list[str] = "Test exception!" 24 | 25 | # Act 26 | exception = PyventusException(errors) 27 | 28 | # Assert 29 | assert exception is not None 30 | assert isinstance(exception, PyventusException) 31 | assert exception.errors == errors 32 | 33 | # ================================= 34 | # Test Cases for propagation 35 | # ================================= 36 | 37 | def test_error_propagation(self) -> None: 38 | # Arrange/Act/Assert 39 | with pytest.raises(PyventusException): 40 | raise PyventusException("Test exception!") 41 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/processing_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections.abc import Callable 3 | from typing import Any, TypeAlias 4 | 5 | ProcessingServiceCallbackType: TypeAlias = Callable[..., Any] 6 | """Type alias for the callback that can be executed by the processing service.""" 7 | 8 | 9 | class ProcessingService(ABC): 10 | """ 11 | A base class that defines a common interface for processing calls. 12 | 13 | **Notes:** 14 | 15 | - The main goal of this class is to decouple the process of executing calls from 16 | the underlying implementation, thereby establishing a template for defining a 17 | variety of strategies to manage the execution. 18 | """ 19 | 20 | # Allow subclasses to define __slots__ 21 | __slots__ = () 22 | 23 | @abstractmethod 24 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 25 | """ 26 | Submit a callback along with its arguments for execution. 27 | 28 | Subclasses must implement this method to define the specific execution strategy. 29 | 30 | :param callback: The callback to be executed. 31 | :param args: Positional arguments to be passed to the callback. 32 | :param kwargs: Keyword arguments to be passed to the callback. 33 | :return: None. 34 | """ 35 | pass 36 | -------------------------------------------------------------------------------- /src/pyventus/core/constants/stdout_colors.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | 4 | class StdOutColors: 5 | """A utility class for ANSI escape codes that add color and style to console text output.""" 6 | 7 | DEFAULT: str = "\033[0m" 8 | PURPLE: str = "\033[35m" 9 | YELLOW: str = "\033[33m" 10 | BLUE: str = "\033[34m" 11 | RED: str = "\033[31m" 12 | CYAN: str = "\033[36m" 13 | GREEN: str = "\033[32m" 14 | BOLD: str = "\033[1m" 15 | UNDERLINE: str = "\033[4m" 16 | 17 | DEFAULT_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.DEFAULT}{text}" 18 | PURPLE_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.PURPLE}{text}{StdOutColors.DEFAULT}" 19 | YELLOW_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.YELLOW}{text}{StdOutColors.DEFAULT}" 20 | BLUE_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.BLUE}{text}{StdOutColors.DEFAULT}" 21 | RED_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.RED}{text}{StdOutColors.DEFAULT}" 22 | CYAN_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.CYAN}{text}{StdOutColors.DEFAULT}" 23 | GREEN_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.GREEN}{text}{StdOutColors.DEFAULT}" 24 | BOLD_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.BOLD}{text}{StdOutColors.DEFAULT}" 25 | UNDERLINE_TEXT: Callable[[str], str] = lambda text: f"{StdOutColors.UNDERLINE}{text}{StdOutColors.DEFAULT}" 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v5 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v6 19 | with: 20 | python-version: "3.x" 21 | 22 | - name: Install pypa/build 23 | run: python -m pip install build --user 24 | 25 | - name: Build a binary wheel and a source tarball 26 | run: python -m build 27 | 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v5 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | 34 | publish-to-pypi: 35 | name: Publish Python 🐍 distribution 📦 to PyPI 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/pyventus 43 | 44 | permissions: 45 | id-token: write # IMPORTANT: mandatory for trusted publishing 46 | 47 | steps: 48 | - name: Download all the dists 49 | uses: actions/download-artifact@v6 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | 54 | - name: Publish distribution 📦 to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /src/pyventus/reactive/observers/observer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic, TypeVar 3 | 4 | _InT = TypeVar("_InT", contravariant=True) 5 | """A generic type representing the input value for the `next` method of the observer.""" 6 | 7 | 8 | class Observer(ABC, Generic[_InT]): 9 | """ 10 | A base class that defines the workflow and essential protocols for responding to notifications from an observable. 11 | 12 | **Notes:** 13 | 14 | - This class is parameterized by the type of value that will be received in the `next` method. 15 | 16 | """ 17 | 18 | # Allow subclasses to define __slots__ 19 | __slots__ = () 20 | 21 | @abstractmethod 22 | async def next(self, value: _InT) -> None: 23 | """ 24 | Handle the next value emitted by the observable. 25 | 26 | :param value: The value emitted by the observable. 27 | :return: None. 28 | """ 29 | pass 30 | 31 | @abstractmethod 32 | async def error(self, exception: Exception) -> None: 33 | """ 34 | Handle an error that occurs in the observable. 35 | 36 | :param exception: The exception that was raised by the observable. 37 | :return: None. 38 | """ 39 | pass 40 | 41 | @abstractmethod 42 | async def complete(self) -> None: 43 | """ 44 | Handle the completion of the observable. 45 | 46 | :return: None. 47 | """ 48 | pass 49 | -------------------------------------------------------------------------------- /tests/pyventus/core/exceptions/test_pyventus_import_exception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyventus import PyventusImportException 3 | 4 | 5 | class TestPyventusImportException: 6 | # ================================= 7 | # Test Cases for creation 8 | # ================================= 9 | 10 | @pytest.mark.parametrize( 11 | ["import_name", "is_optional", "is_dependency"], 12 | [ 13 | ("pyventus", False, False), 14 | ("pyventus", True, False), 15 | ("pyventus", False, True), 16 | ("pyventus", True, True), 17 | ], 18 | ) 19 | def test_creation_with_arguments(self, import_name: str, is_optional: bool, is_dependency: bool) -> None: 20 | # Arrange/Act 21 | exception = PyventusImportException(import_name, is_optional=is_optional, is_dependency=is_dependency) 22 | 23 | # Assert 24 | assert exception is not None 25 | assert isinstance(exception, PyventusImportException) 26 | assert exception.errors is not None and len(exception.errors) > 0 27 | assert exception.is_optional == is_optional 28 | assert exception.is_dependency == is_dependency 29 | 30 | # ================================= 31 | # Test Cases for propagation 32 | # ================================= 33 | 34 | def test_error_propagation(self) -> None: 35 | # Arrange/Act/Assert 36 | with pytest.raises(PyventusImportException): 37 | raise PyventusImportException("pyventus") 38 | -------------------------------------------------------------------------------- /docs/learn/events/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | --- 5 | 6 | # Event-Driven Programming 7 | 8 |

9 |   Event-driven programming is a paradigm in which the flow of a program is determined by events. These events can be generated by both internal and external actions, such as user interactions (mouse clicks, keyboard inputs, etc), system-generated signals (timers, network messages, etc), or other internal processes. 10 |

11 | 12 |

13 |   In the context of Pyventus, event handling is based on the Publisher-Subscriber pattern[^1], which is an event-driven model where events can be published by one or more sources and consumed by one or more subscribers. This design enables different parts of an application to communicate effectively without needing to be aware of each other. 14 |

15 | 16 |

17 |   What makes Pyventus unique is its distinctive approach to the Publisher-Subscriber pattern. While it provides features similar to traditional event emitter libraries, it also leverages Python's unique characteristics. Pyventus allows you to organize your code around discrete events and their responses in a centralized manner across different contexts, as well as control their emission, propagation, and processing. 18 |

19 | 20 | [^1]: For further reading about the Publisher-Subscriber pattern, you can refer to the Publish–subscribe pattern on Wikipedia. 21 | -------------------------------------------------------------------------------- /src/pyventus/core/exceptions/pyventus_import_exception.py: -------------------------------------------------------------------------------- 1 | from .pyventus_exception import PyventusException 2 | 3 | 4 | class PyventusImportException(PyventusException): 5 | """ 6 | A custom Pyventus exception for handling missing imports within the package. 7 | 8 | **Notes:** 9 | 10 | - This class provides a robust mechanism for handling and identifying potential 11 | import exceptions within the Pyventus package. 12 | 13 | - This class inherits from the base `PyventusException` class, allowing it to be 14 | raised as needed. 15 | """ 16 | 17 | def __init__(self, import_name: str, *, is_optional: bool = False, is_dependency: bool = False) -> None: 18 | """ 19 | Initialize an instance of `PyventusImportException`. 20 | 21 | :param import_name: The name of the missing import. 22 | :param is_optional: A flag indicating whether the missing import is optional 23 | or required for the package to work. Defaults to `False` (required). 24 | :param is_dependency: A flag indicating whether the missing import is an 25 | external dependency or not. Defaults to `False` (local import). 26 | """ 27 | # Store the import name and properties. 28 | self.import_name: str = import_name 29 | self.is_optional: bool = is_optional 30 | self.is_dependency: bool = is_dependency 31 | 32 | # Initialize the base PyventusException class with the error message. 33 | super().__init__( 34 | f"Missing {'optional ' if is_optional else ''}{'dependency' if is_dependency else 'import'}: {import_name}", 35 | ) 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | release: 8 | types: 9 | - created 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | env: 19 | GOOGLE_ANALYTICS_KEY: ${{ secrets.GOOGLE_ANALYTICS_KEY }} 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v5 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Configure Git Credentials 27 | run: | 28 | git config user.name github-actions[bot] 29 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 30 | - uses: actions/setup-python@v6 31 | with: 32 | python-version: "3.11" 33 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 34 | - uses: actions/cache@v4 35 | with: 36 | key: mkdocs-material-${{ env.cache_id }} 37 | path: .cache 38 | restore-keys: | 39 | mkdocs-material- 40 | - name: Install dependencies 41 | run: pip install .[docs] 42 | - name: Deploy dev documentation 43 | if: github.ref == 'refs/heads/master' 44 | run: mike deploy --push dev 45 | - name: Get Pyventus Docs Version 46 | if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' 47 | run: | 48 | pyventus_docs_version=$(python -c "import pyventus; print('.'.join(map(str, pyventus.__version__.split('.')[:2])))") 49 | echo "PYVENTUS_DOCS_VERSION=$pyventus_docs_version" >> $GITHUB_ENV 50 | - name: Deploy release documentation 51 | if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' 52 | run: mike deploy --push --update-aliases $PYVENTUS_DOCS_VERSION latest 53 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | test: 14 | name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ ubuntu-latest, windows-latest, macos-latest ] 20 | python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v5 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v6 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Ensure latest pip 31 | run: python -m pip install --upgrade pip 32 | 33 | - name: Install dependencies 34 | run: pip install hatch 35 | 36 | - name: Run all tests 37 | run: hatch run +py=${{ matrix.python-version }} tests:all 38 | 39 | - name: Coveralls Parallel 40 | uses: coverallsapp/github-action@v2 41 | with: 42 | files: coverage.xml 43 | flag-name: py${{ matrix.python-version }}-${{ matrix.os }} 44 | parallel: true 45 | 46 | coveralls-finish: 47 | needs: 48 | - test 49 | 50 | if: ${{ always() }} 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - name: Coveralls Finished 55 | uses: coverallsapp/github-action@v2 56 | with: 57 | parallel-finished: true 58 | 59 | # https://github.com/marketplace/actions/alls-green#why 60 | alls-green: # This job does nothing and is only used for the branch protection 61 | if: always() 62 | needs: 63 | - coveralls-finish 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Decide whether the needed jobs succeeded or failed 67 | uses: re-actors/alls-green@release/v1 68 | with: 69 | jobs: ${{ toJSON(needs) }} 70 | -------------------------------------------------------------------------------- /tests/pyventus/reactive/observables/test_observable_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyventus.reactive import ObservableTask, as_observable_task 3 | from typing_extensions import Any 4 | 5 | from ....fixtures import CallableMock 6 | from ....utils import get_private_attr 7 | 8 | 9 | class TestObservableUtils: 10 | # ================================= 11 | # Test Cases for as_observable_task() 12 | # ================================= 13 | 14 | @pytest.mark.parametrize( 15 | ["callback", "args", "kwargs"], 16 | [ 17 | (CallableMock.Sync(), (), {}), 18 | (CallableMock.SyncGenerator(), ("pi", 3.14), {}), 19 | (CallableMock.Async(), (), {"...": ...}), 20 | (CallableMock.AsyncGenerator(), ("pi", 3.14), {"...": ...}), 21 | ], 22 | ) 23 | def test_as_observable_task_decorator( 24 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 25 | ) -> None: 26 | # Arrange 27 | decorated_callback = as_observable_task(callback) 28 | 29 | # Act 30 | observable_task: ObservableTask[Any] = decorated_callback(*args, **kwargs) 31 | observable_task_callback = get_private_attr(observable_task, "__callback") 32 | 33 | # Assert 34 | assert callable(decorated_callback) 35 | assert isinstance(observable_task, ObservableTask) 36 | assert get_private_attr(observable_task_callback, "__callable") is callback 37 | assert get_private_attr(observable_task, "__args") == args 38 | assert get_private_attr(observable_task, "__kwargs") == kwargs 39 | 40 | # ================================= 41 | 42 | @pytest.mark.parametrize( 43 | ["debug"], 44 | [(False,), (True,)], 45 | ) 46 | def test_as_observable_task_decorator_with_parameters(self, debug: bool) -> None: 47 | # Arrange 48 | callback = CallableMock.Sync() 49 | decorated_callback = as_observable_task(debug=debug)(callback) 50 | 51 | # Act 52 | observable_task = decorated_callback() 53 | 54 | # Assert 55 | assert isinstance(observable_task, ObservableTask) 56 | assert getattr(observable_task, "_Observable__logger").debug_enabled is debug # noqa: B009 57 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/asyncio/test_asyncio_processing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 4 | from typing_extensions import override 5 | 6 | from .....fixtures import CallableMock 7 | from ..processing_service_test import ProcessingServiceTest 8 | 9 | 10 | class TestAsyncIOProcessingService(ProcessingServiceTest): 11 | # ================================= 12 | # Test Cases for creation 13 | # ================================= 14 | 15 | def test_creation(self) -> None: 16 | # Arrange/Act 17 | processing_service = AsyncIOProcessingService() 18 | 19 | # Assert 20 | assert processing_service is not None 21 | assert isinstance(processing_service, AsyncIOProcessingService) 22 | 23 | # ================================= 24 | # Test Cases for is_loop_running 25 | # ================================= 26 | 27 | def test_is_loop_running_in_sync_context(self) -> None: 28 | # Arrange/Act/Assert 29 | loop_running: bool = AsyncIOProcessingService.is_loop_running() 30 | assert not loop_running 31 | 32 | # ================================= 33 | 34 | async def test_is_loop_running_in_async_context(self) -> None: 35 | # Arrange/Act/Assert 36 | loop_running: bool = AsyncIOProcessingService.is_loop_running() 37 | assert loop_running 38 | 39 | # ================================= 40 | # Test Cases for submission 41 | # ================================= 42 | 43 | @override 44 | def handle_submission_in_sync_context( 45 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 46 | ) -> None: 47 | # Arrange 48 | processing_service = AsyncIOProcessingService() 49 | 50 | # Act 51 | processing_service.submit(callback, *args, **kwargs) 52 | 53 | # ================================= 54 | 55 | @override 56 | async def handle_submission_in_async_context( 57 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 58 | ) -> None: 59 | # Arrange 60 | processing_service = AsyncIOProcessingService() 61 | 62 | # Act 63 | processing_service.submit(callback, *args, **kwargs) 64 | await processing_service.wait_for_tasks() 65 | -------------------------------------------------------------------------------- /docs/.overrides/404.html: -------------------------------------------------------------------------------- 1 | {% extends "main.html" %} 2 | 3 | 4 | {% block content %} 5 | 18 | 19 | 30 | 31 |

404 - Page Not Found

32 | 33 |

34 | The requested URL was not found in this documentation. 35 |

36 | 37 |

38 | Try searching or go to Pyventus' home page. 39 |

40 | 41 | 42 | Gray Clouds Background 43 | 46 | 47 | 48 | {% endblock %} -------------------------------------------------------------------------------- /src/pyventus/core/utils/repr_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def formatted_repr(instance: object, info: str | None = None) -> str: 5 | """ 6 | Generate a formatted string representation of an object instance. 7 | 8 | This function provides a consistent format for representing an object, including 9 | its class name, memory address, and any additional context. It is designed to help 10 | standardize the output of `__repr__` methods across different classes, making it 11 | easier to identify and debug object instances. 12 | 13 | :param instance: The object to be represented. It should be an instance of any class. 14 | :param info: Optional string providing extra information about the object. Defaults to `None`. 15 | :return: A formatted string representation of the object, structured as: 16 | "<`ClassName` at `MemoryAddress` with `Info`>". 17 | """ 18 | return f"<{instance.__class__.__name__} at {hex_id_repr(instance)}{f' with {info}' if info else ''}>" 19 | 20 | 21 | def attributes_repr(**kwargs: Any) -> str: 22 | """ 23 | Create a formatted string representation of specified attributes. 24 | 25 | :param kwargs: Keyword arguments representing attribute names and their corresponding values. 26 | :return: A string formatted as "key1=value1, key2=value2, ..." that provides a concise 27 | overview of the attributes. 28 | """ 29 | return ", ".join(f"{key}={value!r}" for key, value in kwargs.items()) 30 | 31 | 32 | def summarized_repr(instance: object) -> str: 33 | """ 34 | Generate a summary representation of an object instance. 35 | 36 | This function returns a formatted string that includes the name of the class 37 | of the given instance and its unique identifier in the format `ClassName`. 38 | 39 | :param instance: The object instance for which to generate the summary representation. 40 | :return: A string in the format `ClassName(id)`. 41 | """ 42 | return f"{type(instance).__name__}({hex_id_repr(instance)})" 43 | 44 | 45 | def hex_id_repr(instance: object) -> str: 46 | """ 47 | Return the hexadecimal string representation of the unique identifier (ID) for a given object instance. 48 | 49 | :param instance: The object instance for which to retrieve the ID. 50 | :return: A string representing the hexadecimal ID of the object, formatted as `0x(16-character hex number)`. 51 | """ 52 | return f"0x{id(instance):016X}" 53 | -------------------------------------------------------------------------------- /src/pyventus/events/handlers/event_handler.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | from ...core.loggers import StdOutLogger 5 | from ...core.utils import summarized_repr 6 | 7 | 8 | class EventHandler(ABC): 9 | """An abstract base class that defines the workflow and essential protocols for event handling.""" 10 | 11 | # Allow subclasses to define __slots__ 12 | __slots__ = () 13 | 14 | @abstractmethod 15 | async def _handle_event(self, *args: Any, **kwargs: Any) -> Any: 16 | """ 17 | Handle the event response. 18 | 19 | :param args: Positional arguments containing event-specific data. 20 | :param kwargs: Keyword arguments containing event-specific data. 21 | :return: The result of handling the event. 22 | """ 23 | pass 24 | 25 | @abstractmethod 26 | async def _handle_success(self, results: Any) -> None: 27 | """ 28 | Handle the successful completion of the event response. 29 | 30 | :param results: The results of handling the event. 31 | :return: None. 32 | """ 33 | pass 34 | 35 | @abstractmethod 36 | async def _handle_failure(self, exception: Exception) -> None: 37 | """ 38 | Handle the failed completion of the event response. 39 | 40 | :param exception: The exception that occurred during the event handling. 41 | :return: None. 42 | """ 43 | pass 44 | 45 | async def execute(self, *args: Any, **kwargs: Any) -> None: 46 | """ 47 | Execute the event workflow. 48 | 49 | :param args: Positional arguments containing event-specific data. 50 | :param kwargs: Keyword arguments containing event-specific data. 51 | :return: None. 52 | """ 53 | try: 54 | # Start the event handling process and store the results 55 | results: Any = await self._handle_event(*args, **kwargs) 56 | except Exception as exception: 57 | # Log the exception that occurred during the event handling. 58 | StdOutLogger.error( 59 | source=summarized_repr(self), 60 | action="Exception:", 61 | msg=f"{repr(exception)}", 62 | exc_info=True, 63 | ) 64 | 65 | # Handle the failed completion of the event response. 66 | await self._handle_failure(exception=exception) 67 | else: 68 | # Handle the successful completion of the event response. 69 | await self._handle_success(results=results) 70 | -------------------------------------------------------------------------------- /src/pyventus/reactive/observables/observable_utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import wraps 3 | from typing import ParamSpec, TypeVar 4 | 5 | from typing_extensions import overload 6 | 7 | from .observable_task import ObservableTask, ObservableTaskCallbackReturnType 8 | 9 | _P = ParamSpec("_P") 10 | """A generic type representing the names and types of the callback parameters.""" 11 | 12 | _OutT = TypeVar("_OutT", covariant=True) 13 | """A generic type for the value that will be streamed through the observable task.""" 14 | 15 | 16 | @overload 17 | def as_observable_task( 18 | callback: Callable[_P, ObservableTaskCallbackReturnType[_OutT]], / 19 | ) -> Callable[_P, ObservableTask[_OutT]]: ... 20 | 21 | 22 | @overload 23 | def as_observable_task( 24 | *, debug: bool 25 | ) -> Callable[[Callable[_P, ObservableTaskCallbackReturnType[_OutT]]], Callable[_P, ObservableTask[_OutT]]]: ... 26 | 27 | 28 | def as_observable_task( 29 | callback: Callable[_P, ObservableTaskCallbackReturnType[_OutT]] | None = None, /, *, debug: bool | None = None 30 | ) -> ( 31 | Callable[_P, ObservableTask[_OutT]] 32 | | Callable[[Callable[_P, ObservableTaskCallbackReturnType[_OutT]]], Callable[_P, ObservableTask[_OutT]]] 33 | ): 34 | """ 35 | Convert a given callback into an observable task. 36 | 37 | **Notes:** 38 | 39 | - The decorated callback can be either a standard `sync` or `async` function, as well as 40 | a `sync` or `async` generator to stream data through the `ObservableTask`. 41 | 42 | - It is important to note that the decorated callback is executed not upon calling the output 43 | function, but rather when the `ObservableTask` produced by the output function is executed. 44 | 45 | :param callback: The callback to be encapsulated and made observable. 46 | :param debug: Specifies the debug mode for the logger. If `None`, 47 | the mode is determined based on the execution environment. 48 | :return: A function that, upon invocation, generates an `ObservableTask`. 49 | """ 50 | 51 | def decorator( 52 | _callback: Callable[_P, ObservableTaskCallbackReturnType[_OutT]], 53 | ) -> Callable[_P, ObservableTask[_OutT]]: 54 | @wraps(_callback) 55 | def helper(*args: _P.args, **kwargs: _P.kwargs) -> ObservableTask[_OutT]: 56 | # Create an ObservableTask instance based on the provided callback. 57 | return ObservableTask[_OutT](callback=_callback, args=args, kwargs=kwargs, debug=debug) 58 | 59 | return helper 60 | 61 | return decorator(callback) if callback is not None else decorator 62 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/fastapi/fastapi_processing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from typing_extensions import override 4 | 5 | from ...exceptions import PyventusException, PyventusImportException 6 | from ...utils import attributes_repr, formatted_repr 7 | from ..processing_service import ProcessingService, ProcessingServiceCallbackType 8 | 9 | try: # pragma: no cover 10 | from fastapi import BackgroundTasks 11 | except ImportError: # pragma: no cover 12 | raise PyventusImportException(import_name="fastapi", is_optional=True, is_dependency=True) from None 13 | 14 | 15 | class FastAPIProcessingService(ProcessingService): 16 | """ 17 | A processing service that utilizes the FastAPI's `BackgroundTasks` to handle the execution of calls. 18 | 19 | **Notes:** 20 | 21 | - This service is specifically designed for FastAPI applications and leverages FastAPI's `BackgroundTasks` 22 | to handle the callbacks' execution. This is useful for operations that need to happen after a request, but 23 | that the client doesn't really have to be waiting for the operation to complete before receiving the response. 24 | """ 25 | 26 | # Attributes for the FastAPIProcessingService 27 | __slots__ = ("__background_tasks",) 28 | 29 | def __init__(self, background_tasks: BackgroundTasks) -> None: 30 | """ 31 | Initialize an instance of `FastAPIProcessingService`. 32 | 33 | :param background_tasks: The FastAPI `BackgroundTasks` object used to handle callbacks' execution. 34 | :return: None. 35 | """ 36 | # Validate the background_tasks instance. 37 | if background_tasks is None or not isinstance(background_tasks, BackgroundTasks): 38 | raise PyventusException("The 'background_tasks' argument must be an instance of the BackgroundTasks.") 39 | 40 | # Store the BackgroundTasks instance. 41 | self.__background_tasks: BackgroundTasks = background_tasks 42 | 43 | def __repr__(self) -> str: 44 | """ 45 | Retrieve a string representation of the instance. 46 | 47 | :return: A string representation of the instance. 48 | """ 49 | return formatted_repr( 50 | instance=self, 51 | info=attributes_repr( 52 | background_tasks=self.__background_tasks, 53 | ), 54 | ) 55 | 56 | @override 57 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 58 | # Add the callback to the background_tasks instance as a new task to be executed. 59 | self.__background_tasks.add_task(callback, *args, **kwargs) 60 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/redis/redis_processing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from typing_extensions import override 4 | 5 | from ...exceptions import PyventusException, PyventusImportException 6 | from ...utils import attributes_repr, formatted_repr 7 | from ..processing_service import ProcessingService, ProcessingServiceCallbackType 8 | 9 | try: # pragma: no cover 10 | from rq import Queue 11 | except ImportError: # pragma: no cover 12 | raise PyventusImportException(import_name="rq", is_optional=True, is_dependency=True) from None 13 | 14 | 15 | class RedisProcessingService(ProcessingService): 16 | """ 17 | A processing service that utilizes the `Redis Queue` framework to handle the execution of calls. 18 | 19 | **Notes:** 20 | 21 | - This service leverages the `RQ` Python package to enqueue the provided callbacks into a Redis 22 | distributed task system, which is monitored by multiple workers. Once enqueued, these callbacks 23 | are eligible for retrieval and processing by available workers, enabling a scalable and 24 | distributed approach to handling calls asynchronously. 25 | 26 | - Synchronous callbacks are executed in a blocking manner inside the worker, while asynchronous 27 | callbacks are processed within a new asyncio event loop using the `asyncio.run()` function. 28 | """ 29 | 30 | # Attributes for the RedisProcessingService 31 | __slots__ = ("__queue", "__options") 32 | 33 | def __init__(self, queue: Queue, options: dict[str, Any] | None = None) -> None: 34 | """ 35 | Initialize an instance of `RedisProcessingService`. 36 | 37 | :param queue: The Redis queue used to enqueue and process callbacks. 38 | :param options: Additional options for the RQ package enqueueing method. 39 | Defaults to None (an empty dictionary). 40 | :return: None. 41 | :raises PyventusException: If the 'queue' argument is None or not an instance 42 | of the `Queue` class. 43 | """ 44 | # Validate the queue instance. 45 | if queue is None or not isinstance(queue, Queue): 46 | raise PyventusException("The 'queue' argument must be an instance of the Queue class.") 47 | 48 | # Store the Redis queue and RQ options 49 | self.__queue: Queue = queue 50 | self.__options: dict[str, Any] = options if options else {} 51 | 52 | def __repr__(self) -> str: 53 | """ 54 | Retrieve a string representation of the instance. 55 | 56 | :return: A string representation of the instance. 57 | """ 58 | return formatted_repr( 59 | instance=self, 60 | info=attributes_repr( 61 | queue=self.__queue, 62 | options=self.__options, 63 | ), 64 | ) 65 | 66 | @override 67 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 68 | # Send the callback and its arguments to Redis for asynchronous execution. 69 | self.__queue.enqueue(callback, *args, **kwargs, **self.__options) 70 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/redis/test_redis_processing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from fakeredis import FakeStrictRedis 5 | from pyventus import PyventusException 6 | from pyventus.core.processing.redis import RedisProcessingService 7 | from pyventus.core.utils import is_callable_async 8 | from rq import Queue 9 | from typing_extensions import override 10 | 11 | from .....fixtures import CallableMock 12 | from ..processing_service_test import ProcessingServiceTest 13 | 14 | 15 | class TestRedisProcessingService(ProcessingServiceTest): 16 | # ================================= 17 | # Test Cases for creation 18 | # ================================= 19 | 20 | @pytest.mark.parametrize( 21 | ["queue", "options"], 22 | [ 23 | (Queue(connection=FakeStrictRedis(), is_async=False), None), 24 | (Queue(connection=FakeStrictRedis(), is_async=False), {"ttl": 30}), 25 | ], 26 | ) 27 | def test_creation_with_valid_input(self, queue: Queue, options: dict[str, Any]) -> None: 28 | # Arrange/Act 29 | processing_service = RedisProcessingService(queue=queue, options=options) 30 | 31 | # Assert 32 | assert processing_service is not None 33 | assert isinstance(processing_service, RedisProcessingService) 34 | 35 | # ================================= 36 | 37 | @pytest.mark.parametrize( 38 | ["queue", "options", "exception"], 39 | [ 40 | (None, None, PyventusException), 41 | (True, None, PyventusException), 42 | (object(), None, PyventusException), 43 | (Queue, None, PyventusException), 44 | ], 45 | ) 46 | def test_creation_with_invalid_input(self, queue: Any, options: dict[str, Any], exception: type[Exception]) -> None: 47 | # Arrange/Act/Assert 48 | with pytest.raises(exception): 49 | RedisProcessingService(queue=queue, options=options) 50 | 51 | # ================================= 52 | # Test Cases for submission 53 | # ================================= 54 | 55 | @override 56 | def handle_submission_in_sync_context( 57 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 58 | ) -> None: 59 | # Arrange 60 | processing_service = RedisProcessingService(queue=Queue(connection=FakeStrictRedis(), is_async=False)) 61 | 62 | # Act 63 | processing_service.submit(callback, *args, **kwargs) 64 | 65 | # ================================= 66 | 67 | @override 68 | async def handle_submission_in_async_context( 69 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 70 | ) -> None: 71 | # Skip the test if the callback is async, as RQ cannot 72 | # execute async callbacks in an async context during testing. 73 | if is_callable_async(callback): 74 | pytest.skip("During testing, the RQ package cannot execute async callbacks in an async context.") 75 | 76 | # Arrange 77 | processing_service = RedisProcessingService(queue=Queue(connection=FakeStrictRedis(), is_async=False)) 78 | 79 | # Act 80 | processing_service.submit(callback, *args, **kwargs) 81 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/executor/test_executor_processing_service.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor 2 | from typing import Any 3 | 4 | import pytest 5 | from pyventus import PyventusException 6 | from pyventus.core.processing.executor import ExecutorProcessingService 7 | from typing_extensions import override 8 | 9 | from .....fixtures import CallableMock 10 | from ..processing_service_test import ProcessingServiceTest 11 | 12 | 13 | class TestExecutorProcessingService(ProcessingServiceTest): 14 | # ================================= 15 | # Test Cases for creation 16 | # ================================= 17 | 18 | @pytest.mark.parametrize( 19 | ["executor"], 20 | [ 21 | (ThreadPoolExecutor(),), 22 | (ProcessPoolExecutor(),), 23 | ], 24 | ) 25 | def test_creation_with_valid_input(self, executor: Executor) -> None: 26 | # Arrange/Act 27 | processing_service = ExecutorProcessingService(executor=executor) 28 | 29 | # Assert 30 | assert processing_service is not None 31 | assert isinstance(processing_service, ExecutorProcessingService) 32 | 33 | # ================================= 34 | 35 | @pytest.mark.parametrize( 36 | ["executor", "exception"], 37 | [ 38 | (None, PyventusException), 39 | (True, PyventusException), 40 | (object(), PyventusException), 41 | (ThreadPoolExecutor, PyventusException), 42 | ], 43 | ) 44 | def test_creation_with_invalid_input(self, executor: Any, exception: type[Exception]) -> None: 45 | # Arrange/Act/Assert 46 | with pytest.raises(exception): 47 | ExecutorProcessingService(executor=executor) 48 | 49 | # ================================= 50 | # Test Cases for shutdown 51 | # ================================= 52 | 53 | def test_shutdown(self) -> None: 54 | # Arrange 55 | executor = ThreadPoolExecutor() 56 | processing_service = ExecutorProcessingService(executor=executor) 57 | 58 | # Act 59 | processing_service.shutdown() 60 | 61 | # Assert 62 | assert executor._shutdown is True 63 | 64 | # ================================= 65 | # Test Cases for shutdown by context 66 | # ================================= 67 | 68 | def test_shutdown_by_context(self) -> None: 69 | # Arrange 70 | executor = ThreadPoolExecutor() 71 | 72 | # Act 73 | with ExecutorProcessingService(executor=executor): 74 | pass 75 | 76 | # Assert 77 | assert executor._shutdown is True 78 | 79 | # ================================= 80 | # Test Cases for submission 81 | # ================================= 82 | 83 | @override 84 | def handle_submission_in_sync_context( 85 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 86 | ) -> None: 87 | with ExecutorProcessingService(executor=ThreadPoolExecutor()) as processing_service: 88 | processing_service.submit(callback, *args, **kwargs) 89 | 90 | # ================================= 91 | 92 | @override 93 | async def handle_submission_in_async_context( 94 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 95 | ) -> None: 96 | with ExecutorProcessingService(executor=ThreadPoolExecutor()) as processing_service: 97 | processing_service.submit(callback, *args, **kwargs) 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | -------------------------------------------------------------------------------- /docs/learn/upgrade_guide.md: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 | 35 | # Upgrade Guide 36 | 37 | Welcome to the Upgrade Guide for Pyventus `v0.7`! In this section, you will learn how to properly upgrade your application from Pyventus `v0.6` to `v0.7`, as well as address any potential issues related to breaking changes, ensuring a smooth transition to the latest version. 38 | 39 | ## Upgrading to the Latest Version 40 | 41 | Pyventus is published as a Python package and can be easily upgraded with `pip`. To get started, open up a terminal and upgrade Pyventus with the following command: 42 | 43 |
44 | ```console 45 | pip install -U pyventus 46 | ``` 47 |
48 | 49 | ## Reviewing Key Breaking Changes 50 | 51 | Please review the following breaking changes and apply the necessary actions to effectively update your application. You can mark each item as complete to track your progress. 52 | 53 | - [ ] All previous event-driven features must now be imported from the new inner package `pyventus.events` instead of directly from `pyventus` or its submodules. 54 | - [ ] The inheritance structure of the `EventEmitter` has been replaced with composition using the `ProcessingService`. Custom event emitters must now be implemented through the `ProcessingService` interface and composed with the `EventEmitter` class. 55 | - [ ] The `EventLinker` has experienced some method renames and return type modifications to align with the new redesigned codebase: 56 | - [ ] `remove_event_handler()` → `remove_subscriber()`. 57 | - [ ] `get_event_handlers()` → `get_subscribers()`: Now returns a `set` instead of a `list`. 58 | - [ ] `get_events_by_event_handler()` → `get_events_from_subscribers()`: Now returns a `set` instead of a `list` and supports retrieving events for multiple subscribers. 59 | - [ ] `get_event_handlers_by_events()` → `get_subscribers_from_events()`: Now returns a `set` instead of a `list` and includes a new flag `pop_onetime_subscribers`. 60 | - [ ] `unsubscribe()` → `remove()`: Now removes one event from a subscriber at a time. 61 | - [ ] Parameters named `event_handler` have been renamed to `subscriber` in all methods. 62 | - [ ] `get_events()`: Now returns a `set` instead of a `list` with non-duplicates. 63 | - [ ] The `RQEventEmitter` has been renamed to `RedisEventEmitter`. 64 | - [ ] The `CeleryEventEmitter.Queue` has been removed, and the `CeleryEventEmitter` now requires a `Celery` instance. Security aspects have been delegated to the `Celery` app. 65 | - [ ] Dependency injection for the `FastAPIEventEmitter` through FastAPI's `Depends()` function has been simplified; use `Depends(FastAPIEventEmitter())` for all scenarios. 66 | - [ ] The `ExecutorEventEmitter` can no longer be used as a context manager; for this purpose, use the new `ExecutorEventEmitterCtx`. 67 | 68 | ## Questions and Issues 69 | 70 | If you have any questions or run into issues during the upgrade process, please feel free to open a new issue or start a discussion. Before doing so, it is recommended to check for existing inquiries to avoid duplicates. 71 | 72 |
73 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/fastapi/test_fastapi_processing_service.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from fastapi import BackgroundTasks, Depends, FastAPI 5 | from fastapi.testclient import TestClient 6 | from pyventus import PyventusException 7 | from pyventus.core.processing.fastapi import FastAPIProcessingService 8 | from starlette.status import HTTP_200_OK 9 | from typing_extensions import override 10 | 11 | from .....fixtures import CallableMock 12 | from ..processing_service_test import ProcessingServiceTest 13 | 14 | 15 | class TestFastAPIProcessingService(ProcessingServiceTest): 16 | # ================================= 17 | # Test Cases for creation 18 | # ================================= 19 | 20 | def test_creation_with_valid_input(self) -> None: 21 | # Arrange/Act 22 | processing_service = FastAPIProcessingService(background_tasks=BackgroundTasks()) 23 | 24 | # Assert 25 | assert processing_service is not None 26 | assert isinstance(processing_service, FastAPIProcessingService) 27 | 28 | # ================================= 29 | 30 | def test_creation_with_fastapi_depends(self) -> None: 31 | # Arrange 32 | client = TestClient(FastAPI()) 33 | 34 | # Act/Assert 35 | @client.app.get("/") # type: ignore[attr-defined, misc] 36 | def api( 37 | processing_service: FastAPIProcessingService = Depends(FastAPIProcessingService), # noqa: B008 38 | ) -> None: 39 | assert processing_service is not None 40 | assert isinstance(processing_service, FastAPIProcessingService) 41 | 42 | res = client.get("/") 43 | assert res.status_code == HTTP_200_OK 44 | 45 | # ================================= 46 | 47 | @pytest.mark.parametrize( 48 | ["background_tasks", "exception"], 49 | [ 50 | (None, PyventusException), 51 | (True, PyventusException), 52 | (object(), PyventusException), 53 | (BackgroundTasks, PyventusException), 54 | ], 55 | ) 56 | def test_creation_with_invalid_input(self, background_tasks: Any, exception: type[Exception]) -> None: 57 | # Arrange/Act/Assert 58 | with pytest.raises(exception): 59 | FastAPIProcessingService(background_tasks=background_tasks) 60 | 61 | # ================================= 62 | # Test Cases for submission 63 | # ================================= 64 | 65 | @override 66 | def handle_submission_in_sync_context( 67 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 68 | ) -> None: 69 | # Arrange 70 | client = TestClient(FastAPI()) 71 | 72 | @client.app.get("/") # type: ignore[attr-defined, misc] 73 | def api(background_tasks: BackgroundTasks) -> None: 74 | processing_service = FastAPIProcessingService(background_tasks=background_tasks) 75 | processing_service.submit(callback, *args, **kwargs) 76 | 77 | # Act 78 | res = client.get("/") 79 | 80 | # Assert 81 | assert res.status_code == HTTP_200_OK 82 | 83 | # ================================= 84 | 85 | @override 86 | async def handle_submission_in_async_context( 87 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 88 | ) -> None: 89 | # Arrange 90 | client = TestClient(FastAPI()) 91 | 92 | @client.app.get("/") # type: ignore[attr-defined, misc] 93 | async def api(background_tasks: BackgroundTasks) -> None: 94 | processing_service = FastAPIProcessingService(background_tasks=background_tasks) 95 | processing_service.submit(callback, *args, **kwargs) 96 | 97 | # Act 98 | res = client.get("/") 99 | 100 | # Assert 101 | assert res.status_code == HTTP_200_OK 102 | -------------------------------------------------------------------------------- /tests/pyventus/core/subscriptions/test_subscription.py: -------------------------------------------------------------------------------- 1 | from pickle import dumps, loads 2 | from typing import Any 3 | 4 | import pytest 5 | from pyventus import PyventusException 6 | from pyventus.core.subscriptions.subscription import Subscription 7 | 8 | from ....fixtures import CallableMock 9 | from ....utils import get_private_attr 10 | 11 | 12 | class TestSubscription: 13 | # ================================= 14 | # Test Cases for creation 15 | # ================================= 16 | 17 | def test_creation_with_valid_input(self) -> None: 18 | # Arrange/Act 19 | subscription = Subscription(teardown_callback=lambda s: True) 20 | 21 | # Assert 22 | assert subscription is not None 23 | assert isinstance(subscription, Subscription) 24 | assert subscription.timestamp is get_private_attr(subscription, "__timestamp") 25 | 26 | # ================================= 27 | 28 | @pytest.mark.parametrize( 29 | ["teardown_callback", "exception"], 30 | [ 31 | (None, PyventusException), 32 | (True, PyventusException), 33 | (object, PyventusException), 34 | (object(), PyventusException), 35 | ], 36 | ) 37 | def test_creation_with_invalid_input(self, teardown_callback: Any, exception: type[Exception]) -> None: 38 | # Arrange/Act/Assert 39 | with pytest.raises(exception): 40 | Subscription(teardown_callback=teardown_callback) 41 | 42 | # ================================= 43 | # Test Cases for unsubscribe 44 | # ================================= 45 | 46 | def test_unsubscribe_execution(self) -> None: 47 | # Arrange 48 | teardown_callback = CallableMock.Sync(return_value=True) 49 | subscription = Subscription(teardown_callback=teardown_callback) 50 | 51 | # Act 52 | subscription.unsubscribe() 53 | 54 | # Assert 55 | assert teardown_callback.call_count == 1 56 | assert teardown_callback.last_args == (subscription,) 57 | 58 | # ================================= 59 | 60 | def test_unsubscribe_with_exceptions(self) -> None: 61 | # Arrange 62 | teardown_callback = CallableMock.Sync(return_value=False, raise_exception=ValueError()) 63 | subscription = Subscription(teardown_callback=teardown_callback) 64 | 65 | # Act 66 | with pytest.raises(ValueError): 67 | subscription.unsubscribe() 68 | 69 | # Assert 70 | assert teardown_callback.call_count == 1 71 | assert teardown_callback.last_args == (subscription,) 72 | 73 | # ================================= 74 | 75 | @pytest.mark.parametrize( 76 | ["expected"], 77 | [(True,), (False,)], 78 | ) 79 | def test_unsubscribe_return_value(self, expected: bool) -> None: 80 | # Arrange 81 | teardown_callback = CallableMock.Sync(return_value=expected) 82 | subscription = Subscription(teardown_callback=teardown_callback) 83 | 84 | # Act 85 | return_value = subscription.unsubscribe() 86 | 87 | # Assert 88 | assert teardown_callback.call_count == 1 89 | assert teardown_callback.last_args == (subscription,) 90 | assert return_value is expected 91 | 92 | # ================================= 93 | # Test Cases for Serialization/Deserialization 94 | # ================================= 95 | 96 | def test_pickle_serialization_and_deserialization(self) -> None: 97 | # Arrange 98 | teardown_callback = CallableMock.Sync(return_value=False) 99 | subscription = Subscription(teardown_callback=teardown_callback) 100 | 101 | # Act 102 | data = dumps(subscription) 103 | restored = loads(data) 104 | 105 | # Assert 106 | for attr in Subscription.__slots__: 107 | attr_name: str = f"_{type(restored).__name__}{attr}" if attr.startswith("__") else attr 108 | assert hasattr(restored, attr_name) 109 | -------------------------------------------------------------------------------- /src/pyventus/core/subscriptions/subscription.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from datetime import datetime 3 | from typing import Any 4 | 5 | from typing_extensions import Self, override 6 | 7 | from ..utils import attributes_repr, get_callable_name, validate_callable 8 | from .unsubscribable import Unsubscribable 9 | 10 | 11 | class Subscription(Unsubscribable): 12 | """ 13 | A base class that represents a subscription to a source. 14 | 15 | **Notes:** 16 | 17 | - This class encapsulates the subscription lifecycle and provides a 18 | mechanism for releasing or cleaning up any associated resources. 19 | """ 20 | 21 | # Attributes for the Subscription 22 | __slots__ = ("__timestamp", "__teardown_callback") 23 | 24 | def __init__(self, teardown_callback: Callable[[Self], bool]) -> None: 25 | """ 26 | Initialize an instance of `Subscription`. 27 | 28 | :param teardown_callback: A callback function invoked during the 29 | unsubscription process to perform cleanup or teardown operations 30 | associated with the subscription. It should return `True` if the 31 | cleanup was successful, or `False` if the teardown has already been 32 | executed and the subscription is no longer active. 33 | """ 34 | # Validate the teardown callback 35 | validate_callable(teardown_callback) 36 | 37 | # Initialize attributes 38 | self.__timestamp: datetime = datetime.now() 39 | self.__teardown_callback: Callable[[Self], bool] = teardown_callback 40 | 41 | def __repr__(self) -> str: 42 | """ 43 | Retrieve a string representation of the instance. 44 | 45 | :return: A string representation of the instance. 46 | """ 47 | return attributes_repr( 48 | timestamp=self.__timestamp.strftime("%Y-%m-%d %I:%M:%S %p"), 49 | teardown_callback=get_callable_name(self.__teardown_callback), 50 | ) 51 | 52 | @property 53 | def timestamp(self) -> datetime: 54 | """ 55 | Retrieve the timestamp when the subscription was created. 56 | 57 | :return: The timestamp when the subscription was created. 58 | """ 59 | return self.__timestamp 60 | 61 | @override 62 | def unsubscribe(self: Self) -> bool: 63 | return self.__teardown_callback(self) 64 | 65 | def __getstate__(self) -> dict[str, Any]: 66 | """ 67 | Prepare the object state for serialization. 68 | 69 | This method is called when the object is pickled. It returns a dictionary 70 | containing the attributes that should be serialized. Only the attributes 71 | that are necessary for reconstructing the object in another process or 72 | context are included to improve efficiency and avoid issues with 73 | contextually irrelevant attributes. 74 | 75 | :return: A dictionary containing the serialized state of the object. 76 | """ 77 | # Include only the attributes that are necessary for serialization 78 | # Attributes like __teardown_callback are not included as they are 79 | # context-specific and do not make sense in another scope/process. 80 | return {"__timestamp": self.__timestamp} 81 | 82 | def __setstate__(self, state: dict[str, Any]) -> None: 83 | """ 84 | Restore the object from the serialized state. 85 | 86 | This method is called when the object is unpickled. It takes a dictionary 87 | containing the serialized state and restores the object's attributes. 88 | Additionally, it sets default values for attributes that were not serialized, 89 | ensuring the object remains in a valid state after deserialization. 90 | 91 | :param state: A dictionary containing the serialized state of the object. 92 | :return: None 93 | """ 94 | # Restore the attributes from the serialized state 95 | self.__timestamp = state["__timestamp"] 96 | 97 | # Set default values for attributes that were not serialized 98 | self.__teardown_callback = lambda sub: False 99 | -------------------------------------------------------------------------------- /docs/learn/events/emitters/asyncio.md: -------------------------------------------------------------------------------- 1 | # AsyncIO Event Emitter 2 | 3 |

4 |   In Pyventus, you can easily integrate Event Emitters with the AsyncIO framework through the AsyncIO Processing Service. Simply create an instance of the AsyncIO Processing Service and pass it as the event processor when setting up the Event Emitter, or you can use the factory method called AsyncIO Event Emitter to handle the setup in a single step. 5 |

6 | 7 | === ":material-console: Manual Configuration" 8 | 9 | ```Python linenums="1" hl_lines="1 2 4" 10 | from pyventus.events import EventEmitter 11 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 12 | 13 | event_emitter = EventEmitter(event_processor=AsyncIOProcessingService()) 14 | event_emitter.emit("MyEvent") 15 | ``` 16 | 17 | === ":material-factory: Factory Method" 18 | 19 | ```Python linenums="1" hl_lines="1 4" 20 | from pyventus.events import AsyncIOEventEmitter, EventEmitter 21 | 22 | 23 | event_emitter: EventEmitter = AsyncIOEventEmitter() 24 | event_emitter.emit("MyEvent") 25 | ``` 26 | 27 |

28 |   By utilizing the AsyncIO Processing Service, the execution of each event emission will be handled by the AsyncIO event loop. 29 |

30 | 31 | ## AsyncIO Behavior 32 | 33 |

34 |   It is important to note that the AsyncIO Processing Service handles the execution of each event emission differently depending on whether an AsyncIO loop is already running (async context) or not (sync context). If there isn’t an active loop, it uses the `asyncio.run()` method to execute the event emission, creating a new loop, waiting for the event emission to finish, and finally closing it. If a loop is already running, the service simply schedules the event emission as a background task using the `asyncio.create_task()`. 35 |

36 | 37 | !!! tip "Event Emission is Silently Discarded" 38 | 39 |

40 |   When working with async contexts, it is important to properly handle the underlying AsyncIO loop, as the AsyncIO Processing Service simply schedules tasks to it. If the AsyncIO loop closes before all submitted callbacks are complete, they will be discarded. 41 |

42 | 43 | ## Practical Example 44 | 45 | === "`Sync` contexts" 46 | 47 | ```Python linenums="1" hl_lines="3 12 18 19" 48 | import asyncio 49 | 50 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 51 | from pyventus.events import EventEmitter, EventLinker 52 | 53 | 54 | async def handle_event_with_delay(): 55 | await asyncio.sleep(1.5) 56 | print("Done!") 57 | 58 | 59 | def main(): 60 | print("Starting...") 61 | 62 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 63 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 64 | 65 | event_processor = AsyncIOProcessingService() 66 | event_emitter = EventEmitter(event_processor=event_processor) 67 | 68 | event_emitter.emit("MyEvent") 69 | event_emitter.emit("MyEvent") 70 | 71 | print("Closing...") 72 | 73 | 74 | 75 | main() 76 | ``` 77 | 78 | === "`Async` contexts" 79 | 80 | ```Python linenums="1" hl_lines="3 12 18 19 25" 81 | import asyncio 82 | 83 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 84 | from pyventus.events import EventEmitter, EventLinker 85 | 86 | 87 | async def handle_event_with_delay(): 88 | await asyncio.sleep(1.5) 89 | print("Done!") 90 | 91 | 92 | async def main(): 93 | print("Starting...") 94 | 95 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 96 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 97 | 98 | event_processor = AsyncIOProcessingService() 99 | event_emitter = EventEmitter(event_processor=event_processor) 100 | 101 | event_emitter.emit("MyEvent") 102 | event_emitter.emit("MyEvent") 103 | 104 | print("Closing...") 105 | await event_processor.wait_for_tasks() 106 | 107 | 108 | asyncio.run(main()) 109 | ``` 110 | -------------------------------------------------------------------------------- /src/pyventus/core/loggers/logger.py: -------------------------------------------------------------------------------- 1 | from ..utils import attributes_repr, formatted_repr, summarized_repr 2 | from .stdout_logger import ExcInfoType, StdOutLogger 3 | 4 | 5 | class Logger: 6 | """A custom logger class that wraps the `StdOutLogger` and provides additional functionality.""" 7 | 8 | # Attributes for the Logger 9 | __slots__ = ("__source", "__debug") 10 | 11 | def __init__(self, source: str | type | object | None = None, debug: bool = False): 12 | """ 13 | Initialize an instance of `Logger`. 14 | 15 | :param source: The source of the log message, which can be a string, type, or object. Defaults to None. 16 | :param debug: A flag indicating whether debug mode is enabled. 17 | """ 18 | # Variable to hold the name of the source. 19 | source_name: str 20 | 21 | # Determine the name of the source based on its type. 22 | if source is None: 23 | source_name = summarized_repr(self) 24 | elif isinstance(source, str): 25 | source_name = source 26 | elif isinstance(source, type): 27 | source_name = f"{source.__name__}(ClassReference)" 28 | else: 29 | source_name = summarized_repr(source) 30 | 31 | # Store the determined source name and debug flag. 32 | self.__source: str = source_name 33 | self.__debug = debug 34 | 35 | def __repr__(self) -> str: 36 | """ 37 | Retrieve a string representation of the instance. 38 | 39 | :return: A string representation of the instance. 40 | """ 41 | return formatted_repr( 42 | instance=self, 43 | info=attributes_repr( 44 | source=self.__source, 45 | debug=self.__debug, 46 | ), 47 | ) 48 | 49 | @property 50 | def debug_enabled(self) -> bool: 51 | """ 52 | Return a boolean value indicating whether debug mode is enabled. 53 | 54 | :return: `True` if debug mode is enabled, `False` otherwise. 55 | """ 56 | return self.__debug 57 | 58 | def critical(self, msg: str, action: str | None = None, exc_info: ExcInfoType = None) -> None: 59 | """ 60 | Log a CRITICAL level message. 61 | 62 | :param msg: The message to be logged. 63 | :param action: The action or method associated with the log. Defaults to None. 64 | :param exc_info: Exception information to be logged. Defaults to None. 65 | :return: None. 66 | """ 67 | StdOutLogger.critical(msg=msg, source=self.__source, action=action, exc_info=exc_info) 68 | 69 | def error(self, msg: str, action: str | None = None, exc_info: ExcInfoType = None) -> None: 70 | """ 71 | Log an ERROR level message. 72 | 73 | :param msg: The message to be logged. 74 | :param action: The action or method associated with the log. Defaults to None. 75 | :param exc_info: Exception information to be logged. Defaults to None. 76 | :return: None. 77 | """ 78 | StdOutLogger.error(msg=msg, source=self.__source, action=action, exc_info=exc_info) 79 | 80 | def warning(self, msg: str, action: str | None = None) -> None: 81 | """ 82 | Log a WARNING level message. 83 | 84 | :param msg: The message to be logged. 85 | :param action: The action or method associated with the log. Defaults to None. 86 | :return: None. 87 | """ 88 | StdOutLogger.warning(msg=msg, source=self.__source, action=action) 89 | 90 | def info(self, msg: str, action: str | None = None) -> None: 91 | """ 92 | Log an INFO level message. 93 | 94 | :param msg: The message to be logged. 95 | :param action: The action or method associated with the log. Defaults to None. 96 | :return: None. 97 | """ 98 | StdOutLogger.info(msg=msg, source=self.__source, action=action) 99 | 100 | def debug(self, msg: str, action: str | None = None) -> None: 101 | """ 102 | Log a DEBUG level message. 103 | 104 | :param msg: The message to be logged. 105 | :param action: The action or method associated with the log. Defaults to None. 106 | :return: None. 107 | """ 108 | StdOutLogger.debug(msg=msg, source=self.__source, action=action) 109 | -------------------------------------------------------------------------------- /docs/learn/events/emitters/fastapi.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # FastAPI Event Emitter 11 | 12 |

13 |   In Pyventus, you can easily integrate Event Emitters with the FastAPI framework through the FastAPI Processing Service. Simply create an instance of the FastAPI Processing Service and pass it as the event processor when setting up the Event Emitter, or you can use the factory method called FastAPI Event Emitter to handle the setup in a single step. 14 |

15 | 16 | === ":material-console: Manual Configuration" 17 | 18 | ```Python linenums="1" hl_lines="1-3 5 9 10" 19 | from fastapi import BackgroundTasks, FastAPI 20 | from pyventus.core.processing.fastapi import FastAPIProcessingService 21 | from pyventus.events import EventEmitter 22 | 23 | app = FastAPI() 24 | 25 | 26 | @app.get("/") 27 | def read_root(background_tasks: BackgroundTasks): 28 | event_emitter = EventEmitter(event_processor=FastAPIProcessingService(background_tasks)) 29 | event_emitter.emit("MyEvent") 30 | return {"Event": "Emitted"} 31 | ``` 32 | 33 | === ":material-factory: Factory Method" 34 | 35 | ```Python linenums="1" hl_lines="1-2 4 8" 36 | from fastapi import Depends, FastAPI 37 | from pyventus.events import EventEmitter, FastAPIEventEmitter 38 | 39 | app = FastAPI() 40 | 41 | 42 | @app.get("/") 43 | def read_root(event_emitter: EventEmitter = Depends(FastAPIEventEmitter())): 44 | event_emitter.emit("MyEvent") 45 | return {"Event": "Emitted"} 46 | ``` 47 | 48 |

49 |   By utilizing the FastAPI Processing Service, the execution of each event emission will be handled by the FastAPI's Background Tasks system. 50 |

51 | 52 | ## Practical Example 53 | 54 |

55 | To start using the Event Emitter with FastAPI, follow these steps: 56 |

57 | 58 | 1.

Install Dependencies: 59 | Before proceeding, ensure that you have installed the optional [FastAPI dependency](../../../getting-started.md/#optional-dependencies). 60 |

61 | 62 | 2.

Subscribe and Emit: 63 | Once you have everything installed and configured, using Pyventus with FastAPI is straightforward. Simply define your subscribers and events, instantiate an Event Emitter configured for FastAPI, and emit your events. 64 |

65 | 66 | ```Python title="main.py" linenums="1" hl_lines="3-4 7-8 14 20 22" 67 | import time 68 | 69 | from fastapi import Depends, FastAPI 70 | from pyventus.events import EventEmitter, EventLinker, FastAPIEventEmitter 71 | 72 | 73 | @EventLinker.on("SendEmail") 74 | def handle_email_notification(email: str) -> None: 75 | print(f"Sending email to: {email}") 76 | time.sleep(2.5) # Simulate sending delay. 77 | print("Email sent successfully!") 78 | 79 | 80 | app = FastAPI() 81 | 82 | 83 | @app.get("/email") 84 | def send_email( 85 | email_to: str, 86 | event_emitter: EventEmitter = Depends(FastAPIEventEmitter()), 87 | ) -> None: 88 | event_emitter.emit("SendEmail", email_to) 89 | return {"message": "Email sent!"} 90 | ``` 91 | 92 | To start the FastAPI server, run the following command: 93 | 94 |
95 | ```console 96 | fastapi dev main.py 97 | ``` 98 |
99 | 100 | Open your browser and navigate to http://127.0.0.1:8000/email?email_to=email@email.com. You should see the following JSON response: 101 | 102 | ```JSON 103 | { "message": "Email sent!" } 104 | ``` 105 | 106 | Additionally, you will be able to view the output of the functions in the console logs. 107 | 108 | ```console 109 | INFO Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 110 | INFO Started reloader process [12604] using WatchFiles 111 | INFO Started server process [7008] 112 | INFO Waiting for application startup. 113 | INFO Application startup complete. 114 | INFO 127.0.0.1:49763 - "GET /email?email_to=email@email.com HTTP/1.1" 200 115 | Sending email to: email@email.com 116 | Email sent successfully! 117 | ``` 118 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/asyncio/asyncio_processing_service.py: -------------------------------------------------------------------------------- 1 | from asyncio import Task, create_task, gather, get_running_loop, run 2 | from typing import Any 3 | 4 | from typing_extensions import override 5 | 6 | from ...utils import attributes_repr, formatted_repr, is_callable_async 7 | from ..processing_service import ProcessingService, ProcessingServiceCallbackType 8 | 9 | 10 | class AsyncIOProcessingService(ProcessingService): 11 | """ 12 | A processing service that utilizes the `AsyncIO` framework to handle the execution of calls. 13 | 14 | **Notes:** 15 | 16 | - When the provided callback is a synchronous call, it will be executed in a blocking manner, regardless 17 | of whether an event loop is active. However, if the synchronous callback involves I/O or non-CPU-bound 18 | operations, it can be offloaded to a thread pool using `asyncio.to_thread()` from the `AsyncIO` framework. 19 | 20 | - When the provided callback is an asynchronous call and is submitted in a context where an event loop is 21 | already running, the callback is scheduled and processed on that existing loop. If the event loop exits 22 | before all calls are completed, any remaining scheduled calls will be canceled. 23 | 24 | - When the provided callback is an asynchronous call and is submitted in a context where no event loop is 25 | active, a new event loop is started and subsequently closed by the `asyncio.run()` method. Within this 26 | loop, the callback is executed, and the loop waits for all scheduled tasks to finish before closing. 27 | """ 28 | 29 | @staticmethod 30 | def is_loop_running() -> bool: 31 | """ 32 | Determine whether there is currently an active `AsyncIO` event loop. 33 | 34 | :return: `True` if an event loop is running; `False` otherwise. 35 | """ 36 | try: 37 | get_running_loop() 38 | return True 39 | except RuntimeError: 40 | return False 41 | 42 | # Attributes for the AsyncIOProcessingService 43 | __slots__ = ("__background_tasks",) 44 | 45 | def __init__(self) -> None: 46 | """ 47 | Initialize an instance of `AsyncIOProcessingService`. 48 | 49 | :return: None. 50 | """ 51 | # Initialize the set of background tasks 52 | self.__background_tasks: set[Task[Any]] = set() 53 | 54 | def __repr__(self) -> str: 55 | """ 56 | Retrieve a string representation of the instance. 57 | 58 | :return: A string representation of the instance. 59 | """ 60 | return formatted_repr( 61 | instance=self, 62 | info=attributes_repr( 63 | background_tasks=self.__background_tasks, 64 | ), 65 | ) 66 | 67 | @override 68 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 69 | # Check if the callback is asynchronous and execute accordingly. 70 | if is_callable_async(callback): 71 | # Check if there is an active event loop. 72 | loop_running: bool = AsyncIOProcessingService.is_loop_running() 73 | 74 | if loop_running: 75 | # Schedule the callback in the running loop as a background task. 76 | task: Task[Any] = create_task(callback(*args, **kwargs)) 77 | 78 | # Add a callback to remove the Task from the set of background tasks upon completion. 79 | task.add_done_callback(self.__background_tasks.discard) 80 | 81 | # Add the Task to the set of background tasks. 82 | self.__background_tasks.add(task) 83 | else: 84 | # Execute the callback in a blocking manner if no event loop is active. 85 | run(callback(*args, **kwargs)) 86 | else: 87 | # Execute the callback directly if it is not an asynchronous call. 88 | callback(*args, **kwargs) 89 | 90 | async def wait_for_tasks(self) -> None: 91 | """ 92 | Wait for all background tasks associated with the current service to complete. 93 | 94 | This method ensures that any ongoing tasks are finished before proceeding. 95 | 96 | :return: None. 97 | """ 98 | # Retrieve the current set of background tasks and clear the registry. 99 | tasks: set[Task[Any]] = self.__background_tasks.copy() 100 | self.__background_tasks.clear() 101 | 102 | # Await the completion of all background tasks. 103 | await gather(*tasks) 104 | -------------------------------------------------------------------------------- /docs/learn/events/emitters/executor.md: -------------------------------------------------------------------------------- 1 | # Executor Event Emitter 2 | 3 |

4 |   In Pyventus, you can easily integrate Event Emitters with threads or processes through the Executor Processing Service. Simply create an instance of the Executor Processing Service and pass it as the event processor when setting up the Event Emitter, or you can use the factory method called Executor Event Emitter to handle the setup in a single step. 5 |

6 | 7 | === ":material-console: Manual Configuration" 8 | 9 | ```Python linenums="1" hl_lines="1 3 8 10" 10 | from concurrent.futures import ThreadPoolExecutor 11 | 12 | from pyventus.core.processing.executor import ExecutorProcessingService 13 | from pyventus.events import EventEmitter 14 | 15 | 16 | if __name__ == "__main__": 17 | executor = ThreadPoolExecutor() 18 | 19 | event_emitter = EventEmitter(event_processor=ExecutorProcessingService(executor)) 20 | event_emitter.emit("MyEvent") 21 | 22 | executor.shutdown() 23 | ``` 24 | 25 | === ":material-factory: Factory Method" 26 | 27 | ```Python linenums="1" hl_lines="1 3 8 10" 28 | from concurrent.futures import ThreadPoolExecutor 29 | 30 | from pyventus.events import ExecutorEventEmitter 31 | 32 | 33 | 34 | if __name__ == "__main__": 35 | executor = ThreadPoolExecutor() 36 | 37 | event_emitter = ExecutorEventEmitter(executor) 38 | event_emitter.emit("MyEvent") 39 | 40 | executor.shutdown() 41 | ``` 42 | 43 | === ":material-code-block-tags: Context Manager" 44 | 45 | ```Python linenums="1" hl_lines="1 3 7" 46 | from concurrent.futures import ThreadPoolExecutor 47 | 48 | from pyventus.events import ExecutorEventEmitterCtx 49 | 50 | 51 | if __name__ == "__main__": 52 | with ExecutorEventEmitterCtx(executor=ThreadPoolExecutor()) as event_emitter: 53 | event_emitter.emit("MyEvent") 54 | ``` 55 | 56 |

57 |   By utilizing the Executor Processing Service, the execution of each event emission will be handled by the given Thread/Process executor. 58 |

59 | 60 | ## Executor Management 61 | 62 |

63 |   It is important to properly manage the underlying Executor when using the Executor Processing Service. Once you've finished emitting events, call the `shutdown()` method to signal the executor to free any resources associated with pending futures, or use the `with` statement, which will automatically shut down the Executor. 64 |

65 | 66 | ## Practical Example 67 | 68 | === "Using `ThreadPoolExecutor`" 69 | 70 | ```Python linenums="1" hl_lines="2 4 18-21" 71 | import time 72 | from concurrent.futures import ThreadPoolExecutor 73 | 74 | from pyventus.events import EventLinker, ExecutorEventEmitter 75 | 76 | 77 | def handle_event_with_delay(): 78 | time.sleep(1.5) 79 | print("Done!") 80 | 81 | 82 | if __name__ == "__main__": 83 | print("Starting...") 84 | 85 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 86 | EventLinker.subscribe("MyEvent", event_callback=handle_event_with_delay) 87 | 88 | with ThreadPoolExecutor() as executor: 89 | event_emitter = ExecutorEventEmitter(executor) 90 | event_emitter.emit("MyEvent") 91 | event_emitter.emit("MyEvent") 92 | 93 | print("Closing...") 94 | ``` 95 | 96 | === "Using `ProcessPoolExecutor`" 97 | 98 | ```Python linenums="1" hl_lines="1 3 19-22" 99 | from concurrent.futures import ProcessPoolExecutor 100 | 101 | from pyventus.events import EventLinker, ExecutorEventEmitter 102 | 103 | def fibonacci(n): 104 | if n <= 1: 105 | return n 106 | return fibonacci(n - 1) + fibonacci(n - 2) 107 | 108 | if __name__ == "__main__": 109 | print("Starting...") 110 | 111 | EventLinker.subscribe( 112 | "Fibonacci", 113 | event_callback=fibonacci, 114 | success_callback=print, 115 | ) 116 | 117 | with ProcessPoolExecutor() as executor: 118 | event_emitter = ExecutorEventEmitter(executor) 119 | event_emitter.emit("Fibonacci", 35) 120 | event_emitter.emit("Fibonacci", 35) 121 | 122 | print("Closing...") 123 | ``` 124 | 125 | !!! warning "Picklable Objects Required for `ProcessPoolExecutor`" 126 | 127 |

128 |   When working with the `ProcessPoolExecutor`, it is essential to ensure that all objects involved in the event emission are picklable. 129 |

130 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | 14 | 15 |

16 |   Welcome to the Getting Started section! In this guide, you will learn how to install Pyventus, as well as enable any of its optional dependencies. For more detailed information on how to use this library, you can refer to the [API](api/index.md) and [Learn](learn/index.md) sections. 17 |

18 | 19 | ## Installation 20 | 21 |

22 |   Pyventus is published as a Python package and can be installed using pip, ideally in a virtual environment for proper dependency isolation. To get started, open up a terminal and install Pyventus with the following command: 23 |

24 | 25 |
26 | ```console 27 | pip install pyventus 28 | ``` 29 |
30 | 31 |

32 |   By default, Pyventus relies on the Python standard library and requires Python 3.10 or higher with no additional dependencies aside from typing-extensions, which is primarily used to support advanced typing features in older versions of Python. 33 |

34 | 35 | ## Optional Dependencies 36 | 37 |

38 |   While Pyventus primarily relies on the Python standard library, it also supports optional dependencies to access additional features, such as different processing services[^1]. Below is a list of supported integrations: 39 |

40 | 41 | ### Supported Library Integrations 42 | 43 | -

Celery ─ 44 | Pyventus integrates with Celery through the `CeleryProcessingService`, which is a concrete implementation of the `ProcessingService` interface that leverages the Celery framework to handle the execution of calls. To install Pyventus with Celery support, use the following command: 45 |

46 | 47 |
48 | ```console 49 | pip install pyventus[celery] (1) 50 | ``` 51 |
52 | 53 | 1.

Optional Package Dependencies

54 |

55 |   This package also includes optional dependencies. For more information, please visit the Celery documentation. 56 |

57 | 58 | *** 59 | 60 | -

Redis Queue (RQ) ─ 61 | Pyventus integrates with Redis Queue through the `RedisProcessingService`, which is a concrete implementation of the `ProcessingService` interface that leverages the Redis Queue framework to handle the execution of calls. To install Pyventus with Redis Queue support, use the following command: 62 |

63 | 64 |
65 | ```console 66 | pip install pyventus[rq] 67 | ``` 68 |
69 | 70 | ### Supported Framework Integrations 71 | 72 | -

FastAPI ─ 73 | Pyventus integrates with FastAPI through the `FastAPIProcessingService`, which is a concrete implementation of the `ProcessingService` interface that utilizes the FastAPI's `BackgroundTasks` to handle the execution of calls. To install Pyventus with FastAPI integration, use the following command: 74 |

75 | 76 |
77 | ```console 78 | pip install pyventus[fastapi] (1) 79 | ``` 80 |
81 | 82 | 1.

Optional Package Dependencies

83 |

84 |   This package also includes optional dependencies. For more information, please visit the FastAPI documentation. 85 |

86 | 87 | --- 88 | 89 | You can install all of these integrations simultaneously using: 90 | 91 |
92 | ```console 93 | pip install pyventus[all] 94 | ``` 95 |
96 | 97 | [^1]: These processing services expand the capabilities of Pyventus by providing different strategies for processing calls. For instance, the `EventEmitter` class leverages these services to decouple the processing of each event emission from the underlying implementation, resulting in a more flexible and efficient execution mechanism that enhances the responsiveness and scalability of event handling. 98 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/executor/executor_processing_service.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from concurrent.futures import Executor 3 | from types import TracebackType 4 | from typing import Any 5 | 6 | from typing_extensions import Self, override 7 | 8 | from ...exceptions import PyventusException 9 | from ...utils import attributes_repr, formatted_repr, is_callable_async 10 | from ..processing_service import ProcessingService, ProcessingServiceCallbackType 11 | 12 | 13 | class ExecutorProcessingService(ProcessingService): 14 | """ 15 | A processing service that utilizes the `concurrent.futures.Executor` to handle the execution of calls. 16 | 17 | **Notes:** 18 | 19 | - This service uses the `concurrent.futures.Executor` for processing the callbacks' execution. It 20 | can work with either a `ThreadPoolExecutor` for thread-based execution or a `ProcessPoolExecutor` 21 | for process-based execution. 22 | 23 | - Synchronous callbacks are executed in a blocking manner inside the executor, while asynchronous 24 | callbacks are processed within a new asyncio event loop using the `asyncio.run()` function. 25 | 26 | - When using this service, it is important to properly manage the underlying `Executor`. Once 27 | there are no more calls to be processed through the given executor, it's important to invoke 28 | the `shutdown()` method to signal the executor to free any resources for pending futures. You 29 | can avoid the need to call this method explicitly by using the `with` statement, which 30 | automatically shuts down the `Executor`. 31 | """ 32 | 33 | @staticmethod 34 | def _execute(callback: ProcessingServiceCallbackType, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: 35 | """ 36 | Execute the provided callback with the given arguments. 37 | 38 | This method is intended to be used within the executor to execute the callback. 39 | 40 | :param callback: The callback to be executed. 41 | :param args: Positional arguments to be passed to the callback. 42 | :param kwargs: Keyword arguments to be passed to the callback. 43 | :return: None. 44 | """ 45 | # Check if the callback is asynchronous and execute accordingly. 46 | if is_callable_async(callback): 47 | # Run the async callback in a new asyncio event loop. 48 | run(callback(*args, **kwargs)) 49 | else: 50 | # Run the sync callback directly with the provided arguments. 51 | callback(*args, **kwargs) 52 | 53 | # Attributes for the ExecutorProcessingService. 54 | __slots__ = ("__executor",) 55 | 56 | def __init__(self, executor: Executor) -> None: 57 | """ 58 | Initialize an instance of `ExecutorProcessingService`. 59 | 60 | :param executor: The executor object used to handle the callbacks' execution. 61 | :return: None. 62 | :raises PyventusException: If the executor is not provided or is not an instance of `Executor`. 63 | """ 64 | # Validate the executor instance. 65 | if executor is None or not isinstance(executor, Executor): 66 | raise PyventusException("The 'executor' argument must be an instance of Executor.") 67 | 68 | # Store the executor instance. 69 | self.__executor: Executor = executor 70 | 71 | def __repr__(self) -> str: 72 | """ 73 | Retrieve a string representation of the instance. 74 | 75 | :return: A string representation of the instance. 76 | """ 77 | return formatted_repr( 78 | instance=self, 79 | info=attributes_repr( 80 | executor=self.__executor, 81 | ), 82 | ) 83 | 84 | @override 85 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 86 | # Submit the callback to the executor along with its arguments. 87 | self.__executor.submit(self.__class__._execute, callback, args, kwargs) 88 | 89 | def shutdown(self, wait: bool = True, cancel_futures: bool = False) -> None: 90 | """ 91 | Shut down the executor and release any resources it is using. 92 | 93 | :param wait: A boolean indicating whether to wait for the currently pending futures 94 | to complete before shutting down. 95 | :param cancel_futures: A boolean indicating whether to cancel any pending futures. 96 | :return: None. 97 | """ 98 | self.__executor.shutdown(wait=wait, cancel_futures=cancel_futures) 99 | 100 | def __enter__(self) -> Self: 101 | """ 102 | Return the current instance of `ExecutorProcessingService` for context management. 103 | 104 | :return: The current instance of `ExecutorProcessingService`. 105 | """ 106 | return self 107 | 108 | def __exit__( 109 | self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None 110 | ) -> None: 111 | """ 112 | Clean up the executor resources when exiting the context. 113 | 114 | :param exc_type: The exception type, if any. 115 | :param exc_val: The exception value, if any. 116 | :param exc_tb: The traceback information, if any. 117 | :return: None. 118 | """ 119 | self.shutdown(wait=True, cancel_futures=False) 120 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/celery/test_celery_processing_service.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from typing import Any 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | from celery import Celery 7 | from pyventus import PyventusException 8 | from typing_extensions import override 9 | 10 | from .....fixtures import CallableMock 11 | from ..processing_service_test import ProcessingServiceTest 12 | 13 | 14 | class CeleryMock(Celery): # type: ignore[misc] 15 | """ 16 | A mock implementation of the Celery class for testing purposes. 17 | 18 | This class simulates the behavior of a Celery worker, allowing for the 19 | testing of task submissions without requiring a real Celery backend. It 20 | provides a way to invoke tasks by name with the given arguments. 21 | """ 22 | 23 | def send_task(self, *args: Any, **kwargs: Any) -> None: 24 | """Simulate sending a task to the Celery worker.""" 25 | 26 | # Use a new thread to separate the execution of the task from the current test thread. 27 | with ThreadPoolExecutor() as executor: 28 | fut = executor.submit(self.tasks[kwargs["name"]], *kwargs["args"]) 29 | 30 | # Retrieve the results and raise 31 | # any exceptions that occurred. 32 | fut.result() 33 | 34 | 35 | class TestCeleryProcessingService(ProcessingServiceTest): 36 | # ================================= 37 | # Test Cases for creation 38 | # ================================= 39 | 40 | @pytest.mark.parametrize( 41 | ["celery", "queue"], 42 | [ 43 | (CeleryMock(), None), 44 | (CeleryMock(), "Queue"), 45 | ], 46 | ) 47 | def test_creation_with_valid_input(self, celery: Celery, queue: str) -> None: 48 | from pyventus.core.processing.celery import CeleryProcessingService 49 | 50 | # Arrange/Act 51 | processing_service = CeleryProcessingService(celery=celery, queue=queue) 52 | 53 | # Assert 54 | assert processing_service is not None 55 | assert isinstance(processing_service, CeleryProcessingService) 56 | 57 | # ================================= 58 | 59 | @pytest.mark.parametrize( 60 | ["celery", "queue", "exception"], 61 | [ 62 | (None, None, PyventusException), 63 | (True, None, PyventusException), 64 | (object(), None, PyventusException), 65 | (CeleryMock, None, PyventusException), 66 | (CeleryMock(), "", PyventusException), 67 | ], 68 | ) 69 | def test_creation_with_invalid_input(self, celery: Any, queue: str | None, exception: type[Exception]) -> None: 70 | from pyventus.core.processing.celery import CeleryProcessingService 71 | 72 | # Arrange/Act/Assert 73 | with pytest.raises(exception): 74 | CeleryProcessingService(celery=celery, queue=queue) 75 | 76 | # ================================= 77 | # Test Cases for task registration 78 | # ================================= 79 | 80 | def test_task_registration(self) -> None: 81 | from pyventus.core.processing.celery import CeleryProcessingService 82 | 83 | # Arrange/Act 84 | CeleryProcessingService.register() 85 | 86 | # Assert 87 | assert CeleryProcessingService.CELERY_TASK_NAME in CeleryMock().tasks 88 | 89 | # ================================= 90 | # Test Cases for submission 91 | # ================================= 92 | 93 | @override 94 | def handle_submission_in_sync_context( 95 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 96 | ) -> None: 97 | # Use patching to override the behavior of pickle.dumps and pickle.loads. 98 | # This ensures that objects are not serialized or deserialized during testing, 99 | # allowing access to callback metrics for assertions such as call count and others. 100 | with ( 101 | patch("pickle.dumps", new=lambda obj: obj), 102 | patch("pickle.loads", new=lambda obj: obj), 103 | ): 104 | from pyventus.core.processing.celery import CeleryProcessingService 105 | 106 | # Arrange: Register the service task and create a new Celery processing service. 107 | CeleryProcessingService.register() 108 | processing_service = CeleryProcessingService(celery=CeleryMock()) 109 | 110 | # Act: Submit the callback to the processing service with the provided arguments. 111 | processing_service.submit(callback, *args, **kwargs) 112 | 113 | # ================================= 114 | 115 | @override 116 | async def handle_submission_in_async_context( 117 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 118 | ) -> None: 119 | # Use patching to override the behavior of pickle.dumps and pickle.loads. 120 | # This ensures that objects are not serialized or deserialized during testing, 121 | # allowing access to callback metrics for assertions such as call count and others. 122 | with ( 123 | patch("pickle.dumps", new=lambda obj: obj), 124 | patch("pickle.loads", new=lambda obj: obj), 125 | ): 126 | from pyventus.core.processing.celery import CeleryProcessingService 127 | 128 | # Arrange: Register the service task and create a new Celery processing service. 129 | CeleryProcessingService.register() 130 | processing_service = CeleryProcessingService(celery=CeleryMock()) 131 | 132 | # Submit the callback to the processing service with the provided arguments. 133 | processing_service.submit(callback, *args, **kwargs) 134 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | dapensoft@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | 132 | [Mozilla CoC]: https://github.com/mozilla/diversity 133 | 134 | [FAQ]: https://www.contributor-covenant.org/faq 135 | 136 | [translations]: https://www.contributor-covenant.org/translations 137 | -------------------------------------------------------------------------------- /tests/pyventus/core/processing/processing_service_test.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | import pytest 5 | 6 | from ....fixtures import CallableMock 7 | 8 | 9 | class ProcessingServiceTest(ABC): 10 | """Abstract base class for testing processing services.""" 11 | 12 | @abstractmethod 13 | def handle_submission_in_sync_context( 14 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 15 | ) -> None: 16 | """ 17 | Abstract method to process a callback submission in a synchronous context. 18 | 19 | This method should be implemented by subclasses to instantiate the processing 20 | service, submit the provided callback with the specified arguments, and ensure 21 | that the callback execution is complete before returning. 22 | 23 | :param callback: The callback to be submitted for execution. 24 | :param args: Positional arguments to be passed to the callback. 25 | :param kwargs: Keyword arguments to be passed to the callback. 26 | :return: None. 27 | """ 28 | pass 29 | 30 | @abstractmethod 31 | async def handle_submission_in_async_context( 32 | self, callback: CallableMock.Base, args: tuple[Any, ...], kwargs: dict[str, Any] 33 | ) -> None: 34 | """ 35 | Abstract method to process a callback submission in an asynchronous context. 36 | 37 | This method should be implemented by subclasses to instantiate the processing 38 | service, submit the provided callback with the specified arguments, and ensure 39 | that the callback execution is complete before returning. 40 | 41 | :param callback: The callback to be submitted for execution. 42 | :param args: Positional arguments to be passed to the callback. 43 | :param kwargs: Keyword arguments to be passed to the callback. 44 | :return: None. 45 | """ 46 | pass 47 | 48 | @pytest.mark.parametrize( 49 | ["callback_type", "args", "kwargs"], 50 | [ 51 | (CallableMock.Sync, (), {}), 52 | (CallableMock.Sync, ("str", 0), {}), 53 | (CallableMock.Sync, (), {"str": ...}), 54 | (CallableMock.Sync, ("str", 0), {"str": ...}), 55 | (CallableMock.Async, (), {}), 56 | (CallableMock.Async, ("str", 0), {}), 57 | (CallableMock.Async, (), {"str": ...}), 58 | (CallableMock.Async, ("str", 0), {"str": ...}), 59 | ], 60 | ) 61 | def test_submission_in_sync_context( 62 | self, callback_type: type[CallableMock.Base], args: tuple[Any, ...], kwargs: dict[str, Any] 63 | ) -> None: 64 | """ 65 | Test the submission of a callback in a synchronous context. 66 | 67 | This test verifies that the provided callback is submitted correctly to the 68 | processing service and that it is executed with the expected arguments. 69 | It checks that the callback is called exactly once and that the arguments 70 | passed to the callback match the expected values. 71 | 72 | :param callback_type: The callback type to be instantiated and submitted for execution. 73 | :param args: Positional arguments to be passed to the callback. 74 | :param kwargs: Keyword arguments to be passed to the callback. 75 | :return: None. 76 | """ 77 | # Arrange/Act: Instantiate the callback and submit it with the provided arguments in a sync context. 78 | callback: CallableMock.Base = callback_type() 79 | self.handle_submission_in_sync_context(callback=callback, args=args, kwargs=kwargs) 80 | 81 | # Assert: Verify that the callback was called exactly once with the specified arguments. 82 | assert callback.call_count == 1, f"Callback was not called exactly once ({callback.call_count} == 1)." 83 | assert callback.last_args == args, f"Expected args {args}, but got {callback.last_args}." 84 | assert callback.last_kwargs == kwargs, f"Expected kwargs {kwargs}, but got {callback.last_kwargs}." 85 | 86 | @pytest.mark.parametrize( 87 | ["callback_type", "args", "kwargs"], 88 | [ 89 | (CallableMock.Sync, (), {}), 90 | (CallableMock.Sync, ("str", 0), {}), 91 | (CallableMock.Sync, (), {"str": ...}), 92 | (CallableMock.Sync, ("str", 0), {"str": ...}), 93 | (CallableMock.Async, (), {}), 94 | (CallableMock.Async, ("str", 0), {}), 95 | (CallableMock.Async, (), {"str": ...}), 96 | (CallableMock.Async, ("str", 0), {"str": ...}), 97 | ], 98 | ) 99 | async def test_submission_in_async_context( 100 | self, callback_type: type[CallableMock.Base], args: tuple[Any, ...], kwargs: dict[str, Any] 101 | ) -> None: 102 | """ 103 | Test the submission of a callback in an asynchronous context. 104 | 105 | This test verifies that the provided callback is submitted correctly to the 106 | processing service and that it is executed with the expected arguments when 107 | called from an asynchronous context. It checks that the callback is called 108 | exactly once and that the arguments passed to the callback match the 109 | expected values. 110 | 111 | :param callback_type: The callback type to be instantiated and submitted for execution. 112 | :param args: Positional arguments to be passed to the callback. 113 | :param kwargs: Keyword arguments to be passed to the callback. 114 | :return: None. 115 | """ 116 | # Arrange/Act: Instantiate the callback and submit it with the provided arguments in an async context. 117 | callback: CallableMock.Base = callback_type() 118 | await self.handle_submission_in_async_context(callback=callback, args=args, kwargs=kwargs) 119 | 120 | # Assert: Verify that the callback was called exactly once with the specified arguments. 121 | assert callback.call_count == 1, f"Callback was not called exactly once ({callback.call_count} == 1)." 122 | assert callback.last_args == args, f"Expected args {args}, but got {callback.last_args}." 123 | assert callback.last_kwargs == kwargs, f"Expected kwargs {kwargs}, but got {callback.last_kwargs}." 124 | -------------------------------------------------------------------------------- /src/pyventus/core/processing/celery/celery_processing_service.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from dataclasses import dataclass 3 | from pickle import dumps, loads 4 | from typing import Any, Final, cast 5 | 6 | from typing_extensions import override 7 | 8 | from ...exceptions import PyventusException, PyventusImportException 9 | from ...loggers import StdOutLogger 10 | from ...utils import attributes_repr, formatted_repr, is_callable_async, summarized_repr 11 | from ..processing_service import ProcessingService, ProcessingServiceCallbackType 12 | 13 | try: # pragma: no cover 14 | from celery import Celery, shared_task 15 | except ImportError: # pragma: no cover 16 | raise PyventusImportException(import_name="celery", is_optional=True, is_dependency=True) from None 17 | 18 | 19 | class CeleryProcessingService(ProcessingService): 20 | """ 21 | A processing service that utilizes the `Celery` framework to handle the execution of calls. 22 | 23 | **Notes:** 24 | 25 | - This service leverages the `Celery` framework to enqueue the provided callbacks into a distributed 26 | task system, which is monitored by multiple workers. Once enqueued, these callbacks are eligible 27 | for retrieval and processing by available workers, enabling a scalable and distributed approach 28 | to handling calls asynchronously. 29 | 30 | - Synchronous callbacks are executed in a blocking manner inside the worker, while asynchronous 31 | callbacks are processed within a new asyncio event loop using the `asyncio.run()` function. 32 | """ 33 | 34 | CELERY_TASK_NAME: Final[str] = "pyventus_task" 35 | """The name of the task in Celery.""" 36 | 37 | @dataclass(slots=True, frozen=True) 38 | class CeleryPayload: 39 | """A data class representing the payload of the `CeleryProcessingService`.""" 40 | 41 | callback: ProcessingServiceCallbackType 42 | args: tuple[Any, ...] 43 | kwargs: dict[str, Any] 44 | 45 | @classmethod 46 | def register(cls) -> None: 47 | """ 48 | Register the service's task globally in `Celery`. 49 | 50 | **Notes:** 51 | 52 | - This method should be invoked in the `Celery` worker script to ensure that the task is 53 | accessible to both the client and the worker. If this method is not called, a `KeyError` 54 | will be raised when attempting to submit a new callback. 55 | 56 | - This method uses the `shared_task` functionality from the `Celery` framework to register 57 | the service's task, making it available independently of the `Celery` instance used. 58 | 59 | - The registered task is a `Celery` task that will be used to process the execution 60 | of callbacks. 61 | 62 | :return: None. 63 | """ 64 | 65 | def task(serialized_payload: bytes) -> None: 66 | """ 67 | Celery task that processes the callbacks' execution with the given arguments. 68 | 69 | :param serialized_payload: The serialized data representing the callback and its arguments. 70 | :return: None. 71 | :raises PyventusException: If the serialized payload is invalid or cannot be deserialized. 72 | """ 73 | # Validate that the serialized payload is provided. 74 | if not serialized_payload: # pragma: no cover 75 | raise PyventusException("The 'serialized_payload' argument is required but was not received.") 76 | 77 | # Deserialize the payload to retrieve the original callback and its arguments. 78 | payload = cast(CeleryProcessingService.CeleryPayload, loads(serialized_payload)) 79 | 80 | # Validate the deserialized payload to ensure it is of the expected type. 81 | if payload is None or not isinstance(payload, CeleryProcessingService.CeleryPayload): # pragma: no cover 82 | raise PyventusException("Failed to deserialize the given payload.") 83 | 84 | # Check if the callback is asynchronous and execute accordingly. 85 | if is_callable_async(payload.callback): 86 | # Run the async callback in a new asyncio event loop. 87 | run(payload.callback(*payload.args, **payload.kwargs)) 88 | else: 89 | # Run the sync callback directly with the provided arguments. 90 | payload.callback(*payload.args, **payload.kwargs) 91 | 92 | # Register the service's task as a shared task in Celery. 93 | shared_task(name=cls.CELERY_TASK_NAME)(task) 94 | 95 | # Attributes for the CeleryProcessingService 96 | __slots__ = ("__celery", "__queue") 97 | 98 | def __init__(self, celery: Celery, queue: str | None = None) -> None: 99 | """ 100 | Initialize an instance of `CeleryProcessingService`. 101 | 102 | :param celery: The Celery object used to enqueue and process callbacks. 103 | :param queue: The name of the queue where callbacks will be enqueued. Defaults to 104 | None, which uses the `task_default_queue` from the Celery configuration. 105 | :raises PyventusException: If the Celery instance is invalid or if the 106 | queue name is set but empty. 107 | """ 108 | # Validate the Celery instance. 109 | if celery is None or not isinstance(celery, Celery): 110 | raise PyventusException("The 'celery' argument must be an instance of the Celery class.") 111 | 112 | # Check if the Celery app configuration uses the 'auth' serializer. 113 | if celery.conf.task_serializer != "auth": 114 | StdOutLogger.warning( 115 | source=summarized_repr(self), 116 | action="Security Message:", 117 | msg=( 118 | "To enhance security in message communication, it is recommended to employ the Celery 'auth' " 119 | "serializer. While this service is serializer-agnostic, it relies on the pickling process to " 120 | "convert callbacks and their arguments into transmittable data, making security a critical " 121 | "consideration. Please refer to: https://docs.celeryq.dev/en/stable/userguide/security.html" 122 | ), 123 | ) 124 | 125 | # Validate the queue name, if provided. 126 | if queue is not None and len(queue) == 0: 127 | raise PyventusException("The 'queue' argument cannot be empty.") 128 | 129 | # Assign the Celery instance and queue name. 130 | self.__celery: Celery = celery 131 | self.__queue: str = queue if queue else self.__celery.conf.task_default_queue 132 | 133 | def __repr__(self) -> str: 134 | """ 135 | Retrieve a string representation of the instance. 136 | 137 | :return: A string representation of the instance. 138 | """ 139 | return formatted_repr( 140 | instance=self, 141 | info=attributes_repr( 142 | celery=self.__celery, 143 | queue=self.__queue, 144 | ), 145 | ) 146 | 147 | @override 148 | def submit(self, callback: ProcessingServiceCallbackType, *args: Any, **kwargs: Any) -> None: 149 | # Create the Celery payload to encapsulate the callback and its arguments. 150 | payload = CeleryProcessingService.CeleryPayload(callback=callback, args=args, kwargs=kwargs) 151 | 152 | # Serialize the payload object. 153 | serialized_payload: bytes = dumps(payload) 154 | 155 | # Send the serialized payload to Celery for asynchronous execution. 156 | self.__celery.send_task( 157 | name=self.__class__.CELERY_TASK_NAME, 158 | args=(serialized_payload,), 159 | queue=self.__queue, 160 | ) 161 | -------------------------------------------------------------------------------- /docs/learn/events/emitters/redis.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # Redis Event Emitter 11 | 12 |

13 |   In Pyventus, you can easily integrate Event Emitters with the Redis Queue framework through the Redis Processing Service. Simply create an instance of the Redis Processing Service and pass it as the event processor when setting up the Event Emitter, or you can use the factory method called Redis Event Emitter to handle the setup in a single step. 14 |

15 | 16 | === ":material-console: Manual Configuration" 17 | 18 | ```Python linenums="1" hl_lines="1-4 6-7 10" 19 | from pyventus.core.processing.redis import RedisProcessingService 20 | from pyventus.events import EventEmitter 21 | from redis import Redis 22 | from rq import Queue 23 | 24 | redis_conn = Redis.from_url("redis://default:redispw@localhost:6379") 25 | default_queue: Queue = Queue(name="default", connection=redis_conn) 26 | 27 | if __name__ == "__main__": 28 | event_emitter: EventEmitter = EventEmitter(event_processor=RedisProcessingService(queue=default_queue)) 29 | event_emitter.emit("MyEvent") 30 | ``` 31 | 32 | === ":material-factory: Factory Method" 33 | 34 | ```Python linenums="1" hl_lines="1-3 5-6 9" 35 | from pyventus.events import EventEmitter, RedisEventEmitter 36 | from redis import Redis 37 | from rq import Queue 38 | 39 | redis_conn = Redis.from_url("redis://default:redispw@localhost:6379") 40 | default_queue: Queue = Queue(name="default", connection=redis_conn) 41 | 42 | if __name__ == "__main__": 43 | event_emitter: EventEmitter = RedisEventEmitter(queue=default_queue) 44 | event_emitter.emit("MyEvent") 45 | ``` 46 | 47 |

48 |   By utilizing the Redis Processing Service, the execution of each event emission will be handled by a Redis Queue worker. 49 |

50 | 51 | ## Practical Example 52 | 53 |

54 | To start using the Event Emitter with Redis Queue, follow these steps: 55 |

56 | 57 | 1.

Install Dependencies: 58 | Before proceeding, ensure that you have installed the optional [Redis Queue dependency](../../../getting-started.md/#optional-dependencies). 59 |

60 | 61 | 2.

Define Subscribers: 62 | If you're using Python's built-in functions, you can skip this step. If you're working with your own functions, you'll need to let Redis Queue know where they are defined. However, to avoid circular dependencies between modules, it's important to place these functions in a separate module from both your worker module and the event emitter. 63 |

64 | 65 | ```Python title="subscribers.py" linenums="1" 66 | from pyventus.events import EventLinker 67 | 68 | 69 | @EventLinker.on("MyEvent") 70 | def handle_my_event() -> None: 71 | print("Handling 'MyEvent'!") 72 | ``` 73 | 74 | 3.

Create a Worker: 75 | Now that you’ve defined your subscribers, the next step is to create the script for the Redis Queue worker. This worker will listen to the Redis Queue pub/sub channel and process each event emission. For more information about Redis Queue workers, you can refer to the official documentation: [RQ Workers](https://python-rq.org/docs/workers/). 76 |

77 | 78 | === ":material-apple: macOS / :material-linux: Linux" 79 | 80 | ```Python title="worker.py" linenums="1" hl_lines="1 3-4 6 8-9 12 21-24 26-27" 81 | from multiprocessing import Process 82 | 83 | from redis import Redis 84 | from rq import Queue, SimpleWorker 85 | 86 | from .subscribers import handle_my_event # (1)! 87 | 88 | redis_conn = Redis.from_url("redis://default:redispw@localhost:6379") 89 | default_queue: Queue = Queue(name="default", connection=redis_conn) 90 | 91 | 92 | def worker_process() -> None: # (2)! 93 | worker = SimpleWorker(connection=redis_conn, queues=[default_queue]) 94 | worker.work() 95 | 96 | 97 | if __name__ == "__main__": 98 | num_workers = 1 # (3)! 99 | worker_processes: list[Process] = [] 100 | 101 | for _ in range(num_workers): # (4)! 102 | p = Process(target=worker_process) 103 | worker_processes.append(p) 104 | p.start() 105 | 106 | for process in worker_processes: 107 | process.join() # (5)! 108 | ``` 109 | 110 | 1. Import the `subscribers.py` module to let Redis Queue know about the available functions. 111 | 2. Creates a new Worker instance and starts the work loop. 112 | 3. Set the number of workers. For auto-assignment use: `multiprocessing.cpu_count()`. 113 | 4. Creates and starts new Processes for each worker. 114 | 5. Join every worker process. 115 | 116 | === ":fontawesome-brands-windows: Windows" 117 | 118 | ```Python title="worker.py" linenums="1" hl_lines="1 3-5 7 9-10 13-15 25-28 30-31" 119 | from multiprocessing import Process 120 | 121 | from redis import Redis 122 | from rq import Queue, SimpleWorker 123 | from rq.timeouts import TimerDeathPenalty 124 | 125 | from .subscribers import handle_my_event # (1)! 126 | 127 | redis_conn = Redis.from_url("redis://default:redispw@localhost:6379") 128 | default_queue: Queue = Queue(name="default", connection=redis_conn) 129 | 130 | 131 | def worker_process() -> None: # (2)! 132 | class WindowsSimpleWorker(SimpleWorker): # (3)! 133 | death_penalty_class = TimerDeathPenalty 134 | 135 | worker = WindowsSimpleWorker(connection=redis_conn, queues=[default_queue]) 136 | worker.work() 137 | 138 | 139 | if __name__ == "__main__": 140 | num_workers = 1 # (4)! 141 | worker_processes: list[Process] = [] 142 | 143 | for _ in range(num_workers): # (5)! 144 | p = Process(target=worker_process) 145 | worker_processes.append(p) 146 | p.start() 147 | 148 | for process in worker_processes: 149 | process.join() # (6)! 150 | ``` 151 | 152 | 1. Import the `subscribers.py` module to let Redis Queue know about the available functions. 153 | 2. Creates a new Worker instance and starts the work loop. 154 | 3. A class that inherits from `SimpleWorker` and is used to create a new worker instance in a Windows based system. 155 | 4. Set the number of workers. For auto-assignment use: `multiprocessing.cpu_count()`. 156 | 5. Creates and starts new Processes for each worker. 157 | 6. Join every worker process. 158 | 159 |

160 | With the previous configuration in place, you can now launch the Redis Queue worker. 161 |

162 | 163 |
164 | ```console 165 | py -m worker 166 | ``` 167 |
168 | 169 | 4.

Emitting events: 170 | Now that your workers are up and running, it’s time to start emitting events! Just create an Event Emitter configured with the Redis Processing Service, and you’re all set to emit an event. 171 |

172 | 173 | ```Python title="main.py" linenums="1" hl_lines="1 3 6" 174 | from pyventus.events import EventEmitter, RedisEventEmitter 175 | 176 | from .worker import default_queue 177 | 178 | if __name__ == "__main__": 179 | event_emitter: EventEmitter = RedisEventEmitter(queue=default_queue) 180 | event_emitter.emit("MyEvent") 181 | ``` 182 | -------------------------------------------------------------------------------- /tests/pyventus/reactive/subscribers/test_subscriber.py: -------------------------------------------------------------------------------- 1 | from pickle import dumps, loads 2 | from typing import Any 3 | 4 | import pytest 5 | from pyventus import PyventusException 6 | from pyventus.reactive import Subscriber 7 | 8 | from ....fixtures import CallableMock, DummyCallable 9 | from ....utils import has_private_attr 10 | 11 | 12 | class TestSubscriber: 13 | # ================================= 14 | # Test Cases for creation 15 | # ================================= 16 | 17 | @pytest.mark.parametrize( 18 | ["next_callback", "error_callback", "complete_callback"], 19 | [ 20 | (CallableMock.Sync(), None, None), 21 | (None, CallableMock.Async(), None), 22 | (None, None, CallableMock.Async()), 23 | (CallableMock.Async(), None, CallableMock.Sync()), 24 | (CallableMock.Async(), CallableMock.Sync(), None), 25 | (None, CallableMock.Sync(), CallableMock.Async()), 26 | ], 27 | ) 28 | def test_creation_with_valid_input( 29 | self, next_callback: CallableMock.Base, error_callback: CallableMock.Base, complete_callback: CallableMock.Base 30 | ) -> None: 31 | # Arrange/Act 32 | subscriber = Subscriber[Any]( 33 | teardown_callback=lambda sub: True, 34 | next_callback=next_callback, 35 | error_callback=error_callback, 36 | complete_callback=complete_callback, 37 | force_async=False, 38 | ) 39 | 40 | # Assert 41 | assert subscriber is not None 42 | assert isinstance(subscriber, Subscriber) 43 | assert subscriber.has_next_callback is bool(next_callback is not None) 44 | assert subscriber.has_error_callback is bool(error_callback is not None) 45 | assert subscriber.has_complete_callback is bool(complete_callback is not None) 46 | 47 | # ================================= 48 | 49 | @pytest.mark.parametrize( 50 | ["next_callback", "error_callback", "complete_callback", "exception"], 51 | [ 52 | (None, None, None, PyventusException), 53 | (DummyCallable.Sync.Generator.func, None, None, PyventusException), 54 | (None, DummyCallable.Async.Generator.func, None, PyventusException), 55 | (None, None, DummyCallable.Async.Generator.func, PyventusException), 56 | ], 57 | ) 58 | def test_creation_with_invalid_input( 59 | self, next_callback: Any, error_callback: Any, complete_callback: Any, exception: type[Exception] 60 | ) -> None: 61 | # Arrange/Act/Assert 62 | with pytest.raises(exception): 63 | Subscriber[Any]( 64 | teardown_callback=lambda sub: True, 65 | next_callback=next_callback, 66 | error_callback=error_callback, 67 | complete_callback=complete_callback, 68 | force_async=False, 69 | ) 70 | 71 | # ================================= 72 | # Test Cases for the Next method execution. 73 | # ================================= 74 | 75 | @pytest.mark.parametrize( 76 | ["next_callback"], 77 | [ 78 | (None,), 79 | (CallableMock.Sync(),), 80 | (CallableMock.Async(),), 81 | ], 82 | ) 83 | async def test_next_method_execution(self, next_callback: CallableMock.Base | None) -> None: 84 | # Arrange 85 | value: Any = object() 86 | error_callback: CallableMock.Base = CallableMock.Sync() 87 | complete_callback: CallableMock.Base = CallableMock.Async() 88 | subscriber = Subscriber[Any]( 89 | teardown_callback=lambda sub: True, 90 | next_callback=next_callback, 91 | error_callback=error_callback, 92 | complete_callback=complete_callback, 93 | force_async=False, 94 | ) 95 | 96 | # Act 97 | await subscriber.next(value) 98 | 99 | # Assert 100 | if next_callback: 101 | assert next_callback.call_count == 1 102 | assert next_callback.last_args == (value,) 103 | assert next_callback.last_kwargs == {} 104 | assert error_callback.call_count == 0 105 | assert complete_callback.call_count == 0 106 | 107 | # ================================= 108 | # Test Cases for the Error method execution. 109 | # ================================= 110 | 111 | @pytest.mark.parametrize( 112 | ["error_callback"], 113 | [ 114 | (None,), 115 | (CallableMock.Sync(),), 116 | (CallableMock.Async(),), 117 | ], 118 | ) 119 | async def test_error_method_execution(self, error_callback: CallableMock.Base | None) -> None: 120 | # Arrange 121 | exception: Exception = ValueError() 122 | next_callback: CallableMock.Base = CallableMock.Async() 123 | complete_callback: CallableMock.Base = CallableMock.Sync() 124 | subscriber = Subscriber[Any]( 125 | teardown_callback=lambda sub: True, 126 | next_callback=next_callback, 127 | error_callback=error_callback, 128 | complete_callback=complete_callback, 129 | force_async=False, 130 | ) 131 | 132 | # Act 133 | await subscriber.error(exception) 134 | 135 | # Assert 136 | if error_callback: 137 | assert error_callback.call_count == 1 138 | assert error_callback.last_args == (exception,) 139 | assert error_callback.last_kwargs == {} 140 | assert next_callback.call_count == 0 141 | assert complete_callback.call_count == 0 142 | 143 | # ================================= 144 | # Test Cases for the Complete method execution. 145 | # ================================= 146 | 147 | @pytest.mark.parametrize( 148 | ["complete_callback"], 149 | [ 150 | (None,), 151 | (CallableMock.Sync(),), 152 | (CallableMock.Async(),), 153 | ], 154 | ) 155 | async def test_complete_method_execution(self, complete_callback: CallableMock.Base | None) -> None: 156 | # Arrange 157 | next_callback: CallableMock.Base = CallableMock.Async() 158 | error_callback: CallableMock.Base = CallableMock.Sync() 159 | subscriber = Subscriber[Any]( 160 | teardown_callback=lambda sub: True, 161 | next_callback=next_callback, 162 | error_callback=error_callback, 163 | complete_callback=complete_callback, 164 | force_async=False, 165 | ) 166 | 167 | # Act 168 | await subscriber.complete() 169 | 170 | # Assert 171 | if complete_callback: 172 | assert complete_callback.call_count == 1 173 | assert complete_callback.last_args == () 174 | assert complete_callback.last_kwargs == {} 175 | assert next_callback.call_count == 0 176 | assert error_callback.call_count == 0 177 | 178 | # ================================= 179 | # Test Cases for Serialization/Deserialization 180 | # ================================= 181 | 182 | def test_pickle_serialization_and_deserialization(self) -> None: 183 | # Arrange 184 | subscriber = Subscriber( 185 | teardown_callback=CallableMock.Sync(return_value=False), 186 | next_callback=CallableMock.Sync(), 187 | error_callback=None, 188 | complete_callback=None, 189 | force_async=True, 190 | ) 191 | 192 | # Act 193 | data = dumps(subscriber) 194 | restored = loads(data) 195 | 196 | # Assert 197 | for attr in Subscriber.__slots__: 198 | assert ( 199 | hasattr(restored, attr) 200 | if not attr.startswith("__") or attr.endswith("__") 201 | else has_private_attr(restored, attr) 202 | ) 203 | -------------------------------------------------------------------------------- /docs/learn/events/emitters/index.md: -------------------------------------------------------------------------------- 1 | # Event Emitters 2 | 3 |

4 |   Now that your events and responses are all linked up, you may need a way to dispatch these events throughout your application and trigger their corresponding responses. For this purpose, Pyventus provides you with what is known as an Event Emitter. 5 |

6 | 7 |

8 |   The Event Emitter in Pyventus is essentially a base class that allows you to orchestrate the emission of events within your application in a modular and decoupled manner. 9 |

10 | 11 | ## Getting Started 12 | 13 |

14 |   In order to start working with the Event Emitter, the first thing you will need to do is to create a new instance of it. To do so, you will need what is known as an event processor, which is an instance of the Processing Service interface. This processor is crucial for the Event Emitter, as it is responsible for processing the emission of events. 15 |

16 | 17 | === ":material-console: Manual Creation" 18 | 19 | ```Python linenums="1" hl_lines="1 2 4" 20 | from pyventus.events import EventEmitter 21 | from pyventus.core.processing.asyncio import AsyncIOProcessingService # (1)! 22 | 23 | event_emitter = EventEmitter(event_processor=AsyncIOProcessingService()) 24 | event_emitter.emit("MyEvent") 25 | ``` 26 | 27 | 1. You can import and use any of the supported processing services, or even create your own custom ones. 28 | 29 | === ":material-factory: Factory Method" 30 | 31 | ```Python linenums="1" hl_lines="1 4" 32 | from pyventus.events import AsyncIOEventEmitter, EventEmitter 33 | 34 | 35 | event_emitter: EventEmitter = AsyncIOEventEmitter() # (1)! 36 | event_emitter.emit("MyEvent") 37 | ``` 38 | 39 | 1. There is a factory method for each of the supported processing services. For reference, you can check the [Event Emitter utilities](../../../api/events/emitters/event_emitter_utils.md). 40 | 41 | ### Using Custom Event Linkers 42 | 43 |

44 |   By default, the Event Emitter comes with the base Event Linker class configured as the primary access point for events and their responses. However, you have the flexibility to specify the Event Linker class that the Event Emitter will use. 45 |

46 | 47 | === ":material-console: Manual Creation" 48 | 49 | ```Python linenums="1" hl_lines="5 7 11" 50 | from pyventus.events import EventEmitter, EventLinker 51 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 52 | 53 | 54 | class CustomEventLinker(EventLinker): ... 55 | 56 | CustomEventLinker.subscribe("MyEvent", event_callback=print) 57 | EventLinker.subscribe("MyEvent", event_callback=print) # (1)! 58 | 59 | event_emitter = EventEmitter( 60 | event_linker=CustomEventLinker, 61 | event_processor=AsyncIOProcessingService(), 62 | ) 63 | event_emitter.emit("MyEvent", "Hello, World!") 64 | ``` 65 | 66 | 1. This event subscriber will not be triggered, as it is subscribed to the Event Linker base class rather than the custom one that was created. 67 | 68 | === ":material-factory: Factory Method" 69 | 70 | ```Python linenums="1" hl_lines="4 7 12" 71 | from pyventus.events import AsyncIOEventEmitter, EventEmitter, EventLinker 72 | 73 | 74 | class CustomEventLinker(EventLinker): ... 75 | 76 | 77 | CustomEventLinker.subscribe("MyEvent", event_callback=print) 78 | EventLinker.subscribe("MyEvent", event_callback=print) # (1)! 79 | 80 | 81 | event_emitter: EventEmitter = AsyncIOEventEmitter( 82 | event_linker=CustomEventLinker, 83 | ) 84 | event_emitter.emit("MyEvent", "Hello, World!") 85 | ``` 86 | 87 | 1. This event subscriber will not be triggered, as it is subscribed to the Event Linker base class rather than the custom one that was created. 88 | 89 | ### Using Custom Event Processors 90 | 91 |

92 |   If you need a different strategy for processing event emissions, you can easily do so by providing an instance of a subclass of the Processing Service interface to the Event Emitter. 93 |

94 | 95 | ```Python linenums="1" hl_lines="3 7 8 14" 96 | import asyncio 97 | 98 | from pyventus.core.processing import ProcessingService 99 | from pyventus.events import EventEmitter, EventLinker 100 | 101 | 102 | class CustomProcessingService(ProcessingService): 103 | def submit(self, callback, *args, **kwargs): # (1)! 104 | coro = callback(*args, **kwargs) # (2)! 105 | if asyncio.iscoroutine(coro): 106 | asyncio.run(coro) 107 | 108 | 109 | event_emitter = EventEmitter(event_processor=CustomProcessingService()) 110 | 111 | EventLinker.subscribe("MyEvent", event_callback=print) 112 | event_emitter.emit("MyEvent", "Hello, World!") 113 | ``` 114 | 115 | 1. Implement this method to define the processing strategy. See the example below. 116 | 2. The given `callback` can be either a synchronous or an asynchronous Python function. For typing, you can refer to the base [submit()](../../../api/core/processing/#pyventus.core.processing.ProcessingService.submit) method. 117 | 118 | ## Runtime Flexibility 119 | 120 |

121 |   Thanks to the separation of concerns between the Event Linker and the Event Emitter, you can easily change the Event Emitter at runtime without needing to reconfigure all connections or implement complex logic. 122 |

123 | 124 | ```Python linenums="1" hl_lines="6 14 15" 125 | from concurrent.futures import ThreadPoolExecutor 126 | 127 | from pyventus.events import AsyncIOEventEmitter, EventEmitter, EventLinker, ExecutorEventEmitter 128 | 129 | 130 | def main(event_emitter: EventEmitter) -> None: 131 | event_emitter.emit("MyEvent", f"Using: {event_emitter}!") 132 | 133 | 134 | if __name__ == "__main__": 135 | executor = ThreadPoolExecutor() 136 | EventLinker.subscribe("MyEvent", event_callback=print) 137 | 138 | main(event_emitter=AsyncIOEventEmitter()) 139 | main(event_emitter=ExecutorEventEmitter(executor)) 140 | 141 | executor.shutdown() 142 | ``` 143 | 144 | ## Debug Mode 145 | 146 |

147 |   Pyventus' Event Emitter offers a useful debug mode feature to help you understand the flow of events and troubleshoot your event-driven application. You can enable debug mode in the Event Emitter using the following options: 148 |

149 | 150 | ### Global Debug Mode 151 | 152 |

153 |   By default, Pyventus leverages Python's global debug tracing feature to determine whether the code is running in debug mode or not. When this mode is enabled, all local debug flags are set to `True` unless they are already configured. To activate global debug mode, simply run your code in a debugger like [pdb](https://docs.python.org/3/library/pdb.html). 154 |

155 | 156 |

157 | EventLinker Global Debug Mode 158 |

159 | 160 | ### Instance Debug Mode 161 | 162 |

163 |   Alternatively, if you want to enable or disable debug mode for a specific Event Emitter instance, you can use the `debug` flag. Setting the `debug` flag to `True` enables debug mode for that instance, while setting it to `False` disables debug mode. 164 |

165 | 166 | === "Debug Mode `On`" 167 | 168 | ```Python linenums="1" hl_lines="3" 169 | from pyventus.events import AsyncIOEventEmitter, EventEmitter 170 | 171 | event_emitter: EventEmitter = AsyncIOEventEmitter(debug=True) 172 | event_emitter.emit("MyEvent", "Hello, World!") 173 | ``` 174 | 175 | === "Debug Mode `Off`" 176 | 177 | ```Python linenums="1" hl_lines="3" 178 | from pyventus.events import AsyncIOEventEmitter, EventEmitter 179 | 180 | event_emitter: EventEmitter = AsyncIOEventEmitter(debug=False) 181 | event_emitter.emit("MyEvent", "Hello, World!") 182 | ``` 183 | -------------------------------------------------------------------------------- /src/pyventus/events/subscribers/event_subscriber.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any, TypeAlias, final 3 | 4 | from typing_extensions import Self, override 5 | 6 | from ...core.exceptions import PyventusException 7 | from ...core.subscriptions import Subscription 8 | from ...core.utils import CallableWrapper, attributes_repr, formatted_repr 9 | from ...events.handlers import EventHandler 10 | 11 | EventCallbackType: TypeAlias = Callable[..., Any] 12 | """Type alias for the event callback invoked when the event occurs.""" 13 | 14 | SuccessCallbackType: TypeAlias = Callable[..., Awaitable[None] | None] 15 | """Type alias for the callback invoked when the event response completes successfully.""" 16 | 17 | FailureCallbackType: TypeAlias = Callable[[Exception], Awaitable[None] | None] 18 | """Type alias for the callback invoked when the event response fails.""" 19 | 20 | 21 | @final 22 | class EventSubscriber(EventHandler, Subscription): 23 | """ 24 | A class that represents an `EventHandler` subscribed to an `EventLinker`. 25 | 26 | **Notes:** 27 | 28 | - This class combines the `EventHandler` interface with the `Subscription` 29 | base class, providing a convenient way to handle events and manage the 30 | subscription lifecycle. 31 | 32 | - This class is not intended to be subclassed or instantiated directly. 33 | """ 34 | 35 | # Attributes for the EventSubscriber 36 | __slots__ = ("__event_callback", "__success_callback", "__failure_callback", "__once") 37 | 38 | def __init__( 39 | self, 40 | teardown_callback: Callable[[Self], bool], 41 | event_callback: EventCallbackType, 42 | success_callback: SuccessCallbackType | None, 43 | failure_callback: FailureCallbackType | None, 44 | force_async: bool, 45 | once: bool, 46 | ) -> None: 47 | """ 48 | Initialize an instance of `EventSubscriber`. 49 | 50 | :param teardown_callback: A callback function invoked during the unsubscription process to perform 51 | cleanup or teardown operations associated with the subscription. It should return `True` if the 52 | cleanup was successful, or `False` if the teardown has already been executed and the subscription 53 | is no longer active. 54 | :param event_callback: The callback to be executed when the event occurs. 55 | :param success_callback: The callback to be executed when the event response completes successfully. 56 | :param failure_callback: The callback to be executed when the event response fails. 57 | :param force_async: Determines whether to force all callbacks to run asynchronously. 58 | If `True`, synchronous callbacks will be converted to run asynchronously in a 59 | thread pool, using the `asyncio.to_thread` function. If `False`, callbacks 60 | will run synchronously or asynchronously as defined. 61 | :param once: Specifies if the event subscriber is a one-time subscription. 62 | """ 63 | # Initialize the base Subscription class with the teardown callback 64 | super().__init__(teardown_callback=teardown_callback) 65 | 66 | # Ensure the 'once' parameter is a boolean 67 | if not isinstance(once, bool): 68 | raise PyventusException("The 'once' argument must be a boolean value.") 69 | 70 | # Wrap and set the event callback 71 | self.__event_callback = CallableWrapper[..., Any](event_callback, force_async=force_async) 72 | 73 | # Ensure that the event callback is not a generator. 74 | if self.__event_callback.is_generator: 75 | raise PyventusException("The 'event_callback' cannot be a generator.") 76 | 77 | # Wrap and set the success callback, if provided. 78 | self.__success_callback = ( 79 | CallableWrapper[..., None]( 80 | success_callback, 81 | force_async=force_async, 82 | ) 83 | if success_callback 84 | else None 85 | ) 86 | 87 | # Ensure that the success callback is not a generator. 88 | if self.__success_callback and self.__success_callback.is_generator: 89 | raise PyventusException("The 'success_callback' cannot be a generator.") 90 | 91 | # Wrap and set the failure callback, if provided. 92 | self.__failure_callback = ( 93 | CallableWrapper[[Exception], None]( 94 | failure_callback, 95 | force_async=force_async, 96 | ) 97 | if failure_callback 98 | else None 99 | ) 100 | 101 | # Ensure that the failure callback is not a generator. 102 | if self.__failure_callback and self.__failure_callback.is_generator: 103 | raise PyventusException("The 'failure_callback' cannot be a generator.") 104 | 105 | # Store the one-time subscription flag. 106 | self.__once: bool = once 107 | 108 | @override 109 | def __repr__(self) -> str: 110 | return formatted_repr( 111 | instance=self, 112 | info=( 113 | attributes_repr( 114 | event_callback=self.__event_callback, 115 | success_callback=self.__success_callback, 116 | failure_callback=self.__failure_callback, 117 | once=self.__once, 118 | ) 119 | + f", {super().__repr__()}" 120 | ), 121 | ) 122 | 123 | @property 124 | def once(self) -> bool: 125 | """ 126 | Determine if the event subscriber is a one-time subscription. 127 | 128 | :return: A boolean value indicating if the event subscriber 129 | is a one-time subscription. 130 | """ 131 | return self.__once 132 | 133 | @override 134 | async def _handle_event(self, *args: Any, **kwargs: Any) -> Any: 135 | # Execute the event callback with the provided arguments and return the result 136 | return await self.__event_callback.execute(*args, **kwargs) 137 | 138 | @override 139 | async def _handle_success(self, results: Any) -> None: 140 | if self.__success_callback is None: 141 | # If no success callback is set, exit early 142 | return 143 | elif results is None: 144 | # If results are None, invoke the success callback without parameters 145 | await self.__success_callback.execute() 146 | else: 147 | # Invoke the success callback with the given results 148 | await self.__success_callback.execute(results) 149 | 150 | @override 151 | async def _handle_failure(self, exception: Exception) -> None: 152 | if self.__failure_callback is None: 153 | # If no failure callback is set, exit early 154 | return 155 | else: 156 | # Invoke the failure callback with the provided exception 157 | await self.__failure_callback.execute(exception) 158 | 159 | @override 160 | def __getstate__(self) -> dict[str, Any]: 161 | # Retrieve the state of the base Subscription class 162 | state: dict[str, Any] = super().__getstate__() 163 | 164 | # Add the state of the EventSubscriber attributes 165 | state["__event_callback"] = self.__event_callback 166 | state["__success_callback"] = self.__success_callback 167 | state["__failure_callback"] = self.__failure_callback 168 | state["__once"] = self.__once 169 | 170 | # Return the complete state for serialization 171 | return state 172 | 173 | @override 174 | def __setstate__(self, state: dict[str, Any]) -> None: 175 | # Restore the state of the base Subscription class 176 | super().__setstate__(state) 177 | 178 | # Restore the state of the EventSubscriber attributes 179 | self.__event_callback = state["__event_callback"] 180 | self.__success_callback = state["__success_callback"] 181 | self.__failure_callback = state["__failure_callback"] 182 | self.__once = state["__once"] 183 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: Pyventus 3 | site_url: https://mdapena.github.io/pyventus/ 4 | site_author: Manuel Da Pena 5 | site_description: >- 6 | A powerful Python library for event-driven and reactive programming. 7 | 8 | # Repository 9 | repo_name: mdapena/pyventus 10 | repo_url: https://github.com/mdapena/pyventus 11 | 12 | # Copyright 13 | copyright: | 14 | Copyright © 2023-2025 Manuel Da Pena 15 | 16 | # Configuration 17 | theme: 18 | name: material 19 | language: en 20 | custom_dir: docs/.overrides 21 | logo: images/logo/pyventus-logo-white.png 22 | favicon: images/favicon/pyventus-logo.ico 23 | palette: 24 | # Palette toggle for automatic mode 25 | - media: "(prefers-color-scheme)" 26 | toggle: 27 | icon: material/weather-sunny 28 | name: Switch to light mode 29 | # Palette toggle for light mode 30 | - media: '(prefers-color-scheme: light)' 31 | scheme: default 32 | primary: cyan 33 | accent: amber 34 | toggle: 35 | icon: material/weather-night 36 | name: Switch to dark mode 37 | # Palette toggle for dark mode 38 | - media: '(prefers-color-scheme: dark)' 39 | scheme: slate 40 | primary: cyan 41 | accent: amber 42 | toggle: 43 | icon: material/brightness-auto 44 | name: Switch to system preference 45 | features: 46 | - navigation.instant 47 | - navigation.instant.progress 48 | - navigation.tracking 49 | - navigation.tabs 50 | - navigation.indexes 51 | - navigation.top 52 | - navigation.sections 53 | - navigation.footer 54 | - toc.follow 55 | - search.suggest 56 | - search.highlight 57 | - content.tabs.link 58 | - content.code.annotation 59 | - content.code.copy 60 | - content.tooltips 61 | - content.code.annotate 62 | - announce.dismiss 63 | 64 | # Customization 65 | extra: 66 | version: 67 | provider: mike 68 | default: 69 | - latest 70 | - dev 71 | social: 72 | - icon: fontawesome/brands/linkedin 73 | link: https://linkedin.com/in/manuel-da-pena 74 | - icon: fontawesome/brands/github 75 | link: https://github.com/mdapena 76 | analytics: 77 | provider: google 78 | property: !ENV GOOGLE_ANALYTICS_KEY 79 | feedback: 80 | title: Was this page helpful? 81 | ratings: 82 | - icon: material/thumb-up-outline 83 | name: This page was helpful 84 | data: 1 85 | note: >- 86 | Thanks for your feedback! 87 | - icon: material/thumb-down-outline 88 | name: This page could be improved 89 | data: 0 90 | note: >- 91 | Thanks for your feedback! 92 | 93 | # Plugins 94 | plugins: 95 | - search 96 | - git-committers: 97 | enabled: !ENV [ CI, false ] 98 | repository: mdapena/pyventus 99 | branch: master 100 | exclude: 101 | - api/* 102 | - learn/index.md 103 | - index.md 104 | - git-revision-date-localized: 105 | type: timeago 106 | enable_creation_date: true 107 | exclude: 108 | - api/* 109 | - learn/index.md 110 | - index.md 111 | - mkdocstrings: 112 | handlers: 113 | python: 114 | options: 115 | docstring_style: sphinx 116 | show_if_no_docstring: true 117 | inherited_members: true 118 | members_order: source 119 | separate_signature: true 120 | unwrap_annotated: true 121 | filters: ["!^(_.*$)", "^(__init__|__init_subclass__|__call__|__enter__|__exit__)$"] 122 | docstring_section_style: spacy 123 | signature_crossrefs: true 124 | show_symbol_type_heading: true 125 | show_symbol_type_toc: true 126 | show_signature_annotations: true 127 | show_category_heading: true 128 | - mike: 129 | alias_type: symlink 130 | canonical_version: latest 131 | - social: 132 | enabled: !ENV [ CI, false ] 133 | 134 | # Extensions 135 | markdown_extensions: 136 | - attr_list 137 | - md_in_html 138 | - pymdownx.arithmatex: 139 | generic: true 140 | - pymdownx.highlight: 141 | anchor_linenums: true 142 | - pymdownx.tasklist: 143 | custom_checkbox: true 144 | clickable_checkbox: true 145 | - pymdownx.inlinehilite 146 | - pymdownx.snippets: 147 | base_path: 148 | - !relative $docs_dir 149 | - admonition 150 | - footnotes 151 | - pymdownx.details 152 | - pymdownx.superfences 153 | - pymdownx.mark 154 | - abbr 155 | - pymdownx.emoji: 156 | emoji_index: !!python/name:material.extensions.emoji.twemoji 157 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 158 | - pymdownx.tabbed: 159 | alternate_style: true 160 | - toc: 161 | permalink: true 162 | 163 | # Extra Stylesheets 164 | extra_css: 165 | - stylesheets/announcement.css 166 | - stylesheets/search-bar.css 167 | 168 | # Extra JavaScript for MathJax 169 | extra_javascript: 170 | - javascripts/mathjax.js 171 | - javascripts/announcement.js 172 | - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js 173 | 174 | # Page tree 175 | nav: 176 | - Pyventus: index.md 177 | - Getting Started: getting-started.md 178 | - Learn: 179 | - learn/index.md 180 | - Upgrade Guide: learn/upgrade_guide.md 181 | - Events: 182 | - learn/events/index.md 183 | - Event Types: learn/events/types.md 184 | - Event Linkers: learn/events/linkers.md 185 | - Event Emitters: 186 | - learn/events/emitters/index.md 187 | - AsyncIO Event Emitter: learn/events/emitters/asyncio.md 188 | - Celery Event Emitter: learn/events/emitters/celery.md 189 | - Executor Event Emitter: learn/events/emitters/executor.md 190 | - FastAPI Event Emitter: learn/events/emitters/fastapi.md 191 | - Redis Event Emitter: learn/events/emitters/redis.md 192 | - Reactive: 193 | - learn/reactive/index.md 194 | - API Reference: 195 | - api/index.md 196 | - Core: 197 | - api/core/index.md 198 | - Collections: 199 | - MultiBidict: api/core/collections/multi_bidict.md 200 | - Exceptions: 201 | - PyventusException: api/core/exceptions/pyventus_exception.md 202 | - PyventusImportException: api/core/exceptions/pyventus_import_exception.md 203 | - Processing: 204 | - api/core/processing/index.md 205 | - AsyncIOProcessingService: api/core/processing/asyncio_processing_service.md 206 | - CeleryProcessingService: api/core/processing/celery_processing_service.md 207 | - ExecutorProcessingService: api/core/processing/executor_processing_service.md 208 | - FastAPIProcessingService: api/core/processing/fastapi_processing_service.md 209 | - RedisProcessingService: api/core/processing/redis_processing_service.md 210 | - Subscriptions: 211 | - SubscriptionContext: api/core/subscriptions/subscription_context.md 212 | - Subscription: api/core/subscriptions/subscription.md 213 | - Unsubscribable: api/core/subscriptions/unsubscribable.md 214 | - Events: 215 | - api/events/index.md 216 | - EventEmitter: 217 | - api/events/emitters/index.md 218 | - EventEmitter Utils: api/events/emitters/event_emitter_utils.md 219 | - EventHandler: api/events/handlers/event_handler.md 220 | - EventLinker: api/events/linkers/event_linker.md 221 | - EventSubscriber: api/events/subscribers/event_subscriber.md 222 | - Reactive: 223 | - api/reactive/index.md 224 | - Observable: 225 | - api/reactive/observables/index.md 226 | - ObservableTask: api/reactive/observables/observable_task.md 227 | - Observable Utils: api/reactive/observables/observable_utils.md 228 | - Observer: api/reactive/observers/observer.md 229 | - Subscriber: api/reactive/subscribers/subscriber.md 230 | - Contributing: contributing.md 231 | - Release Notes: release-notes.md 232 | -------------------------------------------------------------------------------- /src/pyventus/events/emitters/event_emitter_utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Generator 2 | from concurrent.futures import Executor, ThreadPoolExecutor 3 | from contextlib import contextmanager 4 | from typing import Any 5 | 6 | from ..linkers import EventLinker 7 | from .event_emitter import EventEmitter 8 | 9 | 10 | def AsyncIOEventEmitter( # noqa: N802 11 | event_linker: type[EventLinker] = EventLinker, 12 | debug: bool | None = None, 13 | ) -> EventEmitter: 14 | """ 15 | Create an `EventEmitter` instance configured with the `AsyncIOProcessingService`. 16 | 17 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 18 | corresponding subscribers. Defaults to `EventLinker`. 19 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 20 | execution environment. 21 | :return: An instance of `EventEmitter` configured with the `AsyncIOProcessingService`. 22 | """ 23 | from ...core.processing.asyncio import AsyncIOProcessingService 24 | 25 | processing_service = AsyncIOProcessingService() 26 | 27 | return EventEmitter( 28 | event_processor=processing_service, 29 | event_linker=event_linker, 30 | debug=debug, 31 | ) 32 | 33 | 34 | def CeleryEventEmitter( # noqa: N802 35 | celery: Any, 36 | queue: str | None = None, 37 | event_linker: type[EventLinker] = EventLinker, 38 | debug: bool | None = None, 39 | ) -> EventEmitter: 40 | """ 41 | Create an `EventEmitter` instance configured with the `CeleryProcessingService`. 42 | 43 | :param celery: The Celery object used to enqueue and process event emissions. 44 | :param queue: The name of the queue where the event emission will be enqueued. 45 | Defaults to None, which uses the task_default_queue from the Celery configuration. 46 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 47 | corresponding subscribers. Defaults to `EventLinker`. 48 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 49 | execution environment. 50 | :return: An instance of `EventEmitter` configured with the `CeleryProcessingService`. 51 | """ 52 | from ...core.processing.celery import CeleryProcessingService 53 | 54 | processing_service = CeleryProcessingService(celery=celery, queue=queue) 55 | 56 | return EventEmitter( 57 | event_processor=processing_service, 58 | event_linker=event_linker, 59 | debug=debug, 60 | ) 61 | 62 | 63 | def ExecutorEventEmitter( # noqa: N802 64 | executor: Executor, 65 | event_linker: type[EventLinker] = EventLinker, 66 | debug: bool | None = None, 67 | ) -> EventEmitter: 68 | """ 69 | Create an `EventEmitter` instance configured with the `ExecutorProcessingService`. 70 | 71 | :param executor: The executor object used to handle the execution of event emissions. 72 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 73 | corresponding subscribers. Defaults to `EventLinker`. 74 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 75 | execution environment. 76 | :return: An instance of `EventEmitter` configured with the `ExecutorProcessingService`. 77 | """ 78 | from ...core.processing.executor import ExecutorProcessingService 79 | 80 | processing_service = ExecutorProcessingService(executor=executor) 81 | 82 | return EventEmitter( 83 | event_processor=processing_service, 84 | event_linker=event_linker, 85 | debug=debug, 86 | ) 87 | 88 | 89 | @contextmanager 90 | def ExecutorEventEmitterCtx( # noqa: N802 91 | executor: Executor | None = None, 92 | event_linker: type[EventLinker] = EventLinker, 93 | debug: bool | None = None, 94 | ) -> Generator[EventEmitter, None, None]: 95 | """ 96 | Context manager that creates an `EventEmitter` instance configured with the `ExecutorProcessingService`. 97 | 98 | This context manager yields an `EventEmitter` instance, which can be used within a `with` statement. 99 | Upon exiting the context, the processing service is properly shut down. 100 | 101 | :param executor: The executor object used to handle the execution of event emissions. If `None`, 102 | a `ThreadPoolExecutor` with default settings will be created. 103 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 104 | corresponding subscribers. Defaults to `EventLinker`. 105 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 106 | execution environment. 107 | :return: An instance of `EventEmitter` configured with the `ExecutorProcessingService`. 108 | """ 109 | from ...core.processing.executor import ExecutorProcessingService 110 | 111 | processing_service = ExecutorProcessingService(executor=(executor if executor else ThreadPoolExecutor())) 112 | 113 | yield EventEmitter( 114 | event_processor=processing_service, 115 | event_linker=event_linker, 116 | debug=debug, 117 | ) 118 | 119 | processing_service.shutdown() 120 | 121 | 122 | def FastAPIEventEmitter( # noqa: N802 123 | event_linker: type[EventLinker] = EventLinker, 124 | debug: bool | None = None, 125 | ) -> Callable[[Any], EventEmitter]: 126 | """ 127 | Create an `EventEmitter` instance configured with the `FastAPIProcessingService`. 128 | 129 | This function is compatible with FastAPI's dependency injection system and should be 130 | used with the `Depends` method to automatically provide the `BackgroundTasks` instance. 131 | 132 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 133 | corresponding subscribers. Defaults to `EventLinker`. 134 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 135 | execution environment. 136 | :return: An instance of `EventEmitter` configured with the `FastAPIProcessingService`. 137 | """ 138 | from fastapi import BackgroundTasks 139 | 140 | from ...core.processing.fastapi import FastAPIProcessingService 141 | 142 | def create_event_emitter(background_tasks: BackgroundTasks) -> EventEmitter: 143 | """ 144 | Create and return an `EventEmitter` instance using the provided `BackgroundTasks`. 145 | 146 | :param background_tasks: The FastAPI `BackgroundTasks` object used to handle the execution of event emissions. 147 | :return: An instance of `EventEmitter` configured with the `FastAPIProcessingService`. 148 | """ 149 | processing_service = FastAPIProcessingService(background_tasks=background_tasks) 150 | 151 | return EventEmitter( 152 | event_processor=processing_service, 153 | event_linker=event_linker, 154 | debug=debug, 155 | ) 156 | 157 | return create_event_emitter 158 | 159 | 160 | def RedisEventEmitter( # noqa: N802 161 | queue: Any, 162 | options: dict[str, Any] | None = None, 163 | event_linker: type[EventLinker] = EventLinker, 164 | debug: bool | None = None, 165 | ) -> EventEmitter: 166 | """ 167 | Create an `EventEmitter` instance configured with the `RedisProcessingService`. 168 | 169 | :param queue: The Redis queue object used to enqueue and process event emissions. 170 | :param options: Additional options for the RQ package enqueueing method. Defaults to None (an empty dictionary). 171 | :param event_linker: Specifies the type of event linker used to manage and access events along with their 172 | corresponding subscribers. Defaults to `EventLinker`. 173 | :param debug: Specifies the debug mode for the logger. If `None`, it is determined based on the 174 | execution environment. 175 | :return: An instance of `EventEmitter` configured with the `RedisProcessingService`. 176 | """ 177 | from ...core.processing.redis import RedisProcessingService 178 | 179 | processing_service = RedisProcessingService(queue=queue, options=options) 180 | 181 | return EventEmitter( 182 | event_processor=processing_service, 183 | event_linker=event_linker, 184 | debug=debug, 185 | ) 186 | -------------------------------------------------------------------------------- /tests/pyventus/events/emitters/test_event_emitter_utils.py: -------------------------------------------------------------------------------- 1 | from pyventus.events import ( 2 | AsyncIOEventEmitter, 3 | CeleryEventEmitter, 4 | EventEmitter, 5 | EventLinker, 6 | ExecutorEventEmitter, 7 | ExecutorEventEmitterCtx, 8 | FastAPIEventEmitter, 9 | ) 10 | 11 | from ....utils import get_private_attr 12 | 13 | 14 | class TestEventEmitterUtils: 15 | # ================================= 16 | # Test Cases for AsyncIOEventEmitter 17 | # ================================= 18 | 19 | def test_AsyncIOEventEmitter(self) -> None: # noqa: N802 20 | from pyventus.core.processing.asyncio import AsyncIOProcessingService 21 | 22 | # Arrange 23 | class IsolatedEventLinker(EventLinker): ... 24 | 25 | # Act 26 | event_emitter = AsyncIOEventEmitter(event_linker=IsolatedEventLinker, debug=True) 27 | event_processor = get_private_attr(event_emitter, "__event_processor") 28 | event_linker = get_private_attr(event_emitter, "__event_linker") 29 | logger = get_private_attr(event_emitter, "__logger") 30 | 31 | # Assert creation and properties 32 | assert event_emitter is not None 33 | assert isinstance(event_emitter, EventEmitter) 34 | assert isinstance(event_processor, AsyncIOProcessingService) 35 | assert event_linker is IsolatedEventLinker 36 | assert logger.debug_enabled is True 37 | 38 | # ================================= 39 | # Test Cases for CeleryEventEmitter 40 | # ================================= 41 | 42 | def test_CeleryEventEmitter(self) -> None: # noqa: N802 43 | from celery import Celery 44 | from pyventus.core.processing.celery import CeleryProcessingService 45 | 46 | # Arrange 47 | class IsolatedEventLinker(EventLinker): ... 48 | 49 | celery = Celery() 50 | queue = "Queue" 51 | 52 | # Act 53 | event_emitter = CeleryEventEmitter(celery=celery, queue=queue, event_linker=IsolatedEventLinker, debug=True) 54 | event_processor = get_private_attr(event_emitter, "__event_processor") 55 | event_linker = get_private_attr(event_emitter, "__event_linker") 56 | logger = get_private_attr(event_emitter, "__logger") 57 | 58 | # Assert creation and properties 59 | assert event_emitter is not None 60 | assert isinstance(event_emitter, EventEmitter) 61 | assert isinstance(event_processor, CeleryProcessingService) 62 | assert get_private_attr(event_processor, "__celery") is celery 63 | assert get_private_attr(event_processor, "__queue") is queue 64 | assert event_linker is IsolatedEventLinker 65 | assert logger.debug_enabled is True 66 | 67 | # ================================= 68 | # Test Cases for ExecutorEventEmitter 69 | # ================================= 70 | 71 | def test_ExecutorEventEmitter(self) -> None: # noqa: N802 72 | from concurrent.futures import ThreadPoolExecutor 73 | 74 | from pyventus.core.processing.executor import ExecutorProcessingService 75 | 76 | # Arrange 77 | class IsolatedEventLinker(EventLinker): ... 78 | 79 | executor = ThreadPoolExecutor() 80 | 81 | # Act 82 | event_emitter = ExecutorEventEmitter(executor=executor, event_linker=IsolatedEventLinker, debug=True) 83 | event_processor = get_private_attr(event_emitter, "__event_processor") 84 | event_linker = get_private_attr(event_emitter, "__event_linker") 85 | logger = get_private_attr(event_emitter, "__logger") 86 | 87 | # Assert creation and properties 88 | assert event_emitter is not None 89 | assert isinstance(event_emitter, EventEmitter) 90 | assert isinstance(event_processor, ExecutorProcessingService) 91 | assert get_private_attr(event_processor, "__executor") is executor 92 | assert event_linker is IsolatedEventLinker 93 | assert logger.debug_enabled is True 94 | assert executor._shutdown is False 95 | 96 | # ================================= 97 | # Test Cases for ExecutorEventEmitterCtx 98 | # ================================= 99 | 100 | def test_ExecutorEventEmitterCtx(self) -> None: # noqa: N802 101 | from concurrent.futures import ThreadPoolExecutor 102 | 103 | from pyventus.core.processing.executor import ExecutorProcessingService 104 | 105 | # Arrange 106 | class IsolatedEventLinker(EventLinker): ... 107 | 108 | executor = ThreadPoolExecutor() 109 | 110 | # Act 111 | with ExecutorEventEmitterCtx(executor=executor, event_linker=IsolatedEventLinker, debug=True) as event_emitter: 112 | event_processor = get_private_attr(event_emitter, "__event_processor") 113 | event_linker = get_private_attr(event_emitter, "__event_linker") 114 | logger = get_private_attr(event_emitter, "__logger") 115 | 116 | # Assert creation and properties 117 | assert event_emitter is not None 118 | assert isinstance(event_emitter, EventEmitter) 119 | assert isinstance(event_processor, ExecutorProcessingService) 120 | assert get_private_attr(event_processor, "__executor") is executor 121 | assert event_linker is IsolatedEventLinker 122 | assert logger.debug_enabled is True 123 | assert executor._shutdown is True 124 | 125 | # ================================= 126 | # Test Cases for FastAPIEventEmitter 127 | # ================================= 128 | 129 | def test_FastAPIEventEmitter(self) -> None: # noqa: N802 130 | from fastapi import BackgroundTasks, Depends, FastAPI 131 | from fastapi.testclient import TestClient 132 | from pyventus.core.processing.fastapi import FastAPIProcessingService 133 | from starlette.status import HTTP_200_OK 134 | 135 | # Arrange 136 | class IsolatedEventLinker(EventLinker): ... 137 | 138 | client = TestClient(FastAPI()) 139 | 140 | # Act 141 | @client.app.get("/") # type: ignore[attr-defined, misc] 142 | def api( 143 | background_tasks: BackgroundTasks, 144 | event_emitter: EventEmitter = Depends(FastAPIEventEmitter(IsolatedEventLinker, True)), # noqa: B008 145 | ) -> None: 146 | event_processor = get_private_attr(event_emitter, "__event_processor") 147 | event_linker = get_private_attr(event_emitter, "__event_linker") 148 | logger = get_private_attr(event_emitter, "__logger") 149 | 150 | # Assert creation and properties 151 | assert event_emitter is not None 152 | assert isinstance(event_emitter, EventEmitter) 153 | assert isinstance(event_processor, FastAPIProcessingService) 154 | assert get_private_attr(event_processor, "__background_tasks") is background_tasks 155 | assert event_linker is IsolatedEventLinker 156 | assert logger.debug_enabled is True 157 | 158 | assert client.get("/").status_code == HTTP_200_OK 159 | 160 | # ================================= 161 | # Test Cases for RedisEventEmitter 162 | # ================================= 163 | 164 | def test_RedisEventEmitter(self) -> None: # noqa: N802 165 | from fakeredis import FakeStrictRedis 166 | from pyventus.core.processing.redis import RedisProcessingService 167 | from pyventus.events import RedisEventEmitter 168 | from rq import Queue 169 | 170 | # Arrange 171 | class IsolatedEventLinker(EventLinker): ... 172 | 173 | queue = Queue(connection=FakeStrictRedis(), is_async=False) 174 | options = {"ttl": 1} 175 | 176 | # Act 177 | event_emitter = RedisEventEmitter(queue=queue, options=options, event_linker=IsolatedEventLinker, debug=True) 178 | event_processor = get_private_attr(event_emitter, "__event_processor") 179 | event_linker = get_private_attr(event_emitter, "__event_linker") 180 | logger = get_private_attr(event_emitter, "__logger") 181 | 182 | # Assert creation and properties 183 | assert event_emitter is not None 184 | assert isinstance(event_emitter, EventEmitter) 185 | assert isinstance(event_processor, RedisProcessingService) 186 | assert get_private_attr(event_processor, "__queue") is queue 187 | assert get_private_attr(event_processor, "__options") is options 188 | assert event_linker is IsolatedEventLinker 189 | assert logger.debug_enabled is True 190 | --------------------------------------------------------------------------------