diff --git a/.editorconfig b/.editorconfig index 9f1aee4..89f0576 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,8 +9,8 @@ insert_final_newline = true trim_trailing_whitespace = true max_line_length = 99 -[*.md] -max_line_length = 150 +[*.{md,rst}] +max_line_length = 999 [*.{yml,yaml,json}] indent_size = 2 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fdbf5a8 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,116 @@ +# Code of Conduct - aiogram-broadcaster + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, +threatening, offensive, or harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will +communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at <61621244+loRes228@users.noreply.github.com>. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version +[1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and +[2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), +and was generated by [contributing-gen](https://github.com/bttger/contributing-gen). \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1024331 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://donatello.to/lores diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..153bffa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: Bug report +description: Report issues affecting the framework or the documentation. +labels: + - bug + +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: I am sure the error is coming from aiogram_broadcaster code + required: true + - label: >- + I have searched in the issue tracker for similar bug reports, + including closed ones + required: true + + - type: markdown + attributes: + value: > + ## Context + + + Please provide as much information as possible. This will help us to + reproduce the issue and fix it. + + - type: input + attributes: + label: Operating system + placeholder: e.g. Ubuntu 20.04.2 LTS + validations: + required: true + + - type: input + attributes: + label: Python version + description: (`python --version` inside your virtualenv) + placeholder: e.g. 3.10.1 + validations: + required: true + + - type: input + attributes: + label: aiogram_broadcaster version + description: (`pip show aiogram_broadcaster` inside your virtualenv) + placeholder: e.g. 0.4.7 or 0.5.0 + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: Please describe the behavior you are expecting. + placeholder: 'E.g. the bot should send a message with the text "Hello, world!".' + validations: + required: true + + - type: textarea + attributes: + label: Current behavior + description: Please describe the behavior you are currently experiencing. + placeholder: E.g. the bot doesn't send any message. + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: Please describe the steps you took to reproduce the behavior. + placeholder: | + 1. step 1 + 2. step 2 + 3. ... + 4. you get it... + validations: + required: true + + - type: textarea + attributes: + label: Code example + description: >- + Provide a [minimal, + reproducible](https://stackoverflow.com/help/minimal-reproducible-example) + and properly formatted example (if applicable). + placeholder: | + from aiogram_broadcaster import Bot, Dispatcher + ... + render: python3 + + - type: textarea + attributes: + label: Logs + description: Provide the complete traceback (if applicable) or other kind of logs. + placeholder: | + Traceback (most recent call last): + File "main.py", line 1, in + ... + SomeException: ... + render: sh + + - type: textarea + attributes: + label: Additional information + description: >- + Please provide any additional information that may help us to reproduce + the issue. + placeholder: | + E.g. this behavior is reproducible only in group chats. + + You can also attach additional screenshots, logs, or other files. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e769e96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: true + +contact_links: + - name: Discuss anything related to the library + url: https://github.com/loRes228/aiogram_broadcaster/discussions + about: Ask a question about aiogram-broadcaster or share your code snippets and ideas. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a69d232 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,52 @@ +name: Feature request +description: Report features you would like to see or improve in the framework. +labels: + - enhancement + +body: + - type: textarea + attributes: + label: aiogram_broadcaster version + description: (`pip show aiogram_broadcaster` inside your virtualenv) + validations: + required: true + + - type: textarea + attributes: + label: Problem + description: Is your feature request related to a specific problem? If not, please describe the general idea of your request. + placeholder: e.g. I want to send a photo to a user by url. + validations: + required: true + + - type: textarea + attributes: + label: Possible solution + description: Describe the solution you would like to see in the framework. + placeholder: e.g. Add a method to send a photo to a user by url. + validations: + required: true + + - type: textarea + attributes: + label: Alternatives + description: What other solutions do you have in mind? + placeholder: e.g. I'm sending a text message with photo url. + + - type: textarea + attributes: + label: Code example + description: A small code example that demonstrates the behavior you would like to see. + placeholder: | + await broadcaster.create_mailer(content, chat_ids) + ... + render: python3 + + - type: textarea + attributes: + label: Additional information + description: Any additional information you would like to provide. + placeholder: | + E.g. this method should also cache images to speed up further sending. + + You can also attach additional pictures or other files. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0ced878 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,40 @@ +# Description + +Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this +change. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +# How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + +- [ ] Test A +- [ ] Test B + +**Test Configuration**: + +* Firmware version: +* Hardware: +* Toolchain: +* SDK: + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..154d2fa --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,10 @@ +# **Reporting Security Issues** + +The project's team and community take security issues. + +We appreciate your efforts to disclose your findings responsibly and will make every effort to acknowledge your contributions. + +If your report could leak data or might expose how to gain access to a restricted area or break the system, please +send a message to my telegram - https://t.me/lores1337. + +We'll endeavour to respond quickly and keep you updated throughout the process. diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 939d1e7..785f1a6 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.9" cache: pip cache-dependency-path: pyproject.toml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59b7168..b9ebf35 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,6 @@ jobs: - macos-latest - windows-latest python-version: - - "3.8" - "3.9" - "3.10" - "3.11" @@ -44,13 +43,13 @@ jobs: cache-dependency-path: pyproject.toml - name: Install dependencies - run: python -m pip install --upgrade .[dev,test,redis,mongo,sqlalchemy] build pip aiogram + run: python -m pip install --upgrade ".[all]" build pip - name: Lint code run: make lint - - name: Run tests - run: make test + #- name: Run tests + # run: make test - name: Check installable run: | diff --git a/.gitignore b/.gitignore index af41e76..a3966ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ .venv/ -.tests/ +.dev/ .idea/ .vscode/ .cache/ -.codegen/ dist/ __pycache__/ .python-version diff --git a/Makefile b/Makefile index 44e5335..8fc11ff 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ PACKAGE_DIRECTORY = aiogram_broadcaster -EXAMPLES_DIRECTORY = examples TESTS_DIRECTORY = tests -CODE_DIRECTORIES = ${PACKAGE_DIRECTORY} ${TESTS_DIRECTORY} ${EXAMPLES_DIRECTORY} +EXAMPLES_DIRECTORY = examples +BUTCHER_DIRECTORY = butcher CACHE_DIRECTORY = .cache -REPORTS_DIRECTORY = ${CACHE_DIRECTORY}/reports +CODE_DIRECTORIES = ${PACKAGE_DIRECTORY} ${TESTS_DIRECTORY} ${EXAMPLES_DIRECTORY} ${BUTCHER_DIRECTORY} all: lint @@ -32,7 +32,7 @@ lint: .PHONY: format format: ruff format ${CODE_DIRECTORIES} - ruff check --fix ${PACKAGE_DIRECTORY} + ruff check --fix --unsafe-fixes ${PACKAGE_DIRECTORY} # ===================================== @@ -43,13 +43,16 @@ format: test: pytest -.PHONY: test-report -test-report: - pytest --cov ${PACKAGE_DIRECTORY} --html "${REPORTS_DIRECTORY}/tests/index.html" - coverage html --directory "${REPORTS_DIRECTORY}/coverage" -.PHONY: test-report-view -test-report-view: - -$(MAKE) test-report - python -m webbrowser "${CURDIR}/${REPORTS_DIRECTORY}/tests/index.html" - python -m webbrowser "${CURDIR}/${REPORTS_DIRECTORY}/coverage/index.html" +# ===================================== +# Project +# ===================================== + +.PHONY: butcher +butcher: + python -m butcher + $(MAKE) format + +.PHONY: release +release: + git tag "v$(shell hatch version)" diff --git a/README.md b/README.md index 7ef91bf..c3732bb 100644 --- a/README.md +++ b/README.md @@ -5,459 +5,26 @@ [![PyPI - Status](https://img.shields.io/pypi/status/aiogram-broadcaster?style=plastic&logo=pypi)](https://pypi.org/project/aiogram-broadcaster/) [![PyPI - Version](https://img.shields.io/pypi/v/aiogram-broadcaster?style=plastic&color=blue&logo=pypi)](https://pypi.org/project/aiogram-broadcaster/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/aiogram-broadcaster?style=plastic&color=green&logo=pypi)](https://pypi.org/project/aiogram-broadcaster/) -[![PyPI - Python Version](https://img.shields.io/badge/python-3.8%2B-blue?style=plastic&logo=python)](https://www.python.org/downloads/) -[![Static Badge](https://img.shields.io/badge/aiogram-3.6%2B-blue?style=plastic&logoColor=blue&link=https%3A%2F%2Fwww.python.org%2Fdownloads%2F&logo=pypi)](https://aiogram.dev) +[![PyPI - Python Version](https://img.shields.io/badge/python-3.9%2B-blue?style=plastic&logo=python)](https://www.python.org/downloads/) +[![Static Badge](https://img.shields.io/badge/aiogram-3.10%2B-blue?style=plastic&logoColor=blue&link=https%3A%2F%2Fwww.python.org%2Fdownloads%2F&logo=pypi)](https://aiogram.dev) +[![Static Badge](https://img.shields.io/badge/wiki-gray?style=plastic&logo=github)](https://github.com/loRes228/aiogram_broadcaster/wiki) ### **aiogram_broadcaster** is lightweight aiogram-based library for broadcasting Telegram messages. -## Features +### Features -* #### [Event system](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#event-system-1) -* #### [Placeholders](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#placeholders) (variables in texts) -* #### Flexible contents ([LazyContent](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#lazy-content), [KeyBasedContent](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#key-based-content)) -* #### [Storages](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#storages-1) -* #### [Statistics](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#statistic-mailerstatistic-instance-containing-statistics-about-the-mailers-performance) -* #### [Statuses](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#status-current-status-of-the-mailer-eg-started-stopped-completed) -* #### Supports [multiple mailings](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#mailer) -* #### Supports [multibot](https://github.com/loRes228/aiogram_broadcaster?tab=readme-ov-file#multibot) +* #### Event system allows handle events throughout the broadcast process. +* #### Placeholder system allows dynamic content insertion in broadcast messages. +* #### Storages allows manage and persist mailing data. +* #### Flexable contents to be broadcasting. +* #### Has different types of intervals, for long or fast mailing. +* #### Statistics to monitor the mailing process. +* #### Support of multiple parallel mailings. +* #### Supports multibot for broadcasting from multiple bots. +* #### Status of the mailer. +* #### Support of logging. +* #### Has type hints and can be used with type checking tools. -## Installation +
-* #### From PyPI - -```commandline -pip install --upgrade aiogram-broadcaster -``` - -* #### From GitHub (_Development build_) - -```commandline -pip install https://github.com/loRes228/aiogram_broadcaster/archive/refs/heads/dev.zip --fore-reinstall -``` - -## Creating a mailer and running broadcasting - -#### How to create a mailer and initiate broadcasting. - -#### Usage: - -```python -import logging -import sys -from typing import Any - -from aiogram import Bot, Dispatcher, Router -from aiogram.types import Message - -from aiogram_broadcaster import Broadcaster -from aiogram_broadcaster.contents import MessageSendContent -from aiogram_broadcaster.storages.file import FileMailerStorage - -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} - -router = Router(name=__name__) - - -@router.message() -async def process_any_message(message: Message, broadcaster: Broadcaster) -> Any: - # Creating content based on the Message - content = MessageSendContent(message=message) - - mailer = await broadcaster.create_mailer( - content=content, - chats=USER_IDS, - interval=1, - preserve=True, - destroy_on_complete=True, - ) - - # The mailer launch method starts mailing to chats as an asyncio task. - mailer.start() - - await message.reply(text="Run broadcasting...") - - -def main() -> None: - logging.basicConfig(level=logging.INFO, stream=sys.stdout) - - bot = Bot(token=TOKEN) - dispatcher = Dispatcher() - dispatcher.include_router(router) - - storage = FileMailerStorage() - broadcaster = Broadcaster(bot, storage=storage) - broadcaster.setup(dispatcher=dispatcher) - - dispatcher.run_polling(bot) - - -if __name__ == "__main__": - main() -``` - -## Mailer - -#### The [Mailer](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/mailer/mailer.py) class facilitates the broadcasting of messages to multiple chats in Telegram. It manages the lifecycle of the broadcast process, including starting, stopping, and destroying the broadcast. - -#### Properties - -* #### id: Unique identifier for the mailer. -* #### status: Current [mailer status](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/mailer/status.py) of the mailer (e.g., STARTED, STOPPED, COMPLETED). -* #### settings: Configuration settings for the mailer. -* #### statistic: Statistic instance containing statistics about the mailer's performance. -* #### content: Content to be broadcast. -* #### context: Additional context data used during the broadcasting process. -* #### bot: aiogram Bot instance used for interacting with the Telegram API. - -#### Methods - -* #### send(chat_id: int) -> Any: Sends the content to a specific chat identified by chat_id. -* #### add_chats(chats: Iterable[int]) -> Set[int]: Adds new chats to the mailer's registry. -* #### reset_chats() -> bool: Resets the state of all chats. -* #### destroy() -> None: Destroys the mailer instance and cleans up resources. -* #### stop() -> None: Stops the broadcasting process. -* #### run() -> bool: Initiates the broadcasting process. -* #### start() -> None: Starts the broadcasting process in background. -* #### wait() -> None: Waits for the broadcasting process to complete. - -#### Usage: - -```python -mailer = await broadcaster.create_mailer(content=..., chats=...) -try: - logging.info("Mailer starting...") - await mailer.run() -finally: - logging.info("Mailer shutdown...") - await mailer.destroy() -``` - -## Multibot - -#### When using a multibot, it may be necessary to launch many mailings in several bots. For this case, there is a [MailerGroup](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/mailer/group.py) object that stores several mailers and can manage them. - -#### Usage: - -```python -from aiogram import Bot - -from aiogram_broadcaster import Broadcaster - -# List of bots -bots = [Bot(token="1234:Abc"), Bot(token="5678:Vbn")] - -broadcaster = Broadcaster() - -# Creating a group of mailers based on several bots -mailer_group = await broadcaster.create_mailers( - *bots, - content=..., - chats=..., -) - -# Run all mailers in the mailer group -await mailer_group.run() -``` - -## Event system - -#### The event system empowers you to effectively manage events throughout the broadcast process. - -> [!NOTE] -> `EventRegistry` supports chained nesting, similar to aiogram [Router](https://docs.aiogram.dev/en/latest/dispatcher/router.html#nested-routers). - -#### Usage: - -```python -from aigoram_broadcaster import EventRegistry - -event = EventRegistry(name=__name__) - - -# Define event handlers - - -@event.started() -async def mailer_started() -> None: - """Triggered when the mailer begins its operations.""" - - -@event.stopped() -async def mailer_stopped() -> None: - """Triggered when the mailer stops its operations.""" - - -@event.completed() -async def mailer_completed() -> None: - """Triggered when the mailer successfully completes its operations.""" - - -@event.before_sent() -async def mail_before_sent() -> None: - """ - Triggered before sending content. - - Exclusive parameters for this type of event. - chat_id (int): ID of the chat. - """ - - -@event.failed_sent() -async def mail_failed_sent() -> None: - """ - Triggered when a content fails to send. - - Exclusive parameters for this type of event. - chat_id (int): ID of the chat. - error (Exception): Exception raised during sending. - """ - - -@event.success_sent() -async def mail_successful_sent() -> None: - """ - Triggered when a mail is successfully sent. - - Exclusive parameters for this type of event: - chat_id (int): ID of the chat. - response (Any): Response from the sent mail. - """ - - -# Include the event instance in the broadcaster -broadcaster.event.bind(event) -``` - -## Placeholders - -#### Placeholders facilitate the insertion of dynamic content within texts, this feature allows for personalized messaging. - -> [!NOTE] -> `PlaceholderRegistry` supports chained nesting, similar to -> aiogram [Router](https://docs.aiogram.dev/en/latest/dispatcher/router.html#nested-routers). - -#### Usage: - -* #### Function-based - -```python -from aiogram_broadcaster import PlaceholderRegistry - -placeholder = PlaceholderRegistry(name=__name__) - - -@placeholder(key="name") -async def get_username(chat_id: int, bot: Bot) -> str: - """Retrieves the username using the Telegram Bot API.""" - member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) - return member.user.first_name - - -broadcaster.placeholder.bind(placeholder) -``` - -* #### Class-based - -```python -from aiogram_broadcaster import PlaceholderItem - - -class NamePlaceholder(PlaceholderItem, key="name"): - async def __call__(self, chat_id: int, bot: Bot) -> str: - member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) - return member.user.first_name - - -broadcaster.placeholder.register(NamePlaceholder()) -``` - -* #### Other registration methods - -```python -placeholder["name"] = function -placeholder.add({"key": "value"}, name=function) -``` - -### And then - -```python -text_content = TextContent(text="Hello, $name!") -photo_content = PhotoContent(photo=..., caption="Photo especially for $name!") -``` - -## Key-based content - -#### This module provides utilities to create personalized content targeted to specific users or groups based on their language preferences or geographical location, etc. - -> [!NOTE] -> If the default key is not specified, an error will be given if the key is not found. - -#### Usage: - -```python -from aiogram.exceptions import TelegramBadRequest - -from aiogram_broadcaster.contents import KeyBasedContent, TextContent - - -class LanguageBasedContent(KeyBasedContent): - """Content based on the user's language.""" - - async def __call__(self, chat_id: int, bot: Bot) -> Optional[str]: - try: - member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) - except TelegramBadRequest: - return None - else: - return member.user.language_code - - -content = LanguageBasedContent( - # default=TextContent(text="Hello!"), - uk=TextContent(text="Привіт!"), - ru=TextContent(text="Привет!"), -) -``` - -## Lazy content - -#### Allows content to be generated dynamically at the time the message is sent. - -#### Usage: - -```python -from secrets import choice -from typing import List - -from pydantic import SerializeAsAny - -from aiogram_broadcaster.contents import BaseContent, LazyContent, TextContent - - -class RandomizedContent(LazyContent): - contents: List[SerializeAsAny[BaseContent]] - - async def __call__(self) -> BaseContent: - return choice(self.contents) - - -content = RandomizedContent( - contents=[ - TextContent(text="Hello!"), - TextContent(text="Hi!"), - ], -) -await broadcaster.create_mailer(content=content, chats=...) -``` - -## Dependency injection - -#### It is used for comprehensive dependency management, used in event system, key-based/lazy content, placeholders and so on. - -#### Usage: - -* #### Main contextual data - -```python -from aiogram_broadcaster import Broadcaster - -broadcaster = Broadcaster(key="value") -``` - -* #### Fetching the dispatcher contextual data - -```python -from aiogram import Dispatcher - -from aiogram_broadcaster import Broadcaster - -dispatcher = Dispatcher() -dispatcher["key"] = "value" - -broadcaster = Broadcaster() -broadcaster.setup(dispatcher, fetch_dispatcher_context=True) -``` - -* #### Contextual data only for mailer - -```python -await broadcaster.create_mailer(content=..., chats=..., key=value) -``` - -* #### Stored contextual data only for mailer - -```python -await broadcaster.create_mailer(content=..., chats=..., stored_context={"key": "value"}) -``` - -* #### Event-to-event - -```python -@event.completed() -async def transfer_content() -> Dict[str, Any]: - return {"my_data": 1} - - -@event.completed() -async def mailer_completed(my_data: 1) -> None: - print(my_data) -``` - -## Storages - -#### Storage allow you to save mailer states to external storage. - -* #### [BaseMailerStorage](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/storages/base.py) Abstract class of storage. -* #### [FileMailerStorage](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/storages/file.py) Saves the mailers to a file. -* #### [MongoDBMailerStorage](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/storages/mongodb.py) Saves the mailers to a MongoDB. (Extra: mongo) -* #### [RedisMailerStorage](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/storages/redis.py) Saves the mailers to a Redis. (Extra: redis) -* #### [SQLAlchemyMailerStorage](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/storages/sqlalchemy.py) Saves the mailers using SQLAlchemy. (Extra: sqlalchemy) - -#### Usage: - -```python -from aiogram_broadcaster import Broadcaster -from aiogram_broadcaster.storages.redis import RedisMailerStorage - -# from aiogram_broadcaster.storages.file import FileMailerStorage -# from aiogram_broadcaster.storages.mongodb import MongoDBMailerStorage -# from aiogram_broadcaster.storages.sqlalchemy import SQLAlchemyMailerStorage - -# storages = FileMailerStorage() -# storages = MongoDBMailerStorage.from_url(url="mongodb://localhost:27017") -# storages = SQLAlchemyMailerStorage.from_url(url="sqlite+aiosqlite:///database.db") - -storage = RedisMailerStorage.from_url(url="redis://localhost:6379") -broadcaster = Broadcaster(storage=storage) -``` - -## Default mailer settings - -#### The [DefaultMailerSettings](https://github.com/loRes228/aiogram_broadcaster/blob/main/aiogram_broadcaster/mailer/settings.py) class defines the default properties for mailers created within the broadcaster. It allows setting various parameters like interval, dynamic_interval, run_on_startup, handle_retry_after, destroy_on_complete, and preserve. These properties provide flexibility and control over the behavior of mailers. - -#### Parameters: - -* #### interval: The interval (in seconds) between successive message broadcasts. It defaults to 0, indicating immediate broadcasting. -* #### dynamic_interval: A boolean flag indicating whether the interval should be adjusted dynamically based on the number of chats. If set to True, the interval will be divided equally among the chats. -* #### run_on_startup: A boolean flag indicating whether the mailer should start broadcasting messages automatically on bot startup. -* #### handle_retry_after: A boolean flag indicating whether the mailer should handle the TelegramAPIError error automatically. -* #### destroy_on_complete: A boolean flag indicating whether the mailer should be destroyed automatically upon completing its operations. -* #### preserve: A boolean flag indicating whether the mailer's state should be preserved even after completion. If set to True, the mailer's state will be stored in the specified storage. - -#### Usage: - -```python -from aiogram_broadcaster import Broadcaster -from aiogram_broadcaster.mailer import DefaultMailerSettings - -default = DefaultMailerSettings( - interval=60_000, - dynamic_interval=True, - run_on_startup=True, - handle_retry_after=True, - destroy_on_complete=True, - preserve=True, -) -broadcaster = Broadcaster(default=default) -``` +#### See more in the documentation: https://github.com/loRes228/aiogram_broadcaster/wiki diff --git a/aiogram_broadcaster/__init__.py b/aiogram_broadcaster/__init__.py index 79418c2..1fce6f4 100644 --- a/aiogram_broadcaster/__init__.py +++ b/aiogram_broadcaster/__init__.py @@ -1,16 +1,18 @@ __all__ = ( "Broadcaster", - "DefaultMailerSettings", - "EventRegistry", + "Event", "Mailer", "MailerStatus", - "PlaceholderItem", - "PlaceholderRegistry", + "Placeholder", "__version__", + "contents", + "intervals", ) + +from . import contents, intervals from .__meta__ import __version__ from .broadcaster import Broadcaster -from .event import EventRegistry -from .mailer import DefaultMailerSettings, Mailer, MailerStatus -from .placeholder import PlaceholderItem, PlaceholderRegistry +from .event import Event +from .mailer import Mailer, MailerStatus +from .placeholder import Placeholder diff --git a/aiogram_broadcaster/__meta__.py b/aiogram_broadcaster/__meta__.py index 3d18726..364e7ba 100644 --- a/aiogram_broadcaster/__meta__.py +++ b/aiogram_broadcaster/__meta__.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.6.4" diff --git a/aiogram_broadcaster/broadcaster.py b/aiogram_broadcaster/broadcaster.py index 97995c7..21e1dc1 100644 --- a/aiogram_broadcaster/broadcaster.py +++ b/aiogram_broadcaster/broadcaster.py @@ -1,95 +1,62 @@ -from typing import Any, Dict, Iterable, Literal, Optional, Set, Tuple, Union, cast -from uuid import uuid4 +from collections.abc import Iterable +from typing import Any, Optional -from aiogram import Bot, Dispatcher +from aiogram import Bot, Dispatcher, F +from magic_filter import MagicFilter from pydantic import JsonValue -from pydantic_core import PydanticSerializationError, ValidationError from typing_extensions import Self -from .contents.base import BaseContent, ContentType +from aiogram_broadcaster.utils.logger import logger + +from .contents.base import ContentType from .event.manager import EventManager -from .mailer.chat_engine import ChatsRegistry +from .intervals.base import BaseInterval from .mailer.container import MailerContainer from .mailer.group import MailerGroup from .mailer.mailer import Mailer -from .mailer.settings import DefaultMailerSettings, MailerSettings from .mailer.status import MailerStatus from .placeholder.manager import PlaceholderManager -from .storages.base import BaseMailerStorage, StorageRecord -from .utils import loggers +from .storages.base import BaseStorage class Broadcaster(MailerContainer): - bots: Tuple[Bot, ...] - storage: Optional[BaseMailerStorage] - default: DefaultMailerSettings - context_key: str - context: Dict[str, Any] - event: EventManager - placeholder: PlaceholderManager - def __init__( self, *bots: Bot, - storage: Optional[BaseMailerStorage] = None, - default: Optional[DefaultMailerSettings] = None, - context_key: str = "broadcaster", + storage: Optional[BaseStorage] = None, **context: Any, ) -> None: super().__init__() self.bots = bots self.storage = storage - self.default = default or DefaultMailerSettings() - self.context_key = context_key self.context = context - self.context.update( - {self.context_key: self}, - bots=self.bots, - ) + self.context["bots"] = self.bots self.event = EventManager(name="root") self.placeholder = PlaceholderManager(name="root") - def as_group(self) -> MailerGroup: - if not self._mailers: - raise RuntimeError("No mailers for grouping.") - return MailerGroup(*self) + def get_mailers(self, magic: Optional[MagicFilter] = None) -> MailerGroup: + mailers = list(filter(magic.resolve, self)) if magic else list(self) + return MailerGroup(*mailers) async def create_mailers( self, *bots: Bot, - content: BaseContent, chats: Iterable[int], - interval: Optional[float] = None, - dynamic_interval: Optional[bool] = None, - run_on_startup: Optional[bool] = None, - handle_retry_after: Optional[bool] = None, - destroy_on_complete: Optional[bool] = None, - preserve: Optional[bool] = None, - disable_events: bool = False, - exclude_placeholders: Optional[Union[Literal[True], Set[str]]] = None, - stored_context: Optional[Dict[str, JsonValue]] = None, - **context: Any, - ) -> MailerGroup: + content: ContentType, + interval: Optional[BaseInterval] = None, + **context: JsonValue, + ) -> MailerGroup[ContentType]: if not bots and not self.bots: - raise ValueError("At least one bot must be specified.") - if not bots: - bots = self.bots + raise ValueError("At least one bot must be provided.") + bots = bots or self.bots mailers = [ await self.create_mailer( - content=content, chats=chats, + content=content, bot=bot, interval=interval, - dynamic_interval=dynamic_interval, - run_on_startup=run_on_startup, - handle_retry_after=handle_retry_after, - destroy_on_complete=destroy_on_complete, - preserve=preserve, - disable_events=disable_events, - exclude_placeholders=exclude_placeholders, - stored_context=stored_context, **context, ) for bot in bots @@ -98,148 +65,57 @@ async def create_mailers( async def create_mailer( self, - content: ContentType, chats: Iterable[int], - *, + content: ContentType, bot: Optional[Bot] = None, - interval: Optional[float] = None, - dynamic_interval: Optional[bool] = None, - run_on_startup: Optional[bool] = None, - handle_retry_after: Optional[bool] = None, - destroy_on_complete: Optional[bool] = None, - preserve: Optional[bool] = None, - disable_events: bool = False, - exclude_placeholders: Optional[Union[Literal[True], Set[str]]] = None, - stored_context: Optional[Dict[str, JsonValue]] = None, - **context: Any, + interval: Optional[BaseInterval] = None, + **context: JsonValue, ) -> Mailer[ContentType]: - properties = self.default.prepare( - interval=interval, - dynamic_interval=dynamic_interval, - run_on_startup=run_on_startup, - handle_retry_after=handle_retry_after, - destroy_on_complete=destroy_on_complete, - preserve=preserve, - ) - if not chats: - raise ValueError("At least one chat id must be provided.") - if not bot and not self.bots: - raise ValueError("At least one bot must be specified.") - if not content.is_registered(): - raise ValueError( - f"Register the {type(content).__name__!r} content " - f"using the '{type(content).__name__}.register()' method.", - ) - chats = set(chats) - if bot is None: - bot = self.bots[-1] - if properties.dynamic_interval: - properties.interval = max(0.1, properties.interval / len(chats)) - if stored_context is None: - stored_context = {} - mailer_id = hash(uuid4()) - settings = MailerSettings( - interval=properties.interval, - run_on_startup=properties.run_on_startup, - handle_retry_after=properties.handle_retry_after, - destroy_on_complete=properties.destroy_on_complete, - disable_events=disable_events, - exclude_placeholders=exclude_placeholders, - preserved=properties.preserve, - ) - chats_registry = ChatsRegistry.from_iterable(chats=chats) - mailer = Mailer( - id=mailer_id, - settings=settings, - chats=chats_registry, + return await Mailer.create( + broadcaster=self, + chats=chats, content=content, - event=self.event, - placeholder=self.placeholder, - storage=self.storage if properties.preserve else None, - mailer_container=self._mailers, bot=bot, - context={**self.context, **stored_context, **context}, - ) - loggers.pool.info("Mailer id=%d was created.", mailer_id) - if not properties.preserve: - return mailer - self._mailers[mailer_id] = cast(Mailer, mailer) - if not self.storage: - return mailer - record = StorageRecord( - content=content, - chats=chats_registry, - settings=settings, - bot_id=bot.id, - context=stored_context, + interval=interval, + **context, ) - try: - record.model_dump_json(exclude_defaults=True) - except PydanticSerializationError as error: - del self._mailers[mailer_id] - raise RuntimeError("Record cant be serialized to preserving.") from error - await self.storage.set(mailer_id=mailer_id, record=record) - return mailer async def restore_mailers(self) -> None: if not self.storage: - raise RuntimeError("Storage not found.") - mailer_ids = await self.storage.get_mailer_ids() - if not mailer_ids: - return - bots = {bot.id: bot for bot in self.bots} - for mailer_id in mailer_ids: - if mailer_id in self: - continue + raise ValueError("Storage not found.") + async for mailer_id, record in self.storage.get_records(): try: - record = await self.storage.get(mailer_id=mailer_id) - except ValidationError: - loggers.pool.exception("Failed to restore mailer id=%d.", mailer_id) - continue - if record.bot_id not in bots: - loggers.pool.error( - "Failed to restore mailer id=%d, bot with id=%d not defined.", - mailer_id, - record.bot_id, + await Mailer.create_from_record( + broadcaster=self, + mailer_id=mailer_id, + record=record, ) - continue - mailer = Mailer( - id=mailer_id, - settings=record.settings, - chats=record.chats, - content=record.content, - event=self.event, - placeholder=self.placeholder, - storage=self.storage, - mailer_container=self._mailers, - bot=bots[record.bot_id], - context={**self.context, **record.context}, - ) - self._mailers[mailer_id] = mailer - loggers.pool.info("Mailer id=%d restored from storage.", mailer_id) + except Exception: + logger.exception("Failed to restore mailer id=%d.", mailer_id) - async def run_startup_mailers(self) -> None: - for mailer in self.get_mailers(MailerStatus.STOPPED): - if mailer.settings.run_on_startup: - mailer.start() + async def run_mailers(self) -> None: + group = self.get_mailers(magic=F.status.is_(MailerStatus.STOPPED)) + group.start() def setup( self, dispatcher: Dispatcher, *, + context_key: str = "broadcaster", fetch_dispatcher_context: bool = True, restore_mailers: bool = True, run_mailers: bool = True, ) -> Self: - dispatcher[self.context_key] = self + dispatcher[context_key] = self + self.context[context_key] = self self.context["dispatcher"] = dispatcher if fetch_dispatcher_context: self.context.update(dispatcher.workflow_data) if self.storage: - dispatcher.startup.register(self.storage.startup) - dispatcher.shutdown.register(self.storage.shutdown) + dispatcher.startup.register(callback=self.storage.startup) + dispatcher.shutdown.register(callback=self.storage.shutdown) if restore_mailers: - dispatcher.startup.register(self.restore_mailers) + dispatcher.startup.register(callback=self.restore_mailers) if run_mailers: - dispatcher.startup.register(self.run_startup_mailers) + dispatcher.startup.register(callback=self.run_mailers) return self diff --git a/aiogram_broadcaster/contents/__init__.py b/aiogram_broadcaster/contents/__init__.py index 8478a12..9474942 100644 --- a/aiogram_broadcaster/contents/__init__.py +++ b/aiogram_broadcaster/contents/__init__.py @@ -6,17 +6,18 @@ "ContactContent", "DiceContent", "DocumentContent", - "FromChatCopyContent", - "FromChatForwardContent", + "FromChatCopyMessageContent", + "FromChatCopyMessagesContent", + "FromChatForwardMessageContent", + "FromChatForwardMessagesContent", "GameContent", "InvoiceContent", - "KeyBasedContent", - "LazyContent", "LocationContent", "MediaGroupContent", "MessageCopyContent", "MessageForwardContent", "MessageSendContent", + "PaidMediaContent", "PhotoContent", "PollContent", "StickerContent", @@ -25,8 +26,11 @@ "VideoContent", "VideoNoteContent", "VoiceContent", + "adapters", ) + +from . import adapters from .animation import AnimationContent from .audio import AudioContent from .base import BaseContent @@ -34,14 +38,18 @@ from .contact import ContactContent from .dice import DiceContent from .document import DocumentContent -from .from_chat import FromChatCopyContent, FromChatForwardContent +from .from_chat_copy_message import FromChatCopyMessageContent +from .from_chat_copy_messages import FromChatCopyMessagesContent +from .from_chat_forward_message import FromChatForwardMessageContent +from .from_chat_forward_messages import FromChatForwardMessagesContent from .game import GameContent from .invoice import InvoiceContent -from .key_based import KeyBasedContent -from .lazy import LazyContent from .location import LocationContent from .media_group import MediaGroupContent -from .message import MessageCopyContent, MessageForwardContent, MessageSendContent +from .message_copy import MessageCopyContent +from .message_forward import MessageForwardContent +from .message_send import MessageSendContent +from .paid_media import PaidMediaContent from .photo import PhotoContent from .poll import PollContent from .sticker import StickerContent diff --git a/aiogram_broadcaster/contents/adapters/__init__.py b/aiogram_broadcaster/contents/adapters/__init__.py new file mode 100644 index 0000000..355b413 --- /dev/null +++ b/aiogram_broadcaster/contents/adapters/__init__.py @@ -0,0 +1,8 @@ +__all__ = ( + "LazyContentAdapter", + "MappedContentAdapter", +) + + +from .lazy import LazyContentAdapter +from .mapped import MappedContentAdapter diff --git a/aiogram_broadcaster/contents/adapters/lazy.py b/aiogram_broadcaster/contents/adapters/lazy.py new file mode 100644 index 0000000..4a24038 --- /dev/null +++ b/aiogram_broadcaster/contents/adapters/lazy.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING, Any + +from aiogram.methods import TelegramMethod + +from aiogram_broadcaster.contents.base import BaseContent + + +if TYPE_CHECKING: + from abc import abstractmethod + + +class LazyContentAdapter(BaseContent, register=False): + async def as_method(self, **context: Any) -> TelegramMethod[Any]: + content: BaseContent = await self.call(**context) + return await content.as_method(**context) + + if TYPE_CHECKING: + + @abstractmethod + async def __call__(self, *args: Any, **kwargs: Any) -> BaseContent: + pass diff --git a/aiogram_broadcaster/contents/adapters/mapped.py b/aiogram_broadcaster/contents/adapters/mapped.py new file mode 100644 index 0000000..4f572c1 --- /dev/null +++ b/aiogram_broadcaster/contents/adapters/mapped.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING, Any, Optional + +from aiogram.methods import TelegramMethod +from pydantic import SerializeAsAny + +from aiogram_broadcaster.contents.base import BaseContent + + +if TYPE_CHECKING: + from abc import abstractmethod + + +class MappedContentAdapter(BaseContent, register=False): + default: Optional[SerializeAsAny[BaseContent]] = None + __pydantic_extra__: dict[str, SerializeAsAny[BaseContent]] + + def __getitem__(self, item: str) -> BaseContent: + if self.default: + return self.__pydantic_extra__.get(item, self.default) + try: + return self.__pydantic_extra__[item] + except KeyError as error: + raise KeyError( + f"The '{type(self).__name__}' cannot find content by the '{item}' key.", + ) from error + + def __contains__(self, item: str) -> bool: + return item in self.__pydantic_extra__ + + async def as_method(self, **context: Any) -> TelegramMethod[Any]: + key = await self.call(**context) + content = self[key] + return await content.as_method(**context) + + def model_post_init(self, __context: Any) -> None: # noqa: PYI063 + super().model_post_init(__context) + if not self.default and not self.model_extra: + raise ValueError("At least one content must be provided.") + + if TYPE_CHECKING: + + def __init__( + self, + *, + default: Optional[BaseContent] = ..., + **contents: BaseContent, + ) -> None: ... + + @abstractmethod + async def __call__(self, *args: Any, **kwargs: Any) -> Optional[str]: + pass diff --git a/aiogram_broadcaster/contents/animation.py b/aiogram_broadcaster/contents/animation.py index 03999e7..cf7ebae 100644 --- a/aiogram_broadcaster/contents/animation.py +++ b/aiogram_broadcaster/contents/animation.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendAnimation +from aiogram.methods import ( + SendAnimation, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -23,19 +32,14 @@ class AnimationContent(BaseContent): thumbnail: Optional[InputFile] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") has_spoiler: Optional[bool] = None disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendAnimation: @@ -72,19 +76,14 @@ def __init__( thumbnail: Optional[InputFile] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., show_caption_above_media: Optional[Union[bool, Default]] = ..., has_spoiler: Optional[bool] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/audio.py b/aiogram_broadcaster/contents/audio.py index 4093dfa..a0d7cf0 100644 --- a/aiogram_broadcaster/contents/audio.py +++ b/aiogram_broadcaster/contents/audio.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendAudio +from aiogram.methods import ( + SendAudio, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -19,7 +28,7 @@ class AudioContent(BaseContent): business_connection_id: Optional[str] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None duration: Optional[int] = None performer: Optional[str] = None title: Optional[str] = None @@ -28,12 +37,7 @@ class AudioContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendAudio: @@ -64,7 +68,7 @@ def __init__( business_connection_id: Optional[str] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., duration: Optional[int] = ..., performer: Optional[str] = ..., title: Optional[str] = ..., @@ -73,12 +77,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/base.py b/aiogram_broadcaster/contents/base.py index 6e14d18..2dad898 100644 --- a/aiogram_broadcaster/contents/base.py +++ b/aiogram_broadcaster/contents/base.py @@ -1,16 +1,10 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Type, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar -from aiogram.dispatcher.event.handler import CallableObject from aiogram.methods import TelegramMethod -from pydantic import ( - BaseModel, - ConfigDict, - SerializerFunctionWrapHandler, - ValidatorFunctionWrapHandler, - model_serializer, - model_validator, -) +from pydantic import ConfigDict + +from aiogram_broadcaster.utils.callable_model import CallableModel +from aiogram_broadcaster.utils.union_model import UnionModel if TYPE_CHECKING: @@ -18,79 +12,14 @@ else: ContentType = TypeVar("ContentType", bound="BaseContent") -VALIDATOR_KEY = "__V" - -class BaseContent(BaseModel, ABC): +class BaseContent(UnionModel, CallableModel, register=False): model_config = ConfigDict( extra="allow", - frozen=True, validate_assignment=True, arbitrary_types_allowed=True, ) - _validators: ClassVar[Dict[str, Type["BaseContent"]]] = {} - _callback: CallableObject - - def __init_subclass__( - cls, - register: bool = True, # noqa: FBT001, FBT002 - **kwargs: Any, - ) -> None: - if register: - cls.register() - super().__init_subclass__(**kwargs) - - if TYPE_CHECKING: - __call__: Callable[..., Any] - else: - - @abstractmethod - async def __call__(self, **kwargs: Any) -> Any: - pass - async def as_method(self, **context: Any) -> TelegramMethod[Any]: - method = await self._callback.call(**context) - return cast(TelegramMethod[Any], method) - - @classmethod - def is_registered(cls) -> bool: - return cls.__name__ in cls._validators - - @classmethod - def register(cls) -> None: - if cls is BaseContent: - raise TypeError("BaseContent cannot be registered.") - if cls.is_registered(): - raise RuntimeError(f"The content {cls.__name__!r} is already registered.") - cls._validators[cls.__name__] = cls - - @classmethod - def unregister(cls) -> None: - if not cls.is_registered(): - raise RuntimeError(f"The content {cls.__name__!r} is not registered.") - del cls._validators[cls.__name__] - - def model_post_init(self, __context: Any) -> None: - self._callback = CallableObject(callback=self.__call__) - - @model_validator(mode="wrap") - @classmethod - def _validate(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: - if not isinstance(value, dict): - return handler(value) - if VALIDATOR_KEY not in value: - return handler(value) - validator_name: str = value.pop(VALIDATOR_KEY, None) - if validator_name not in cls._validators: - raise ValueError( - f"Content {validator_name!r} has not been registered, " - f"you can register using the '{validator_name}.register()' method.", - ) - return cls._validators[validator_name].model_validate(value) - - @model_serializer(mode="wrap", return_type=Any) - def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: - data = handler(self) - data[VALIDATOR_KEY] = type(self).__name__ - return data + method: TelegramMethod[Any] = await self.call(**context) + return method diff --git a/aiogram_broadcaster/contents/chat_action.py b/aiogram_broadcaster/contents/chat_action.py index da2bf64..1524005 100644 --- a/aiogram_broadcaster/contents/chat_action.py +++ b/aiogram_broadcaster/contents/chat_action.py @@ -1,6 +1,14 @@ -from typing import TYPE_CHECKING, Any, Optional +# THIS CODE WAS AUTO-GENERATED VIA `butcher` -from aiogram.methods import SendChatAction +from typing import ( + TYPE_CHECKING, + Any, + Optional, +) + +from aiogram.methods import ( + SendChatAction, +) from .base import BaseContent diff --git a/aiogram_broadcaster/contents/contact.py b/aiogram_broadcaster/contents/contact.py index e21108c..abda6b1 100644 --- a/aiogram_broadcaster/contents/contact.py +++ b/aiogram_broadcaster/contents/contact.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendContact +from aiogram.methods import ( + SendContact, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -22,12 +31,7 @@ class ContactContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendContact: @@ -59,12 +63,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/dice.py b/aiogram_broadcaster/contents/dice.py index 26916cb..bc3b98f 100644 --- a/aiogram_broadcaster/contents/dice.py +++ b/aiogram_broadcaster/contents/dice.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendDice +from aiogram.methods import ( + SendDice, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -19,12 +28,7 @@ class DiceContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendDice: @@ -50,12 +54,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/document.py b/aiogram_broadcaster/contents/document.py index 0c0443b..ebfced4 100644 --- a/aiogram_broadcaster/contents/document.py +++ b/aiogram_broadcaster/contents/document.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendDocument +from aiogram.methods import ( + SendDocument, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -20,18 +29,13 @@ class DocumentContent(BaseContent): thumbnail: Optional[InputFile] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None disable_content_type_detection: Optional[bool] = None disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendDocument: @@ -61,18 +65,13 @@ def __init__( thumbnail: Optional[InputFile] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., disable_content_type_detection: Optional[bool] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/from_chat.py b/aiogram_broadcaster/contents/from_chat_copy_message.py similarity index 53% rename from aiogram_broadcaster/contents/from_chat.py rename to aiogram_broadcaster/contents/from_chat_copy_message.py index 91d8f3e..40293a2 100644 --- a/aiogram_broadcaster/contents/from_chat.py +++ b/aiogram_broadcaster/contents/from_chat_copy_message.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import CopyMessage, ForwardMessage +from aiogram.methods import ( + CopyMessage, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -13,22 +22,17 @@ from .base import BaseContent -class FromChatCopyContent(BaseContent): +class FromChatCopyMessageContent(BaseContent): from_chat_id: Union[int, str] message_id: int caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> CopyMessage: @@ -55,46 +59,12 @@ def __init__( message_id: int, caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., show_caption_above_media: Optional[Union[bool, Default]] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... - - -class FromChatForwardContent(BaseContent): - from_chat_id: Union[int, str] - message_id: int - disable_notification: Optional[bool] = None - protect_content: Optional[Union[bool, Default]] = Default("protect_content") - - async def __call__(self, chat_id: int) -> ForwardMessage: - return ForwardMessage( - chat_id=chat_id, - from_chat_id=self.from_chat_id, - message_id=self.message_id, - disable_notification=self.disable_notification, - protect_content=self.protect_content, - **(self.model_extra or {}), - ) - - if TYPE_CHECKING: - - def __init__( - self, - *, - from_chat_id: Union[int, str], - message_id: int, - disable_notification: Optional[bool] = ..., - protect_content: Optional[Union[bool, Default]] = ..., - **kwargs: Any, - ) -> None: ... diff --git a/aiogram_broadcaster/contents/from_chat_copy_messages.py b/aiogram_broadcaster/contents/from_chat_copy_messages.py new file mode 100644 index 0000000..8eceb3b --- /dev/null +++ b/aiogram_broadcaster/contents/from_chat_copy_messages.py @@ -0,0 +1,46 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.methods import ( + CopyMessages, +) + +from .base import BaseContent + + +class FromChatCopyMessagesContent(BaseContent): + from_chat_id: Union[int, str] + message_ids: list[int] + disable_notification: Optional[bool] = None + protect_content: Optional[bool] = None + remove_caption: Optional[bool] = None + + async def __call__(self, chat_id: int) -> CopyMessages: + return CopyMessages( + chat_id=chat_id, + from_chat_id=self.from_chat_id, + message_ids=self.message_ids, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + remove_caption=self.remove_caption, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + from_chat_id: Union[int, str], + message_ids: list[int], + disable_notification: Optional[bool] = ..., + protect_content: Optional[bool] = ..., + remove_caption: Optional[bool] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/from_chat_forward_message.py b/aiogram_broadcaster/contents/from_chat_forward_message.py new file mode 100644 index 0000000..357f21a --- /dev/null +++ b/aiogram_broadcaster/contents/from_chat_forward_message.py @@ -0,0 +1,44 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.client.default import Default +from aiogram.methods import ( + ForwardMessage, +) + +from .base import BaseContent + + +class FromChatForwardMessageContent(BaseContent): + from_chat_id: Union[int, str] + message_id: int + disable_notification: Optional[bool] = None + protect_content: Optional[Union[bool, Default]] = Default("protect_content") + + async def __call__(self, chat_id: int) -> ForwardMessage: + return ForwardMessage( + chat_id=chat_id, + from_chat_id=self.from_chat_id, + message_id=self.message_id, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + from_chat_id: Union[int, str], + message_id: int, + disable_notification: Optional[bool] = ..., + protect_content: Optional[Union[bool, Default]] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/from_chat_forward_messages.py b/aiogram_broadcaster/contents/from_chat_forward_messages.py new file mode 100644 index 0000000..c131e60 --- /dev/null +++ b/aiogram_broadcaster/contents/from_chat_forward_messages.py @@ -0,0 +1,43 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.methods import ( + ForwardMessages, +) + +from .base import BaseContent + + +class FromChatForwardMessagesContent(BaseContent): + from_chat_id: Union[int, str] + message_ids: list[int] + disable_notification: Optional[bool] = None + protect_content: Optional[bool] = None + + async def __call__(self, chat_id: int) -> ForwardMessages: + return ForwardMessages( + chat_id=chat_id, + from_chat_id=self.from_chat_id, + message_ids=self.message_ids, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + from_chat_id: Union[int, str], + message_ids: list[int], + disable_notification: Optional[bool] = ..., + protect_content: Optional[bool] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/game.py b/aiogram_broadcaster/contents/game.py index 27afe2b..4fe42ba 100644 --- a/aiogram_broadcaster/contents/game.py +++ b/aiogram_broadcaster/contents/game.py @@ -1,8 +1,19 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendGame -from aiogram.types import InlineKeyboardMarkup +from aiogram.methods import ( + SendGame, +) +from aiogram.types import ( + InlineKeyboardMarkup, +) from .base import BaseContent diff --git a/aiogram_broadcaster/contents/invoice.py b/aiogram_broadcaster/contents/invoice.py index 03c4854..0116a66 100644 --- a/aiogram_broadcaster/contents/invoice.py +++ b/aiogram_broadcaster/contents/invoice.py @@ -1,8 +1,20 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendInvoice -from aiogram.types import InlineKeyboardMarkup, LabeledPrice +from aiogram.methods import ( + SendInvoice, +) +from aiogram.types import ( + InlineKeyboardMarkup, + LabeledPrice, +) from .base import BaseContent @@ -12,10 +24,10 @@ class InvoiceContent(BaseContent): description: str payload: str currency: str - prices: List[LabeledPrice] + prices: list[LabeledPrice] provider_token: Optional[str] = None max_tip_amount: Optional[int] = None - suggested_tip_amounts: Optional[List[int]] = None + suggested_tip_amounts: Optional[list[int]] = None start_parameter: Optional[str] = None provider_data: Optional[str] = None photo_url: Optional[str] = None @@ -74,10 +86,10 @@ def __init__( description: str, payload: str, currency: str, - prices: List[LabeledPrice], + prices: list[LabeledPrice], provider_token: Optional[str] = ..., max_tip_amount: Optional[int] = ..., - suggested_tip_amounts: Optional[List[int]] = ..., + suggested_tip_amounts: Optional[list[int]] = ..., start_parameter: Optional[str] = ..., provider_data: Optional[str] = ..., photo_url: Optional[str] = ..., diff --git a/aiogram_broadcaster/contents/key_based.py b/aiogram_broadcaster/contents/key_based.py deleted file mode 100644 index 46f427a..0000000 --- a/aiogram_broadcaster/contents/key_based.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional - -from aiogram.methods import TelegramMethod -from pydantic import SerializeAsAny - -from .base import BaseContent - - -class KeyBasedContent(BaseContent, register=False): - default: Optional[SerializeAsAny[BaseContent]] = None - __pydantic_extra__: Dict[str, SerializeAsAny[BaseContent]] - - def __getitem__(self, item: str) -> BaseContent: - if self.default: - return self.__pydantic_extra__.get(item, self.default) - return self.__pydantic_extra__[item] - - def __contains__(self, item: str) -> bool: - return item in self.__pydantic_extra__ - - async def as_method(self, **context: Any) -> TelegramMethod[Any]: - key = await self._callback.call(**context) - return await self[key].as_method(**context) - - def model_post_init(self, __context: Any) -> None: - if not self.default and not self.__pydantic_extra__: - raise ValueError("At least one content must be specified.") - super().model_post_init(__context) - - if TYPE_CHECKING: - - def __init__( - self, - *, - default: Optional[BaseContent] = ..., - **contents: BaseContent, - ) -> None: ... diff --git a/aiogram_broadcaster/contents/lazy.py b/aiogram_broadcaster/contents/lazy.py deleted file mode 100644 index 6927aec..0000000 --- a/aiogram_broadcaster/contents/lazy.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Any - -from aiogram.methods import TelegramMethod - -from .base import BaseContent - - -class LazyContent(BaseContent, register=False): - async def as_method(self, **context: Any) -> TelegramMethod[Any]: - content = await self._callback.call(**context) - if not isinstance(content, BaseContent): - raise TypeError( - f"The {type(self).__name__!r} expected to return an content of " - f"type BaseContent, not a {type(content).__name__}.", - ) - return await content.as_method(**context) diff --git a/aiogram_broadcaster/contents/location.py b/aiogram_broadcaster/contents/location.py index 3a24347..fc3e04a 100644 --- a/aiogram_broadcaster/contents/location.py +++ b/aiogram_broadcaster/contents/location.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendLocation +from aiogram.methods import ( + SendLocation, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -24,12 +33,7 @@ class LocationContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendLocation: @@ -65,12 +69,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/media_group.py b/aiogram_broadcaster/contents/media_group.py index d7f7c09..07ff1d4 100644 --- a/aiogram_broadcaster/contents/media_group.py +++ b/aiogram_broadcaster/contents/media_group.py @@ -1,21 +1,28 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendMediaGroup -from aiogram.types import InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo +from aiogram.methods import ( + SendMediaGroup, +) +from aiogram.types import ( + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, +) from .base import BaseContent class MediaGroupContent(BaseContent): - media: List[ - Union[ - InputMediaAudio, - InputMediaDocument, - InputMediaPhoto, - InputMediaVideo, - ] - ] + media: list[Union[InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo]] business_connection_id: Optional[str] = None disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") @@ -37,13 +44,8 @@ async def __call__(self, chat_id: int) -> SendMediaGroup: def __init__( self, *, - media: List[ - Union[ - InputMediaAudio, - InputMediaDocument, - InputMediaPhoto, - InputMediaVideo, - ] + media: list[ + Union[InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo] ], business_connection_id: Optional[str] = ..., disable_notification: Optional[bool] = ..., diff --git a/aiogram_broadcaster/contents/message.py b/aiogram_broadcaster/contents/message.py deleted file mode 100644 index 1cf4bbe..0000000 --- a/aiogram_broadcaster/contents/message.py +++ /dev/null @@ -1,162 +0,0 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union - -from aiogram.client.default import Default -from aiogram.methods import ( - CopyMessage, - ForwardMessage, - SendAnimation, - SendAudio, - SendContact, - SendDice, - SendDocument, - SendLocation, - SendMessage, - SendPhoto, - SendPoll, - SendSticker, - SendVenue, - SendVideo, - SendVideoNote, - SendVoice, -) -from aiogram.types import ( - ForceReply, - InlineKeyboardMarkup, - Message, - MessageEntity, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, -) - -from .base import BaseContent - - -class MessageCopyContent(BaseContent): - message: Message - caption: Optional[str] = None - parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None - show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") - disable_notification: Optional[bool] = None - protect_content: Optional[Union[bool, Default]] = Default("protect_content") - reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] - ] = None - - async def __call__(self, chat_id: int) -> CopyMessage: - return self.message.copy_to( - chat_id=chat_id, - caption=self.caption, - parse_mode=self.parse_mode, - caption_entities=self.caption_entities, - show_caption_above_media=self.show_caption_above_media, - disable_notification=self.disable_notification, - protect_content=self.protect_content, - reply_markup=self.reply_markup, - **(self.model_extra or {}), - ) - - if TYPE_CHECKING: - - def __init__( - self, - *, - message: Message, - caption: Optional[str] = ..., - parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., - show_caption_above_media: Optional[Union[bool, Default]] = ..., - disable_notification: Optional[bool] = ..., - protect_content: Optional[Union[bool, Default]] = ..., - reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] - ] = ..., - **kwargs: Any, - ) -> None: ... - - -class MessageForwardContent(BaseContent): - message: Message - disable_notification: Optional[bool] = None - protect_content: Optional[Union[bool, Default]] = Default("protect_content") - - async def __call__(self, chat_id: int) -> ForwardMessage: - return self.message.forward( - chat_id=chat_id, - disable_notification=self.disable_notification, - protect_content=self.protect_content, - **(self.model_extra or {}), - ) - - if TYPE_CHECKING: - - def __init__( - self, - *, - message: Message, - disable_notification: Optional[bool] = ..., - protect_content: Optional[Union[bool, Default]] = ..., - **kwargs: Any, - ) -> None: ... - - -class MessageSendContent(BaseContent): - message: Message - disable_notification: Optional[bool] = None - reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None - business_connection_id: Optional[str] = None - parse_mode: Optional[str] = None - message_effect_id: Optional[str] = None - - async def __call__( - self, - chat_id: int, - ) -> Union[ - ForwardMessage, - SendAnimation, - SendAudio, - SendContact, - SendDocument, - SendLocation, - SendMessage, - SendPhoto, - SendPoll, - SendDice, - SendSticker, - SendVenue, - SendVideo, - SendVideoNote, - SendVoice, - ]: - return self.message.send_copy( - chat_id=chat_id, - disable_notification=self.disable_notification, - reply_markup=self.reply_markup, - business_connection_id=self.business_connection_id, - parse_mode=self.parse_mode, - message_effect_id=self.message_effect_id, - ) - - if TYPE_CHECKING: - - def __init__( - self, - *, - message: Message, - disable_notification: Optional[bool] = ..., - reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = ..., - business_connection_id: Optional[str] = ..., - parse_mode: Optional[str] = ..., - message_effect_id: Optional[str] = ..., - **kwargs: Any, - ) -> None: ... diff --git a/aiogram_broadcaster/contents/message_copy.py b/aiogram_broadcaster/contents/message_copy.py new file mode 100644 index 0000000..699e0c0 --- /dev/null +++ b/aiogram_broadcaster/contents/message_copy.py @@ -0,0 +1,67 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.client.default import Default +from aiogram.methods import ( + CopyMessage, +) +from aiogram.types import ( + ForceReply, + InlineKeyboardMarkup, + Message, + MessageEntity, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) + +from .base import BaseContent + + +class MessageCopyContent(BaseContent): + message: Message + caption: Optional[str] = None + parse_mode: Optional[Union[str, Default]] = Default("parse_mode") + caption_entities: Optional[list[MessageEntity]] = None + show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") + disable_notification: Optional[bool] = None + protect_content: Optional[Union[bool, Default]] = Default("protect_content") + reply_markup: Optional[ + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] + ] = None + + async def __call__(self, chat_id: int) -> CopyMessage: + return self.message.copy_to( + chat_id=chat_id, + caption=self.caption, + parse_mode=self.parse_mode, + caption_entities=self.caption_entities, + show_caption_above_media=self.show_caption_above_media, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + reply_markup=self.reply_markup, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + message: Message, + caption: Optional[str] = ..., + parse_mode: Optional[Union[str, Default]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., + show_caption_above_media: Optional[Union[bool, Default]] = ..., + disable_notification: Optional[bool] = ..., + protect_content: Optional[Union[bool, Default]] = ..., + reply_markup: Optional[ + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] + ] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/message_forward.py b/aiogram_broadcaster/contents/message_forward.py new file mode 100644 index 0000000..72f6eb5 --- /dev/null +++ b/aiogram_broadcaster/contents/message_forward.py @@ -0,0 +1,43 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.client.default import Default +from aiogram.methods import ( + ForwardMessage, +) +from aiogram.types import ( + Message, +) + +from .base import BaseContent + + +class MessageForwardContent(BaseContent): + message: Message + disable_notification: Optional[bool] = None + protect_content: Optional[Union[bool, Default]] = Default("protect_content") + + async def __call__(self, chat_id: int) -> ForwardMessage: + return self.message.forward( + chat_id=chat_id, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + message: Message, + disable_notification: Optional[bool] = ..., + protect_content: Optional[Union[bool, Default]] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/message_send.py b/aiogram_broadcaster/contents/message_send.py new file mode 100644 index 0000000..389dbaa --- /dev/null +++ b/aiogram_broadcaster/contents/message_send.py @@ -0,0 +1,85 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.methods import ( + ForwardMessage, + SendAnimation, + SendAudio, + SendContact, + SendDice, + SendDocument, + SendLocation, + SendMessage, + SendPhoto, + SendPoll, + SendSticker, + SendVenue, + SendVideo, + SendVideoNote, + SendVoice, +) +from aiogram.types import ( + InlineKeyboardMarkup, + Message, + ReplyKeyboardMarkup, +) + +from .base import BaseContent + + +class MessageSendContent(BaseContent): + message: Message + disable_notification: Optional[bool] = None + reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None + business_connection_id: Optional[str] = None + parse_mode: Optional[str] = None + message_effect_id: Optional[str] = None + + async def __call__( + self, + chat_id: int, + ) -> Union[ + ForwardMessage, + SendAnimation, + SendAudio, + SendContact, + SendDocument, + SendLocation, + SendMessage, + SendPhoto, + SendPoll, + SendDice, + SendSticker, + SendVenue, + SendVideo, + SendVideoNote, + SendVoice, + ]: + return self.message.send_copy( + chat_id=chat_id, + disable_notification=self.disable_notification, + reply_markup=self.reply_markup, + business_connection_id=self.business_connection_id, + parse_mode=self.parse_mode, + message_effect_id=self.message_effect_id, + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + message: Message, + disable_notification: Optional[bool] = ..., + reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = ..., + business_connection_id: Optional[str] = ..., + parse_mode: Optional[str] = ..., + message_effect_id: Optional[str] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/paid_media.py b/aiogram_broadcaster/contents/paid_media.py new file mode 100644 index 0000000..f3538d4 --- /dev/null +++ b/aiogram_broadcaster/contents/paid_media.py @@ -0,0 +1,77 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from aiogram.methods import ( + SendPaidMedia, +) +from aiogram.types import ( + ForceReply, + InlineKeyboardMarkup, + InputPaidMediaPhoto, + InputPaidMediaVideo, + MessageEntity, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) + +from .base import BaseContent + + +class PaidMediaContent(BaseContent): + star_count: int + media: list[Union[InputPaidMediaPhoto, InputPaidMediaVideo]] + business_connection_id: Optional[str] = None + payload: Optional[str] = None + caption: Optional[str] = None + parse_mode: Optional[str] = None + caption_entities: Optional[list[MessageEntity]] = None + show_caption_above_media: Optional[bool] = None + disable_notification: Optional[bool] = None + protect_content: Optional[bool] = None + reply_markup: Optional[ + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] + ] = None + + async def __call__(self, chat_id: int) -> SendPaidMedia: + return SendPaidMedia( + chat_id=chat_id, + star_count=self.star_count, + media=self.media, + business_connection_id=self.business_connection_id, + payload=self.payload, + caption=self.caption, + parse_mode=self.parse_mode, + caption_entities=self.caption_entities, + show_caption_above_media=self.show_caption_above_media, + disable_notification=self.disable_notification, + protect_content=self.protect_content, + reply_markup=self.reply_markup, + **(self.model_extra or {}), + ) + + if TYPE_CHECKING: + + def __init__( + self, + *, + star_count: int, + media: list[Union[InputPaidMediaPhoto, InputPaidMediaVideo]], + business_connection_id: Optional[str] = ..., + payload: Optional[str] = ..., + caption: Optional[str] = ..., + parse_mode: Optional[str] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., + show_caption_above_media: Optional[bool] = ..., + disable_notification: Optional[bool] = ..., + protect_content: Optional[bool] = ..., + reply_markup: Optional[ + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] + ] = ..., + **kwargs: Any, + ) -> None: ... diff --git a/aiogram_broadcaster/contents/photo.py b/aiogram_broadcaster/contents/photo.py index 215ca17..86d699e 100644 --- a/aiogram_broadcaster/contents/photo.py +++ b/aiogram_broadcaster/contents/photo.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendPhoto +from aiogram.methods import ( + SendPhoto, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -19,19 +28,14 @@ class PhotoContent(BaseContent): business_connection_id: Optional[str] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") has_spoiler: Optional[bool] = None disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendPhoto: @@ -60,19 +64,14 @@ def __init__( business_connection_id: Optional[str] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., show_caption_above_media: Optional[Union[bool, Default]] = ..., has_spoiler: Optional[bool] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/poll.py b/aiogram_broadcaster/contents/poll.py index e5d5c71..210fe29 100644 --- a/aiogram_broadcaster/contents/poll.py +++ b/aiogram_broadcaster/contents/poll.py @@ -1,8 +1,17 @@ +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendPoll +from aiogram.methods import ( + SendPoll, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -17,17 +26,17 @@ class PollContent(BaseContent): question: str - options: List[Union[InputPollOption, str]] + options: list[Union[InputPollOption, str]] business_connection_id: Optional[str] = None question_parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - question_entities: Optional[List[MessageEntity]] = None + question_entities: Optional[list[MessageEntity]] = None is_anonymous: Optional[bool] = None type: Optional[str] = None allows_multiple_answers: Optional[bool] = None correct_option_id: Optional[int] = None explanation: Optional[str] = None explanation_parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - explanation_entities: Optional[List[MessageEntity]] = None + explanation_entities: Optional[list[MessageEntity]] = None open_period: Optional[int] = None close_date: Optional[Union[datetime, timedelta, int]] = None is_closed: Optional[bool] = None @@ -35,12 +44,7 @@ class PollContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendPoll: @@ -74,17 +78,17 @@ def __init__( self, *, question: str, - options: List[Union[InputPollOption, str]], + options: list[Union[InputPollOption, str]], business_connection_id: Optional[str] = ..., question_parse_mode: Optional[Union[str, Default]] = ..., - question_entities: Optional[List[MessageEntity]] = ..., + question_entities: Optional[list[MessageEntity]] = ..., is_anonymous: Optional[bool] = ..., - type: Optional[str] = ..., # noqa: A002 + type: Optional[str] = ..., allows_multiple_answers: Optional[bool] = ..., correct_option_id: Optional[int] = ..., explanation: Optional[str] = ..., explanation_parse_mode: Optional[Union[str, Default]] = ..., - explanation_entities: Optional[List[MessageEntity]] = ..., + explanation_entities: Optional[list[MessageEntity]] = ..., open_period: Optional[int] = ..., close_date: Optional[Union[datetime, timedelta, int]] = ..., is_closed: Optional[bool] = ..., @@ -92,12 +96,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/sticker.py b/aiogram_broadcaster/contents/sticker.py index a5335f5..ca40e12 100644 --- a/aiogram_broadcaster/contents/sticker.py +++ b/aiogram_broadcaster/contents/sticker.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendSticker +from aiogram.methods import ( + SendSticker, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -21,12 +30,7 @@ class StickerContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendSticker: @@ -54,12 +58,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/text.py b/aiogram_broadcaster/contents/text.py index 5073a02..3cd6200 100644 --- a/aiogram_broadcaster/contents/text.py +++ b/aiogram_broadcaster/contents/text.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendMessage +from aiogram.methods import ( + SendMessage, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -18,18 +27,13 @@ class TextContent(BaseContent): text: str business_connection_id: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - entities: Optional[List[MessageEntity]] = None + entities: Optional[list[MessageEntity]] = None link_preview_options: Optional[Union[LinkPreviewOptions, Default]] = Default("link_preview") disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendMessage: @@ -55,18 +59,13 @@ def __init__( text: str, business_connection_id: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - entities: Optional[List[MessageEntity]] = ..., + entities: Optional[list[MessageEntity]] = ..., link_preview_options: Optional[Union[LinkPreviewOptions, Default]] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/venue.py b/aiogram_broadcaster/contents/venue.py index 1b60665..243c944 100644 --- a/aiogram_broadcaster/contents/venue.py +++ b/aiogram_broadcaster/contents/venue.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendVenue +from aiogram.methods import ( + SendVenue, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -26,12 +35,7 @@ class VenueContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendVenue: @@ -71,12 +75,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/video.py b/aiogram_broadcaster/contents/video.py index 7762875..d83c0ff 100644 --- a/aiogram_broadcaster/contents/video.py +++ b/aiogram_broadcaster/contents/video.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendVideo +from aiogram.methods import ( + SendVideo, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -23,7 +32,7 @@ class VideoContent(BaseContent): thumbnail: Optional[InputFile] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None show_caption_above_media: Optional[Union[bool, Default]] = Default("show_caption_above_media") has_spoiler: Optional[bool] = None supports_streaming: Optional[bool] = None @@ -31,12 +40,7 @@ class VideoContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendVideo: @@ -74,7 +78,7 @@ def __init__( thumbnail: Optional[InputFile] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., show_caption_above_media: Optional[Union[bool, Default]] = ..., has_spoiler: Optional[bool] = ..., supports_streaming: Optional[bool] = ..., @@ -82,12 +86,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/video_note.py b/aiogram_broadcaster/contents/video_note.py index a1a37c1..528b4ea 100644 --- a/aiogram_broadcaster/contents/video_note.py +++ b/aiogram_broadcaster/contents/video_note.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendVideoNote +from aiogram.methods import ( + SendVideoNote, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -23,12 +32,7 @@ class VideoNoteContent(BaseContent): protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendVideoNote: @@ -60,12 +64,7 @@ def __init__( protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/contents/voice.py b/aiogram_broadcaster/contents/voice.py index 9fe9e19..bdd8b73 100644 --- a/aiogram_broadcaster/contents/voice.py +++ b/aiogram_broadcaster/contents/voice.py @@ -1,7 +1,16 @@ -from typing import TYPE_CHECKING, Any, List, Optional, Union +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) from aiogram.client.default import Default -from aiogram.methods import SendVoice +from aiogram.methods import ( + SendVoice, +) from aiogram.types import ( ForceReply, InlineKeyboardMarkup, @@ -19,18 +28,13 @@ class VoiceContent(BaseContent): business_connection_id: Optional[str] = None caption: Optional[str] = None parse_mode: Optional[Union[str, Default]] = Default("parse_mode") - caption_entities: Optional[List[MessageEntity]] = None + caption_entities: Optional[list[MessageEntity]] = None duration: Optional[int] = None disable_notification: Optional[bool] = None protect_content: Optional[Union[bool, Default]] = Default("protect_content") message_effect_id: Optional[str] = None reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None async def __call__(self, chat_id: int) -> SendVoice: @@ -58,18 +62,13 @@ def __init__( business_connection_id: Optional[str] = ..., caption: Optional[str] = ..., parse_mode: Optional[Union[str, Default]] = ..., - caption_entities: Optional[List[MessageEntity]] = ..., + caption_entities: Optional[list[MessageEntity]] = ..., duration: Optional[int] = ..., disable_notification: Optional[bool] = ..., protect_content: Optional[Union[bool, Default]] = ..., message_effect_id: Optional[str] = ..., reply_markup: Optional[ - Union[ - InlineKeyboardMarkup, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, - ForceReply, - ] + Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = ..., **kwargs: Any, ) -> None: ... diff --git a/aiogram_broadcaster/event/__init__.py b/aiogram_broadcaster/event/__init__.py index 47520df..5b6af4b 100644 --- a/aiogram_broadcaster/event/__init__.py +++ b/aiogram_broadcaster/event/__init__.py @@ -1,3 +1,4 @@ -__all__ = ("EventRegistry",) +__all__ = ("Event",) -from .registry import EventRegistry + +from .event import Event diff --git a/aiogram_broadcaster/event/event.py b/aiogram_broadcaster/event/event.py new file mode 100644 index 0000000..3d24cef --- /dev/null +++ b/aiogram_broadcaster/event/event.py @@ -0,0 +1,27 @@ +from typing import Optional + +from aiogram_broadcaster.utils.chain import Chain + +from .observer import EventObserver + + +class Event(Chain["Event"], sub_name="event"): + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) + + self.created = EventObserver() + self.deleted = EventObserver() + self.started = EventObserver() + self.stopped = EventObserver() + self.completed = EventObserver() + self.failed_send = EventObserver() + self.success_send = EventObserver() + self.observers: dict[str, EventObserver] = { + "created": self.created, + "deleted": self.deleted, + "started": self.started, + "stopped": self.stopped, + "completed": self.completed, + "failed_send": self.failed_send, + "success_send": self.success_send, + } diff --git a/aiogram_broadcaster/event/manager.py b/aiogram_broadcaster/event/manager.py index 071fa94..c4507d7 100644 --- a/aiogram_broadcaster/event/manager.py +++ b/aiogram_broadcaster/event/manager.py @@ -1,37 +1,39 @@ -from typing import Any, Dict +from typing import Any -from aiogram_broadcaster.utils.interrupt import suppress_interrupt +from .event import Event -from .registry import EventRegistry - -class EventManager(EventRegistry): +class EventManager(Event): __chain_root__ = True - async def emit_event(self, __event_name: str, /, **context: Any) -> Dict[str, Any]: - collected_data: Dict[str, Any] = {} - with suppress_interrupt(): - for registry in self.chain_tail: - for callback in registry.observers[__event_name].callbacks: - result = await callback.call(**context, **collected_data) - if result and isinstance(result, dict): - collected_data.update(result) - return collected_data + async def emit_event(self, event_name: str, /, **context: Any) -> None: + for event in self.chain_tail: + for handler in event.observers[event_name].handlers: + filter_result, filter_data = await handler.check(**context) + if not filter_result: + continue + context.update(filter_data) + handler_result = await handler.call(**context) + if isinstance(handler_result, dict): + context.update(handler_result) + + async def emit_created(self, **context: Any) -> None: + await self.emit_event("created", **context) - async def emit_started(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("started", **context) + async def emit_deleted(self, **context: Any) -> None: + await self.emit_event("deleted", **context) - async def emit_stopped(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("stopped", **context) + async def emit_started(self, **context: Any) -> None: + await self.emit_event("started", **context) - async def emit_completed(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("completed", **context) + async def emit_stopped(self, **context: Any) -> None: + await self.emit_event("stopped", **context) - async def emit_before_sent(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("before_sent", **context) + async def emit_completed(self, **context: Any) -> None: + await self.emit_event("completed", **context) - async def emit_success_sent(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("success_sent", **context) + async def emit_failed_send(self, **context: Any) -> None: + await self.emit_event("failed_send", **context) - async def emit_failed_sent(self, **context: Any) -> Dict[str, Any]: - return await self.emit_event("failed_sent", **context) + async def emit_success_send(self, **context: Any) -> None: + await self.emit_event("success_send", **context) diff --git a/aiogram_broadcaster/event/observer.py b/aiogram_broadcaster/event/observer.py index 391c2a2..0bb2272 100644 --- a/aiogram_broadcaster/event/observer.py +++ b/aiogram_broadcaster/event/observer.py @@ -1,30 +1,46 @@ -from typing import Callable, Iterator, List +from typing import Any, Callable -from aiogram.dispatcher.event.handler import CallableObject, CallbackType +from aiogram.dispatcher.event.handler import FilterObject, HandlerObject +from aiogram.filters import Filter +from magic_filter import AttrDict, MagicFilter from typing_extensions import Self -class EventObserver: - callbacks: List[CallableObject] +class MagicContext(Filter): + """For the magic filters to work properly.""" - def __init__(self) -> None: - self.callbacks = [] + def __init__(self, magic_filter: MagicFilter) -> None: + self.magic_filter = magic_filter - def __iter__(self) -> Iterator[CallbackType]: - return iter(callback.callback for callback in self.callbacks) + async def __call__(self, **context: Any) -> Any: + return self.magic_filter.resolve(value=AttrDict(**context)) - def __len__(self) -> int: - return len(self.callbacks) - def __call__(self) -> Callable[[CallbackType], CallbackType]: - def wrapper(callback: CallbackType) -> CallbackType: - self.register(callback) +class EventObserver: + def __init__(self) -> None: + self.handlers: list[HandlerObject] = [] + + def __call__( + self, + *filters: Callable[..., Any], + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def wrapper(callback: Callable[..., Any]) -> Callable[..., Any]: + self.register(callback, *filters) return callback return wrapper - def register(self, *callbacks: CallbackType) -> Self: - if not callbacks: - raise ValueError("At least one callback must be provided to register.") - self.callbacks.extend(CallableObject(callback=callback) for callback in callbacks) + def register(self, callback: Callable[..., Any], *filters: Callable[..., Any]) -> Self: + filters_ = [ + FilterObject( + callback=( + MagicContext(magic_filter=filter_) # type: ignore[arg-type] + if isinstance(filter_, MagicFilter) + else filter_ + ), + ) + for filter_ in filters + ] + handler = HandlerObject(callback=callback, filters=filters_) + self.handlers.append(handler) return self diff --git a/aiogram_broadcaster/event/registry.py b/aiogram_broadcaster/event/registry.py deleted file mode 100644 index 0ad0528..0000000 --- a/aiogram_broadcaster/event/registry.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Dict, Optional - -from aiogram_broadcaster.utils.chain import Chain - -from .observer import EventObserver - - -class EventRegistry(Chain["EventRegistry"], sub_name="event"): - started: EventObserver - stopped: EventObserver - completed: EventObserver - before_sent: EventObserver - success_sent: EventObserver - failed_sent: EventObserver - observers: Dict[str, EventObserver] - - def __init__(self, name: Optional[str] = None) -> None: - super().__init__(name=name) - - self.started = EventObserver() - self.stopped = EventObserver() - self.completed = EventObserver() - self.before_sent = EventObserver() - self.success_sent = EventObserver() - self.failed_sent = EventObserver() - self.observers = { - "started": self.started, - "stopped": self.stopped, - "completed": self.completed, - "before_sent": self.before_sent, - "success_sent": self.success_sent, - "failed_sent": self.failed_sent, - } - - def __getitem__(self, item: str) -> EventObserver: - return self.observers[item] diff --git a/aiogram_broadcaster/intervals/__init__.py b/aiogram_broadcaster/intervals/__init__.py new file mode 100644 index 0000000..3edb065 --- /dev/null +++ b/aiogram_broadcaster/intervals/__init__.py @@ -0,0 +1,10 @@ +__all__ = ( + "BaseInterval", + "PeriodInterval", + "SimpleInterval", +) + + +from .base import BaseInterval +from .period import PeriodInterval +from .simple import SimpleInterval diff --git a/aiogram_broadcaster/intervals/base.py b/aiogram_broadcaster/intervals/base.py new file mode 100644 index 0000000..d64ef4b --- /dev/null +++ b/aiogram_broadcaster/intervals/base.py @@ -0,0 +1,26 @@ +from asyncio import Event +from typing import TYPE_CHECKING, Any + +from aiogram_broadcaster.utils.callable_model import CallableModel +from aiogram_broadcaster.utils.sleep import sleep +from aiogram_broadcaster.utils.union_model import UnionModel + + +if TYPE_CHECKING: + from abc import abstractmethod + + +class BaseInterval(UnionModel, CallableModel, register=False): + async def sleep(self, event: Event, /, **context: Any) -> bool: + if event.is_set(): + return False + delay = await self.call(**context) + if not delay: + return True + return await sleep(event=event, delay=delay) + + if TYPE_CHECKING: + + @abstractmethod + async def __call__(self, *args: Any, **kwargs: Any) -> float: + pass diff --git a/aiogram_broadcaster/intervals/period.py b/aiogram_broadcaster/intervals/period.py new file mode 100644 index 0000000..657c347 --- /dev/null +++ b/aiogram_broadcaster/intervals/period.py @@ -0,0 +1,33 @@ +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from pydantic import Field +from typing_extensions import Self + +from aiogram_broadcaster.mailer.mailer import Mailer + +from .base import BaseInterval + + +class PeriodInterval(BaseInterval): + period: float = Field(default=1, ge=1) + + @classmethod + def from_timedelta(cls, period: timedelta) -> Self: + return cls(period=period.total_seconds()) + + @classmethod + def from_datetime(cls, period: datetime) -> Self: + delta = period - datetime.now(tz=period.tzinfo) + return cls(period=delta.total_seconds()) + + async def __call__(self, mailer: Mailer) -> float: + return self.period / len(mailer.chats.total) + + if TYPE_CHECKING: + + def __init__( + self, + *, + period: float = ..., + ) -> None: ... diff --git a/aiogram_broadcaster/intervals/simple.py b/aiogram_broadcaster/intervals/simple.py new file mode 100644 index 0000000..4f70796 --- /dev/null +++ b/aiogram_broadcaster/intervals/simple.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING + +from pydantic import Field + +from .base import BaseInterval + + +class SimpleInterval(BaseInterval): + interval: float = Field(default=0, ge=0) + + async def __call__(self) -> float: + return self.interval + + if TYPE_CHECKING: + + def __init__( + self, + *, + interval: float = ..., + ) -> None: ... diff --git a/aiogram_broadcaster/mailer/__init__.py b/aiogram_broadcaster/mailer/__init__.py index 5237a1c..6a9c5cf 100644 --- a/aiogram_broadcaster/mailer/__init__.py +++ b/aiogram_broadcaster/mailer/__init__.py @@ -1,9 +1,8 @@ __all__ = ( - "DefaultMailerSettings", "Mailer", "MailerStatus", ) + from .mailer import Mailer -from .settings import DefaultMailerSettings from .status import MailerStatus diff --git a/aiogram_broadcaster/mailer/chat_engine.py b/aiogram_broadcaster/mailer/chat_engine.py deleted file mode 100644 index fbfcc13..0000000 --- a/aiogram_broadcaster/mailer/chat_engine.py +++ /dev/null @@ -1,90 +0,0 @@ -from enum import Enum, auto -from typing import TYPE_CHECKING, AsyncGenerator, DefaultDict, Iterable, Optional, Set - -from pydantic import BaseModel, ConfigDict -from typing_extensions import Self - - -if TYPE_CHECKING: - from aiogram_broadcaster.storages.base import BaseMailerStorage - - -class ChatState(str, Enum): - PENDING = auto() - SUCCESS = auto() - FAILED = auto() - - -class ChatsRegistry(BaseModel): - model_config = ConfigDict(validate_assignment=True) - - chats: DefaultDict[ChatState, Set[int]] - - @classmethod - def from_iterable( - cls, - chats: Iterable[int], - state: ChatState = ChatState.PENDING, - ) -> Self: - return cls(chats={state: chats}) - - -class ChatEngine: - registry: ChatsRegistry - mailer_id: Optional[int] - storage: Optional["BaseMailerStorage"] - - def __init__( - self, - registry: ChatsRegistry, - mailer_id: Optional[int] = None, - storage: Optional["BaseMailerStorage"] = None, - ) -> None: - self.registry = registry - self.mailer_id = mailer_id - self.storage = storage - - async def iterate_chats(self, state: ChatState) -> AsyncGenerator[int, None]: - while self.registry.chats[state]: - yield self.registry.chats[state].copy().pop() - - def get_chats(self, *states: ChatState) -> Set[int]: - if len(states) == 1: - return self.registry.chats[states[-1]] - chats = (self.registry.chats[state] for state in states or ChatState) - return set().union(*chats) - - async def add_chats(self, chats: Iterable[int], state: ChatState) -> Set[int]: - difference = set(chats) - self.get_chats() - if not difference: - return set() - self.registry.chats[state].update(difference) - await self._preserve() - return difference - - async def set_chats_state(self, state: ChatState) -> bool: - chats = self.get_chats() - if self.registry.chats[state] == chats: - return False - self.registry.chats.clear() - self.registry.chats[state] = chats - await self._preserve() - return True - - async def set_chat_state(self, chat: int, state: ChatState) -> None: - from_state = self._resolve_chat_state(chat=chat) - self.registry.chats[from_state].remove(chat) - self.registry.chats[state].add(chat) - await self._preserve() - - def _resolve_chat_state(self, chat: int) -> ChatState: - for state, chats in self.registry.chats.items(): - if chat in chats: - return state - raise LookupError(f"State of chat={chat} could not be resolved.") - - async def _preserve(self) -> None: - if not self.storage or not self.mailer_id: - return - async with self.storage.update(mailer_id=self.mailer_id) as record: - record.chats = self.registry diff --git a/aiogram_broadcaster/mailer/chats.py b/aiogram_broadcaster/mailer/chats.py new file mode 100644 index 0000000..af6cb07 --- /dev/null +++ b/aiogram_broadcaster/mailer/chats.py @@ -0,0 +1,129 @@ +from collections import defaultdict +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from enum import IntEnum, auto +from math import inf +from typing import SupportsInt + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + + +@dataclass(frozen=True) +class ChatsMetric: + ids: set[int] + + def __str__(self) -> str: + return str(len(self)) + + def __repr__(self) -> str: + return f"ChatsMetric(total={len(self)})" + + def __iter__(self) -> Iterator[int]: + return iter(self.ids) + + def __contains__(self, item: int) -> bool: + return item in self.ids + + def __bool__(self) -> bool: + return bool(self.ids) + + def __len__(self) -> int: + return len(self.ids) + + def __index__(self) -> int: + return len(self) + + def __int__(self) -> int: + return len(self) + + def __float__(self) -> float: + return len(self) + + def __lt__(self, other: SupportsInt) -> bool: + return int(self) < int(other) + + def __gt__(self, other: SupportsInt) -> bool: + return int(self) > int(other) + + def __le__(self, other: SupportsInt) -> bool: + return int(self) <= int(other) + + def __ge__(self, other: SupportsInt) -> bool: + return int(self) >= int(other) + + def __mod__(self, other: SupportsInt) -> float: # Same as ratio + return self.ratio(other=other) + + def __sub__(self, other: SupportsInt) -> int: # Same as difference + return self.difference(other=other) + + def __add__(self, other: SupportsInt) -> float: # Same as average + return self.average(other=other) + + def ratio(self, other: SupportsInt) -> float: + if int(other) == 0: + return inf + return (int(self) / int(other)) * 100 + + def difference(self, other: SupportsInt) -> int: + return abs(int(self) - int(other)) + + def average(self, other: SupportsInt) -> float: + return (int(self) + int(other)) / 2 + + +class ChatState(IntEnum): + PENDING = auto() + FAILED = auto() + SUCCESS = auto() + + +class Chats(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + registry: defaultdict[ChatState, set[int]] + + def __str__(self) -> str: + metrics = [f"{metric_name}={len(metric)}" for metric_name, metric in self.metrics.items()] + metrics_string = ", ".join(metrics) + return f"Chats({metrics_string})" + + @classmethod + def from_iterable(cls, iterable: Iterable[int]) -> Self: + return cls(registry={ChatState.PENDING: set(iterable)}) + + @property + def total(self) -> ChatsMetric: + chats = set().union(*(self.registry[state] for state in ChatState)) + return ChatsMetric(ids=chats) + + @property + def processed(self) -> ChatsMetric: + chats = set().union(self.registry[ChatState.FAILED], self.registry[ChatState.SUCCESS]) + return ChatsMetric(ids=chats) + + @property + def pending(self) -> ChatsMetric: + chats = self.registry[ChatState.PENDING].copy() + return ChatsMetric(ids=chats) + + @property + def failed(self) -> ChatsMetric: + chats = self.registry[ChatState.FAILED].copy() + return ChatsMetric(ids=chats) + + @property + def success(self) -> ChatsMetric: + chats = self.registry[ChatState.SUCCESS].copy() + return ChatsMetric(ids=chats) + + @property + def metrics(self) -> dict[str, ChatsMetric]: + return { + "total": self.total, + "processed": self.processed, + "pending": self.pending, + "failed": self.failed, + "success": self.success, + } diff --git a/aiogram_broadcaster/mailer/container.py b/aiogram_broadcaster/mailer/container.py index 51db9a2..d478173 100644 --- a/aiogram_broadcaster/mailer/container.py +++ b/aiogram_broadcaster/mailer/container.py @@ -1,56 +1,27 @@ -from types import MappingProxyType -from typing import Dict, Iterator, List, Mapping, Optional +from collections.abc import Iterator +from typing import Generic -from .mailer import Mailer -from .status import MailerStatus +from aiogram_broadcaster.contents.base import ContentType +from .mailer import Mailer -class MailerContainer: - _mailers: Dict[int, Mailer] - def __init__(self, *mailers: Mailer) -> None: - self._mailers = {mailer.id: mailer for mailer in mailers} +class MailerContainer(Generic[ContentType]): + def __init__(self, *mailers: Mailer[ContentType]) -> None: + self.mailers = {mailer.id: mailer for mailer in mailers} def __repr__(self) -> str: - return f"{type(self).__name__}(total_mailers={len(self)})" - - def __str__(self) -> str: mailers_string = ", ".join(map(repr, self)) return f"{type(self).__name__}[{mailers_string}]" - def __contains__(self, item: int) -> bool: - return item in self._mailers + def __iter__(self) -> Iterator[Mailer[ContentType]]: + return iter(self.mailers.values()) - def __getitem__(self, item: int) -> Mailer: - return self._mailers[item] + def __getitem__(self, item: int) -> Mailer[ContentType]: + return self.mailers[item] - def __iter__(self) -> Iterator[Mailer]: - return iter(self._mailers.values()) + def __contains__(self, item: int) -> bool: + return item in self.mailers def __len__(self) -> int: - return len(self._mailers) - - def __bool__(self) -> bool: - if not self._mailers: - return False - return all(self) - - def __hash__(self) -> int: - return hash(frozenset(self._mailers)) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, MailerContainer): - return False - return hash(self) == hash(other) - - @property - def mailers(self) -> Mapping[int, Mailer]: - return MappingProxyType(mapping=self._mailers) - - def get_mailer(self, mailer_id: int) -> Optional[Mailer]: - return self._mailers.get(mailer_id) - - def get_mailers(self, *statuses: MailerStatus) -> List[Mailer]: - if not statuses: - return list(self) - return [mailer for mailer in self if mailer.status in statuses] + return len(self.mailers) diff --git a/aiogram_broadcaster/mailer/group.py b/aiogram_broadcaster/mailer/group.py index 2fecd4a..dd3d2d3 100644 --- a/aiogram_broadcaster/mailer/group.py +++ b/aiogram_broadcaster/mailer/group.py @@ -1,48 +1,64 @@ -from asyncio import ensure_future, gather, wait -from typing import Any, Coroutine, Dict, Iterable, Optional, Set, Union +from asyncio import Task, gather +from collections.abc import Awaitable, Iterable +from typing import Any, Optional, TypeVar, Union + +from aiogram_broadcaster.contents.base import ContentType from .container import MailerContainer from .mailer import Mailer -class MailerGroup(MailerContainer): - def start(self) -> Dict[Mailer, Optional[Exception]]: - results: Dict[Mailer, Optional[Exception]] = {} +ReturnType = TypeVar("ReturnType") + + +class MailerGroup(MailerContainer[ContentType]): + async def delete(self) -> dict[Mailer[ContentType], Optional[BaseException]]: + return await self._emit(mailer.delete() for mailer in self) + + async def stop(self) -> dict[Mailer[ContentType], Optional[BaseException]]: + return await self._emit(mailer.stop() for mailer in self) + + def start(self) -> dict[Mailer[ContentType], Union[Task[bool], BaseException]]: + results: dict[Mailer[ContentType], Union[Task[bool], BaseException]] = {} for mailer in self: try: - mailer.start() - results[mailer] = None - except Exception as error: # noqa: BLE001, PERF203 + results[mailer] = mailer.start() + except BaseException as error: # noqa: BLE001, PERF203 results[mailer] = error return results - async def wait(self) -> None: - if not self._mailers: - return - await wait(ensure_future(mailer.wait()) for mailer in self) + async def start_and_wait(self) -> dict[Mailer[ContentType], Union[bool, BaseException]]: + return await self._emit(mailer.start() for mailer in self) - async def run(self) -> Dict[Mailer, Union[Exception, bool]]: - return await self._gather_targets(mailer.run() for mailer in self) - - async def stop(self) -> Dict[Mailer, Optional[Exception]]: - return await self._gather_targets(mailer.stop() for mailer in self) - - async def destroy(self) -> Dict[Mailer, Optional[Exception]]: - return await self._gather_targets(mailer.destroy() for mailer in self) - - async def add_chats(self, chats: Iterable[int]) -> Dict[Mailer, Union[Exception, Set[int]]]: - return await self._gather_targets(mailer.add_chats(chats=chats) for mailer in self) + async def extend( + self, + chats: Iterable[int], + ) -> dict[Mailer[ContentType], Union[set[int], BaseException]]: + return await self._emit(mailer.extend(chats=chats) for mailer in self) - async def reset_chats(self) -> Dict[Mailer, Union[Exception, bool]]: - return await self._gather_targets(mailer.reset_chats() for mailer in self) + async def reset(self) -> dict[Mailer[ContentType], Optional[BaseException]]: + return await self._emit(mailer.reset() for mailer in self) - async def send(self, chat_id: int) -> Dict[Mailer, Any]: - return await self._gather_targets(mailer.send(chat_id=chat_id) for mailer in self) + async def send( + self, + chat_id: int, + *, + disable_placeholders: bool = False, + disable_error_handling: bool = False, + ) -> dict[Mailer[ContentType], Union[tuple[bool, Any], BaseException]]: + return await self._emit( + mailer.send( + chat_id=chat_id, + disable_placeholders=disable_placeholders, + disable_error_handling=disable_error_handling, + ) + for mailer in self + ) - async def _gather_targets( + async def _emit( self, - targets: Iterable[Coroutine[Any, Any, Any]], - ) -> Dict[Mailer, Any]: + targets: Iterable[Awaitable[ReturnType]], + ) -> dict[Mailer[ContentType], Union[ReturnType, BaseException]]: if not targets: return {} results = await gather(*targets, return_exceptions=True) diff --git a/aiogram_broadcaster/mailer/mailer.py b/aiogram_broadcaster/mailer/mailer.py index 432f706..7af7cef 100644 --- a/aiogram_broadcaster/mailer/mailer.py +++ b/aiogram_broadcaster/mailer/mailer.py @@ -1,292 +1,309 @@ -from asyncio import Event, TimeoutError, wait_for -from typing import Any, Dict, Generic, Iterable, Optional, Set +from asyncio import Event, Task, create_task +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, Optional, cast from aiogram import Bot -from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter +from aiogram.exceptions import TelegramAPIError +from pydantic import JsonValue +from typing_extensions import Self from aiogram_broadcaster.contents.base import ContentType -from aiogram_broadcaster.event.manager import EventManager -from aiogram_broadcaster.placeholder.manager import PlaceholderManager -from aiogram_broadcaster.storages.base import BaseMailerStorage -from aiogram_broadcaster.utils import loggers - -from .chat_engine import ChatEngine, ChatsRegistry, ChatState -from .settings import MailerSettings -from .statistic import MailerStatistic +from aiogram_broadcaster.intervals.base import BaseInterval +from aiogram_broadcaster.storages.base import StorageRecord +from aiogram_broadcaster.utils.exceptions import ( + MailerDeleteError, + MailerExtendError, + MailerResetError, + MailerStartError, + MailerStopError, +) +from aiogram_broadcaster.utils.id_generator import generate_id +from aiogram_broadcaster.utils.logger import logger + +from .chats import Chats, ChatState from .status import MailerStatus -from .task_manager import TaskManager +if TYPE_CHECKING: + from aiogram_broadcaster.broadcaster import Broadcaster + + +@dataclass class Mailer(Generic[ContentType]): - _id: int - _settings: MailerSettings - _content: ContentType - _event: EventManager - _placeholder: PlaceholderManager - _storage: Optional[BaseMailerStorage] - _mailer_container: Dict[int, "Mailer"] - _bot: Bot - _context: Dict[str, Any] - _chat_engine: ChatEngine - _statistic: MailerStatistic - _task_manager: TaskManager - _status: MailerStatus + id: int + status: MailerStatus + chats: Chats + content: ContentType + interval: Optional[BaseInterval] + bot: Bot + context: dict[str, Any] + broadcaster: "Broadcaster" _stop_event: Event + _deleted: bool - def __init__( - self, - *, - id: int, # noqa: A002 - settings: MailerSettings, - chats: ChatsRegistry, + @classmethod + async def create( + cls, + broadcaster: "Broadcaster", + chats: Iterable[int], content: ContentType, - event: EventManager, - placeholder: PlaceholderManager, - storage: Optional[BaseMailerStorage], - mailer_container: Dict[int, "Mailer"], - bot: Bot, - context: Dict[str, Any], - ) -> None: - self._id = id - self._settings = settings - self._content = content - self._event = event - self._placeholder = placeholder - self._storage = storage - self._mailer_container = mailer_container - self._bot = bot - self._context = context - self._context.update(mailer=self, bot=self._bot) - - self._chat_engine = ChatEngine(registry=chats, mailer_id=self._id, storage=self._storage) - self._statistic = MailerStatistic(chat_engine=self._chat_engine) - self._task_manager = TaskManager() - self._status = self._resolve_status() - self._stop_event = Event() - self._stop_event.set() - - def __repr__(self) -> str: - return ( - f"Mailer(" - f"id={self._id}, " - f"status={self._status.name.lower()!r}, " - f"interval={self._settings.interval:.2f}, " - f"chats={self._statistic.processed_chats.total}/{self._statistic.total_chats.total}" - ")" + bot: Optional[Bot] = None, + interval: Optional[BaseInterval] = None, + **context: JsonValue, + ) -> Self: + if not chats: + raise ValueError("At least one chat must be provided.") + if not bot and not broadcaster.bots: + raise ValueError("At least one bot must be provided.") + mailer_id = generate_id(container=broadcaster) + chats_ = Chats.from_iterable(iterable=chats) + bot = bot or broadcaster.bots[-1] + stop_event = Event() + stop_event.set() + mailer = cls( + id=mailer_id, + status=MailerStatus.STOPPED, + chats=chats_, + content=content, + interval=interval, + bot=bot, + context=context.copy(), + broadcaster=broadcaster, + _stop_event=stop_event, + _deleted=False, ) + mailer.context.update( + broadcaster.context, + mailer=mailer, + bot=bot, + ) + record = StorageRecord( + chats=chats_, + content=content, + interval=interval, + bot_id=bot.id, + context=context, + ) + if broadcaster.storage: + await broadcaster.storage.set_record(mailer_id=mailer.id, record=record) + broadcaster.mailers[mailer.id] = cast(Mailer, mailer) + logger.info("Mailer id=%d was created.", mailer.id) + await broadcaster.event.emit_created(**mailer.context) + return mailer + + @classmethod + async def create_from_record( + cls, + broadcaster: "Broadcaster", + mailer_id: int, + record: StorageRecord, + ) -> Self: + try: + bot = {bot.id: bot for bot in broadcaster.bots}[record.bot_id] + except KeyError as error: + raise LookupError( + f"Mailer id {mailer_id} could not find bot with id {record.bot_id}, " + f"add the bot instance to Broadcaster.", + ) from error + status = ( + MailerStatus.STOPPED + if record.chats.registry[ChatState.PENDING] + else MailerStatus.COMPLETED + ) + stop_event = Event() + stop_event.set() + mailer = cls( + id=mailer_id, + status=status, + chats=record.chats, + content=cast(ContentType, record.content), + interval=record.interval, + bot=bot, + context=record.context.copy(), + broadcaster=broadcaster, + _stop_event=stop_event, + _deleted=False, + ) + mailer.context.update( + broadcaster.context, + mailer=mailer, + bot=bot, + ) + broadcaster.mailers[mailer.id] = cast(Mailer, mailer) + logger.info("Mailer id=%d was restored from storage.", mailer.id) + await broadcaster.event.emit_created(**mailer.context) + return mailer - def __str__(self) -> str: - return str(self._statistic) - - def __bool__(self) -> bool: - return self._status is MailerStatus.COMPLETED - - def __int__(self) -> int: - return self._id - - def __index__(self) -> int: - return self._id + def __repr__(self) -> str: + return f"Mailer(id={self.id}, status='{self.status.name.lower()}')" def __hash__(self) -> int: - return hash(self._id) + return hash(self.id) def __eq__(self, other: object) -> bool: if not isinstance(other, Mailer): - return False + return NotImplemented return hash(self) == hash(other) @property - def id(self) -> int: - return self._id + def can_deleted(self) -> bool: + return not self._deleted @property - def status(self) -> MailerStatus: - return self._status + def can_stopped(self) -> bool: + return not self._deleted and self.status is MailerStatus.STARTED @property - def settings(self) -> MailerSettings: - return self._settings + def can_started(self) -> bool: + return not self._deleted and self.status is MailerStatus.STOPPED @property - def statistic(self) -> MailerStatistic: - return self._statistic + def can_extended(self) -> bool: + return not self._deleted @property - def content(self) -> ContentType: - return self._content - - @property - def context(self) -> Dict[str, Any]: - return self._context - - @property - def bot(self) -> Bot: - return self._bot - - async def send(self, chat_id: int) -> Any: - method = await self._content.as_method(chat_id=chat_id, **self._context) - if self._settings.exclude_placeholders is not True: - method = await self._placeholder.render( - method, - self._settings.exclude_placeholders, - chat_id=chat_id, - **self._context, - ) - return await method.as_(bot=self._bot) - - async def add_chats(self, chats: Iterable[int]) -> Set[int]: - if self._status is MailerStatus.DESTROYED: - raise RuntimeError(f"Mailer id={self._id} cant be added the chats.") - new_chats = await self._chat_engine.add_chats(chats=chats, state=ChatState.PENDING) - if not new_chats: - return set() - if self._status is MailerStatus.COMPLETED: - self._status = MailerStatus.STOPPED - loggers.mailer.info("Mailer id=%d new %d chats added.", self._id, len(new_chats)) - return new_chats - - async def reset_chats(self) -> bool: - if self._status in {MailerStatus.STARTED, MailerStatus.DESTROYED}: - raise RuntimeError(f"Mailer id={self._id} cant be reset.") - is_reset = await self._chat_engine.set_chats_state(state=ChatState.PENDING) - if not is_reset: - return False - if self._status is MailerStatus.COMPLETED: - self._status = MailerStatus.STOPPED - loggers.mailer.info("Mailer id=%d has been reset.", self._id) - return is_reset + def can_reset(self) -> bool: + return ( + not self._deleted + and self.status is not MailerStatus.STARTED + and self.chats.total != self.chats.pending + ) - async def destroy(self) -> None: - if self._status in {MailerStatus.STARTED, MailerStatus.DESTROYED}: - raise RuntimeError(f"Mailer id={self._id} cant be destroyed.") - loggers.mailer.info("Mailer id=%d has been destroyed.", self._id) + async def delete(self) -> None: + if not self.can_deleted: + raise MailerDeleteError(mailer_id=self.id) + logger.info("Mailer id=%d was deleted.", self.id) + self.status = MailerStatus.COMPLETED self._stop_event.set() - self._status = MailerStatus.DESTROYED - if not self._settings.preserved: - return - del self._mailer_container[self._id] - if self._storage: - await self._storage.delete(mailer_id=self._id) + self._deleted = True + del self.broadcaster.mailers[self.id] + if self.broadcaster.storage: + await self.broadcaster.storage.delete_record(mailer_id=self.id) + await self.broadcaster.event.emit_deleted(**self.context) async def stop(self) -> None: - if self._status is not MailerStatus.STARTED: - raise RuntimeError(f"Mailer id={self._id} cant be stopped.") - loggers.mailer.info("Mailer id=%d is stopped.", self._id) + if not self.can_stopped: + raise MailerStopError(mailer_id=self.id) + logger.info("Mailer id=%d was stopped.", self.id) + self.status = MailerStatus.STOPPED self._stop_event.set() - self._status = MailerStatus.STOPPED - if not self._settings.disable_events: - await self._event.emit_stopped(**self._context) + await self.broadcaster.event.emit_stopped(**self.context) - async def run(self) -> bool: - if self._status is not MailerStatus.STOPPED: - raise RuntimeError(f"Mailer id={self._id} cant be started.") - loggers.mailer.info("Mailer id=%d is started.", self._id) + def start(self) -> Task[bool]: + return create_task(coro=self._start()) + + async def _start(self) -> bool: + if not self.can_started: + raise MailerStartError(mailer_id=self.id) + logger.info("Mailer id=%d was started.", self.id) + self.status = MailerStatus.STARTED self._stop_event.clear() - self._status = MailerStatus.STARTED - if not self._settings.disable_events: - await self._event.emit_started(**self._context) + await self.broadcaster.event.emit_started(**self.context) try: - completed = await self._broadcast() + completed = await self._process_chats() except: await self.stop() raise if not completed: return False - loggers.mailer.info("Mailer id=%d successfully completed.", self._id) + logger.info("Mailer id=%d was completed.", self.id) + self.status = MailerStatus.COMPLETED self._stop_event.set() - self._status = MailerStatus.COMPLETED - if self._settings.destroy_on_complete: - await self.destroy() - if not self._settings.disable_events: - await self._event.emit_completed(**self._context) - return True - - def start(self) -> None: - if self._status is not MailerStatus.STOPPED: - raise RuntimeError(f"Mailer id={self._id} cant be started.") - self._task_manager.start(target=self.run()) - - async def wait(self) -> None: - if not self._task_manager.started or self._task_manager.waited: - raise RuntimeError(f"Mailer id={self._id} cant be wait.") - await self._task_manager.wait() - - async def _broadcast(self) -> bool: - async for chat in self._chat_engine.iterate_chats(state=ChatState.PENDING): - if self._stop_event.is_set(): - return False - await self._send(chat_id=chat) - if not self._chat_engine.get_chats(ChatState.PENDING): - return True - if not self._settings.interval: - continue - if not await self._sleep(delay=self._settings.interval): - return False + await self.broadcaster.event.emit_completed(**self.context) return True - async def _send(self, chat_id: int) -> None: - if not self._settings.disable_events: - await self._event.emit_before_sent(chat_id=chat_id, **self._context) - try: - response = await self.send(chat_id=chat_id) - except TelegramRetryAfter as error: - if self._settings.handle_retry_after: - await self._process_retry_after(chat_id=chat_id, delay=error.retry_after) - else: - await self._process_failed_sent(chat_id=chat_id, error=error) - except TelegramAPIError as error: - await self._process_failed_sent(chat_id=chat_id, error=error) - else: - await self._process_success_sent(chat_id=chat_id, response=response) - - async def _process_retry_after(self, chat_id: int, delay: float) -> None: - loggers.mailer.info( - "Mailer id=%d waits %.2f seconds to resend a message to chat id=%d.", - self._id, - delay, - chat_id, + async def extend(self, chats: Iterable[int]) -> set[int]: + if not self.can_extended: + raise MailerExtendError(mailer_id=self.id) + difference = set(chats) - self.chats.total.ids + if not difference: + return difference + self.chats.registry[ChatState.PENDING].update(difference) + await self._preserve_chats() + if self.status is MailerStatus.COMPLETED: + self.status = MailerStatus.STOPPED + logger.info( + "Mailer id=%d has been updated with %d new chats.", + self.id, + len(difference), ) - if await self._sleep(delay=delay): - await self._send(chat_id=chat_id) - - async def _process_failed_sent(self, chat_id: int, error: Exception) -> None: - loggers.mailer.info( - "Mailer id=%d failed to send a message to chat id=%d, error: %s.", - self._id, - chat_id, - error, + return difference + + async def reset(self) -> None: + if not self.can_reset: + raise MailerResetError(mailer_id=self.id) + total_chats = self.chats.total.ids + self.chats.registry.clear() + self.chats.registry[ChatState.PENDING].update(total_chats) + await self._preserve_chats() + if self.status is MailerStatus.COMPLETED: + self.status = MailerStatus.STOPPED + logger.info("Mailer id=%d has been reset.") + + async def send( + self, + chat_id: int, + *, + disable_placeholders: bool = False, + disable_error_handling: bool = False, + ) -> tuple[bool, Any]: + method = await self.content.as_method( + chat_id=chat_id, + **self.context, ) - await self._chat_engine.set_chat_state(chat=chat_id, state=ChatState.FAILED) - if not self._settings.disable_events: - await self._event.emit_failed_sent( + method.as_(bot=self.bot) + if not disable_placeholders: + method = await self.broadcaster.placeholder.render( + method, + chat_id=chat_id, + **self.context, + ) + if disable_error_handling: + return True, await method + try: + response = await method + except TelegramAPIError as error: + logger.info( + "Mailer id=%d failed send the content to chat id=%d due to: %s.", + self.id, + chat_id, + error, + ) + await self.broadcaster.event.emit_failed_send( chat_id=chat_id, error=error, - **self._context, + **self.context, ) - - async def _process_success_sent(self, chat_id: int, response: Any) -> None: - loggers.mailer.info( - "Mailer id=%d successfully sent a message to chat id=%d.", - self._id, - chat_id, - ) - await self._chat_engine.set_chat_state(chat=chat_id, state=ChatState.SUCCESS) - if not self._settings.disable_events: - await self._event.emit_success_sent( + return False, error + else: + logger.info( + "Mailer id=%d success send the content to chat id=%d.", + self.id, + chat_id, + ) + await self.broadcaster.event.emit_success_send( chat_id=chat_id, response=response, - **self._context, + **self.context, ) + return True, response - async def _sleep(self, delay: float) -> bool: - try: - await wait_for(fut=self._stop_event.wait(), timeout=delay) - except TimeoutError: - return True - else: - return False + async def _process_chats(self) -> bool: + while self.chats.registry[ChatState.PENDING]: + if self._stop_event.is_set(): + return False + chat = self.chats.registry[ChatState.PENDING].pop() + success, _ = await self.send(chat_id=chat) + self.chats.registry[ChatState.SUCCESS if success else ChatState.FAILED].add(chat) + await self._preserve_chats() + if not self.chats.registry[ChatState.PENDING]: + return True + if self.interval: + await self.interval.sleep(self._stop_event, **self.context) + return True - def _resolve_status(self) -> MailerStatus: - if self._chat_engine.get_chats(ChatState.PENDING): - return MailerStatus.STOPPED - return MailerStatus.COMPLETED + async def _preserve_chats(self) -> None: + if self.broadcaster.storage: + async with self.broadcaster.storage.update_record(mailer_id=self.id) as record: + record.chats = self.chats diff --git a/aiogram_broadcaster/mailer/settings.py b/aiogram_broadcaster/mailer/settings.py deleted file mode 100644 index b0a9578..0000000 --- a/aiogram_broadcaster/mailer/settings.py +++ /dev/null @@ -1,70 +0,0 @@ -from dataclasses import dataclass -from sys import version_info -from typing import Any, Dict, Literal, Optional, Set, Union - -from pydantic import BaseModel, ConfigDict, Field - - -class MailerSettings(BaseModel): - model_config = ConfigDict( - frozen=True, - validate_assignment=True, - ) - - interval: float = Field(default=0, ge=0) - run_on_startup: bool = False - handle_retry_after: bool = False - destroy_on_complete: bool = False - disable_events: bool = False - exclude_placeholders: Optional[Union[Literal[True], Set[str]]] = None - preserved: bool = Field(default=True, exclude=True) - - -_dataclass_properties: Dict[str, Any] = {} -if version_info >= (3, 10): - _dataclass_properties.update(slots=True, kw_only=True) # pragma: no cover - - -@dataclass(**_dataclass_properties) -class DefaultMailerSettings: - interval: float = 0 - dynamic_interval: bool = False - run_on_startup: bool = False - handle_retry_after: bool = False - destroy_on_complete: bool = False - preserve: bool = False - - def __post_init__(self) -> None: - if self.interval < 0: - raise ValueError("The interval must be greater than or equal to 0.") - - def prepare( - self, - *, - interval: Optional[float] = None, - dynamic_interval: Optional[bool] = None, - run_on_startup: Optional[bool] = None, - handle_retry_after: Optional[bool] = None, - destroy_on_complete: Optional[bool] = None, - preserve: Optional[bool] = None, - ) -> "DefaultMailerSettings": - if interval is None: - interval = self.interval - if dynamic_interval is None: - dynamic_interval = self.dynamic_interval - if run_on_startup is None: - run_on_startup = self.run_on_startup - if handle_retry_after is None: - handle_retry_after = self.handle_retry_after - if destroy_on_complete is None: - destroy_on_complete = self.destroy_on_complete - if preserve is None: - preserve = self.preserve - return DefaultMailerSettings( - interval=interval, - dynamic_interval=dynamic_interval, - run_on_startup=run_on_startup, - handle_retry_after=handle_retry_after, - destroy_on_complete=destroy_on_complete, - preserve=preserve, - ) diff --git a/aiogram_broadcaster/mailer/statistic.py b/aiogram_broadcaster/mailer/statistic.py deleted file mode 100644 index f00ce50..0000000 --- a/aiogram_broadcaster/mailer/statistic.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Dict, Iterator, Set, SupportsInt - -from .chat_engine import ChatEngine, ChatState - - -class ChatsMetric: - ids: Set[int] - total: int - ratio: float - average: float - range: float - relative_range: float - metrics: Dict[str, float] - - def __init__(self, ids: Set[int], total: int) -> None: - self.ids = ids - self.total = len(ids) - self.ratio = (self.total / total) * 100 - self.average = (self.total + total) / 2 - self.range = abs(self.total - total) - self.relative_range = (self.range / self.average) * 100 - self.metrics = { - "total": self.total, - "ratio": self.ratio, - "average": self.average, - "range": self.range, - "relative_range": self.relative_range, - } - - def __repr__(self) -> str: - # fmt: off - metrics = ", ".join( - f"{metric_name}={metric}" - for metric_name, metric in self.metrics.items() - ) - # fmt: on - return f"ChatsStatistic({metrics})" - - def __str__(self) -> str: - # fmt: off - return ", ".join( - f"{metric_name.replace('_', ' ')}: {metric}" - for metric_name, metric in self.metrics.items() - ) - # fmt: on - - def __getitem__(self, item: str) -> float: - return self.metrics[item] - - def __iter__(self) -> Iterator[int]: - return iter(self.ids) - - def __contains__(self, item: int) -> bool: - return item in self.ids - - def __len__(self) -> int: - return self.total - - def __int__(self) -> int: - return self.total - - def __index__(self) -> int: - return self.total - - def __bool__(self) -> bool: - return self.range == 0 - - def __lt__(self, other: SupportsInt) -> bool: - return self.total < int(other) - - def __gt__(self, other: SupportsInt) -> bool: - return self.total > int(other) - - def __le__(self, other: SupportsInt) -> bool: - return self.total <= int(other) - - def __ge__(self, other: SupportsInt) -> bool: - return self.total >= int(other) - - -class MailerStatistic: - _chat_engine: ChatEngine - - def __init__(self, chat_engine: ChatEngine) -> None: - self._chat_engine = chat_engine - - def __repr__(self) -> str: - # fmt: off - metrics = ", ".join( - f"{metric_name}={metric.total}" - for metric_name, metric in self.metrics.items() - ) - # fmt: on - return f"MailerStatistic({metrics})" - - def __str__(self) -> str: - return "\n".join( - f"{metric_name.replace('_', ' ').capitalize()} - {metric}" - for metric_name, metric in self.metrics.items() - ) - - def __getitem__(self, item: str) -> ChatsMetric: - return self.metrics[item] - - @property - def total_chats(self) -> ChatsMetric: - chats = self._chat_engine.get_chats() - return ChatsMetric(ids=chats, total=len(chats)) - - @property - def pending_chats(self) -> ChatsMetric: - chats = self._chat_engine.get_chats(ChatState.PENDING) - return ChatsMetric(ids=chats, total=self.total_chats.total) - - @property - def success_chats(self) -> ChatsMetric: - chats = self._chat_engine.get_chats(ChatState.SUCCESS) - return ChatsMetric(ids=chats, total=self.total_chats.total) - - @property - def failed_chats(self) -> ChatsMetric: - chats = self._chat_engine.get_chats(ChatState.FAILED) - return ChatsMetric(ids=chats, total=self.total_chats.total) - - @property - def processed_chats(self) -> ChatsMetric: - chats = self._chat_engine.get_chats(ChatState.SUCCESS, ChatState.FAILED) - return ChatsMetric(ids=chats, total=self.total_chats.total) - - @property - def metrics(self) -> Dict[str, ChatsMetric]: - return { - "total_chats": self.total_chats, - "pending_chats": self.pending_chats, - "success_chats": self.success_chats, - "failed_chats": self.failed_chats, - "processed_chats": self.processed_chats, - } diff --git a/aiogram_broadcaster/mailer/status.py b/aiogram_broadcaster/mailer/status.py index 438b29d..71e98b0 100644 --- a/aiogram_broadcaster/mailer/status.py +++ b/aiogram_broadcaster/mailer/status.py @@ -2,7 +2,6 @@ class MailerStatus(Enum): - DESTROYED = auto() STOPPED = auto() STARTED = auto() COMPLETED = auto() diff --git a/aiogram_broadcaster/mailer/task_manager.py b/aiogram_broadcaster/mailer/task_manager.py deleted file mode 100644 index 4b02009..0000000 --- a/aiogram_broadcaster/mailer/task_manager.py +++ /dev/null @@ -1,41 +0,0 @@ -from asyncio import create_task -from typing import TYPE_CHECKING, Any, Coroutine, Optional - - -if TYPE_CHECKING: - from asyncio import Task - - -class TaskManager: - _task: Optional["Task[Any]"] - _waited: bool - - def __init__(self) -> None: - self._task = None - self._waited = False - - @property - def started(self) -> bool: - return self._task is not None - - @property - def waited(self) -> bool: - return self._waited - - def start(self, target: Coroutine[Any, Any, Any]) -> None: - if self._task: - raise RuntimeError("Task is already started.") - self._task = create_task(target) - self._task.add_done_callback(self._on_task_done) - - async def wait(self) -> None: - if not self._task: - raise RuntimeError("No task for wait.") - if self._waited: - raise RuntimeError("Task is already waited.") - self._waited = True - await self._task - - def _on_task_done(self, _: "Task[Any]") -> None: - self._task = None - self._waited = False diff --git a/aiogram_broadcaster/placeholder/__init__.py b/aiogram_broadcaster/placeholder/__init__.py index dba7df9..94a90a1 100644 --- a/aiogram_broadcaster/placeholder/__init__.py +++ b/aiogram_broadcaster/placeholder/__init__.py @@ -1,7 +1,4 @@ -__all__ = ( - "PlaceholderItem", - "PlaceholderRegistry", -) +__all__ = ("Placeholder",) -from .item import PlaceholderItem -from .registry import PlaceholderRegistry + +from .placeholder import Placeholder diff --git a/aiogram_broadcaster/placeholder/item.py b/aiogram_broadcaster/placeholder/item.py deleted file mode 100644 index 7754bc7..0000000 --- a/aiogram_broadcaster/placeholder/item.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Callable, ClassVar - -from .placeholder import Placeholder - - -class PlaceholderItem(ABC): - key: ClassVar[str] - - def __init_subclass__(cls, key: str, **kwargs: Any) -> None: - cls.key = key - super().__init_subclass__(**kwargs) - - def __repr__(self) -> str: - return f"{type(self).__name__}(key={self.key!r})" - - if TYPE_CHECKING: - __call__: Callable[..., Any] - else: - - @abstractmethod - async def __call__(self, **kwargs: Any) -> Any: - pass - - def as_placeholder(self) -> Placeholder: - return Placeholder(key=self.key, value=self.__call__) diff --git a/aiogram_broadcaster/placeholder/items/__init__.py b/aiogram_broadcaster/placeholder/items/__init__.py new file mode 100644 index 0000000..5e88ffe --- /dev/null +++ b/aiogram_broadcaster/placeholder/items/__init__.py @@ -0,0 +1,12 @@ +__all__ = ( + "BasePlaceholderItem", + "JinjaPlaceholderItem", + "RegexpPlaceholderItem", + "StringPlaceholderItem", +) + + +from .base import BasePlaceholderItem +from .jinja import JinjaPlaceholderItem +from .regexp import RegexpPlaceholderItem +from .string import StringPlaceholderItem diff --git a/aiogram_broadcaster/placeholder/items/base.py b/aiogram_broadcaster/placeholder/items/base.py new file mode 100644 index 0000000..a8a0df6 --- /dev/null +++ b/aiogram_broadcaster/placeholder/items/base.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Callable + +from aiogram.dispatcher.event.handler import CallableObject +from typing_extensions import Self + + +if TYPE_CHECKING: + from aiogram_broadcaster.placeholder.placeholder import Placeholder + + +class BasePlaceholderItem: + def __init__(self, value: Any) -> None: + self._value = CallableObject(callback=value) if callable(value) else value + + def __hash__(self) -> int: + return hash((type(self), repr(vars(self)))) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BasePlaceholderItem): + return NotImplemented + return hash(self) == hash(other) + + @property + def value(self) -> Any: + if isinstance(self._value, CallableObject): + return self._value.callback + return self._value + + async def get_value(self, **context: Any) -> Any: + if isinstance(self._value, CallableObject): + return await self._value.call(**context) + return self._value + + +class BasePlaceholderDecorator(ABC): + def __init__(self, placeholder: "Placeholder") -> None: + self._placeholder = placeholder + + def __call__( + self, + *args: Any, + **kwargs: Any, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def wrapper(callback: Callable[..., Any]) -> Callable[..., Any]: + self.register(callback, *args, **kwargs) + return callback + + return wrapper + + def register(self, *args: Any, **kwargs: Any) -> Self: + item = self.__item_class__(*args, **kwargs) + self._placeholder.register(item) + return self + + @property + @abstractmethod + def __item_class__(self) -> type[BasePlaceholderItem]: + pass + + +class BasePlaceholderEngine(ABC): + @abstractmethod + async def render(self, source: str, *items: Any, **context: Any) -> str: + pass diff --git a/aiogram_broadcaster/placeholder/items/jinja.py b/aiogram_broadcaster/placeholder/items/jinja.py new file mode 100644 index 0000000..f7be004 --- /dev/null +++ b/aiogram_broadcaster/placeholder/items/jinja.py @@ -0,0 +1,86 @@ +# ruff: noqa: PLC0415 + +from importlib import import_module +from typing import TYPE_CHECKING, Any, Callable + +from aiogram_broadcaster.utils.exceptions import DependencyNotFoundError + +from .base import BasePlaceholderDecorator, BasePlaceholderEngine, BasePlaceholderItem + + +if TYPE_CHECKING: + from jinja2 import Template + from typing_extensions import Self + + +class JinjaPlaceholderItem(BasePlaceholderItem): + def __init__(self, value: Any, name: str) -> None: + super().__init__(value=value) + + self.name = name + + try: + import_module(name="jinja2") + except ImportError as error: + raise DependencyNotFoundError( + feature_name="JinjaPlaceholderItem", + module_name="jinja2", + extra_name="jinja", + ) from error + + +class JinjaPlaceholderDecorator(BasePlaceholderDecorator): + __item_class__ = JinjaPlaceholderItem + + if TYPE_CHECKING: + + def __call__( + self, + name: str, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def register( + self, + value: Any, + name: str, + ) -> Self: ... + + +class JinjaPlaceholderEngine(BasePlaceholderEngine): + async def render(self, source: str, *items: JinjaPlaceholderItem, **context: Any) -> str: + from jinja2 import Template + + template = Template(source=source) + template_keys = self.get_template_keys(template=template, source=source) + if not template_keys: + return source + data = await self.get_data( + template_keys, + *items, + **context, + template=template, + ) + if not data: + return source + return template.render(data) + + def get_template_keys(self, template: "Template", source: str) -> set[str]: + from jinja2.meta import find_undeclared_variables + + node = template.environment.parse(source=source) + return find_undeclared_variables(ast=node) + + async def get_data( + self, + template_keys: set[str], + *items: JinjaPlaceholderItem, + **context: Any, + ) -> dict[str, Any]: + data = {} + for item in items: + if item.name in template_keys: + continue + value = await item.get_value(**context) + if value is not None: + data[item.name] = value + return data diff --git a/aiogram_broadcaster/placeholder/items/regexp.py b/aiogram_broadcaster/placeholder/items/regexp.py new file mode 100644 index 0000000..4c58f02 --- /dev/null +++ b/aiogram_broadcaster/placeholder/items/regexp.py @@ -0,0 +1,68 @@ +from enum import Enum +from re import Pattern, RegexFlag, compile +from typing import TYPE_CHECKING, Any, Callable, Union + +from .base import BasePlaceholderDecorator, BasePlaceholderEngine, BasePlaceholderItem + + +if TYPE_CHECKING: + from typing_extensions import Self + + +class RegexMode(str, Enum): + SEARCH = "search" + MATCH = "match" + FULLMATCH = "fullmatch" + FINDALL = "findall" + FINDITER = "finditer" + + +class RegexpPlaceholderItem(BasePlaceholderItem): + def __init__( + self, + value: Any, + pattern: Union[str, Pattern[str]], + flags: Union[int, RegexFlag] = 0, + mode: RegexMode = RegexMode.MATCH, + ) -> None: + super().__init__(value=value) + + self.pattern = ( + compile(pattern=pattern, flags=flags) if isinstance(pattern, str) else pattern + ) + self.flags = flags + self.mode = mode + + +class RegexpPlaceholderDecorator(BasePlaceholderDecorator): + __item_class__ = RegexpPlaceholderItem + + if TYPE_CHECKING: + + def __call__( + self, + pattern: Union[str, Pattern[str]], + flags: Union[int, RegexFlag] = ..., + mode: RegexMode = ..., + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def register( + self, + value: Any, + pattern: Union[str, Pattern[str]], + flags: Union[int, RegexFlag] = ..., + mode: RegexMode = ..., + ) -> Self: ... + + +class RegexpPlaceholderEngine(BasePlaceholderEngine): + async def render(self, source: str, *items: RegexpPlaceholderItem, **context: Any) -> str: + for item in items: + regex_method = getattr(item.pattern, item.mode.value) + match = regex_method(source) + if not match: + continue + value = await item.get_value(match=match, **context) + if value is not None: + source = item.pattern.sub(repl=value, string=source) + return source diff --git a/aiogram_broadcaster/placeholder/items/string.py b/aiogram_broadcaster/placeholder/items/string.py new file mode 100644 index 0000000..3801a48 --- /dev/null +++ b/aiogram_broadcaster/placeholder/items/string.py @@ -0,0 +1,67 @@ +from string import Template +from typing import TYPE_CHECKING, Any, Callable + +from .base import BasePlaceholderDecorator, BasePlaceholderEngine, BasePlaceholderItem + + +if TYPE_CHECKING: + from typing_extensions import Self + + +class StringPlaceholderItem(BasePlaceholderItem): + def __init__(self, value: Any, name: str) -> None: + super().__init__(value=value) + + self.name = name + + +class StringPlaceholderDecorator(BasePlaceholderDecorator): + __item_class__ = StringPlaceholderItem + + if TYPE_CHECKING: + + def __call__( + self, + name: str, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def register( + self, + value: Any, + name: str, + ) -> Self: ... + + +class StringPlaceholderEngine(BasePlaceholderEngine): + async def render(self, source: str, *items: StringPlaceholderItem, **context: Any) -> str: + template = Template(template=source) + template_keys = self.get_template_keys(template=template, source=source) + if not template_keys: + return source + data = await self.get_data( + template_keys, + *items, + **context, + template=template, + ) + if not data: + return source + return template.safe_substitute(data) + + def get_template_keys(self, template: Template, source: str) -> set[str]: + return {match.group("named") for match in template.pattern.finditer(string=source)} + + async def get_data( + self, + template_keys: set[str], + *items: StringPlaceholderItem, + **context: Any, + ) -> dict[str, Any]: + data = {} + for item in items: + if item.name in template_keys: + continue + value = await item.get_value(**context) + if value is not None: + data[item.name] = value + return data diff --git a/aiogram_broadcaster/placeholder/manager.py b/aiogram_broadcaster/placeholder/manager.py index 3c9f600..7acc93a 100644 --- a/aiogram_broadcaster/placeholder/manager.py +++ b/aiogram_broadcaster/placeholder/manager.py @@ -1,87 +1,59 @@ -from string import Template -from typing import ( - Any, - ClassVar, - Container, - Dict, - Iterator, - Optional, - Protocol, - Set, - Tuple, - TypeVar, -) +from collections import defaultdict +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, Optional, TypeVar from pydantic import BaseModel -from aiogram_broadcaster.utils.interrupt import suppress_interrupt +from .items.jinja import JinjaPlaceholderEngine, JinjaPlaceholderItem +from .items.regexp import RegexpPlaceholderEngine, RegexpPlaceholderItem +from .items.string import StringPlaceholderEngine, StringPlaceholderItem +from .placeholder import Placeholder -from .registry import PlaceholderRegistry +if TYPE_CHECKING: + from .items.base import BasePlaceholderEngine, BasePlaceholderItem -ModelType = TypeVar("ModelType", bound=BaseModel) +ModelType = TypeVar("ModelType", bound=BaseModel) -class Mappable(Protocol): - def __iter__(self) -> Iterator[Tuple[str, Any]]: ... +TEXT_FIELDS = {"text", "caption", "title", "description"} -class PlaceholderManager(PlaceholderRegistry): +class PlaceholderManager(Placeholder): __chain_root__ = True - TEXT_FIELDS: ClassVar[Set[str]] = {"caption", "text"} + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) - async def fetch_data(self, __select_keys: Container[str], /, **context: Any) -> Dict[str, Any]: - data: Dict[str, Any] = {} - if not __select_keys: - return data - for placeholder in self.chain_placeholders: - if placeholder.key not in __select_keys: - continue - with suppress_interrupt(): - data[placeholder.key] = await placeholder.get_value(**context) - return data - - def extract_text_field(self, model: Mappable) -> Optional[Tuple[str, str]]: - mapped_model = dict(model) - for field_name in self.TEXT_FIELDS: - if (field_value := mapped_model.get(field_name)) and isinstance(field_value, str): - return field_name, field_value - return None - - def extract_keys(self, template: Template) -> Set[str]: - # fmt: off - return { - match.group("named") - for match in template.pattern.finditer(string=template.template) + self.engines: dict[type[BasePlaceholderItem], BasePlaceholderEngine] = { + JinjaPlaceholderItem: JinjaPlaceholderEngine(), + RegexpPlaceholderItem: RegexpPlaceholderEngine(), + StringPlaceholderItem: StringPlaceholderEngine(), } - # fmt: on - async def render( - self, - __model: ModelType, - __exclude_keys: Optional[Set[str]] = None, - /, - **context: Any, - ) -> ModelType: - if __exclude_keys is None: - __exclude_keys = set() - self_keys = set(self.chain_keys) - if not self_keys: - return __model - if not self_keys - __exclude_keys: - return __model - field = self.extract_text_field(model=__model) - if not field: - return __model - field_name, field_value = field - template = Template(template=field_value) - template_keys = self.extract_keys(template=template) - select_keys = (self_keys & template_keys) - __exclude_keys - if not select_keys: - return __model - data = await self.fetch_data(select_keys, **context) - if not data: - return __model - rendered_field = {field_name: template.safe_substitute(data)} - return __model.model_copy(update=rendered_field) + async def render(self, model: ModelType, /, **context: Any) -> ModelType: + if not tuple(self.chain_items): + return model + for field_name, field_value in self._parse_text_fields(model=model): + rendered_field_value = await self._render_source(field_value, **context) + if rendered_field_value != field_value: + model = model.model_copy(update={field_name: rendered_field_value}) + return model + + def _parse_text_fields(self, model: BaseModel) -> Generator[tuple[str, str], None, None]: + mapped_model = dict(model) + for field_name in TEXT_FIELDS: + if field_value := mapped_model.get(field_name): + yield field_name, field_value + + async def _render_source(self, source: str, /, **context: Any) -> str: + grouped_items = defaultdict(set) + for item in self.chain_items: + grouped_items[type(item)].add(item) + for item_type, items in grouped_items.items(): + source = await self.engines[item_type].render( + source, + *items, + **context, + ) + return source diff --git a/aiogram_broadcaster/placeholder/placeholder.py b/aiogram_broadcaster/placeholder/placeholder.py index 8a18627..0f0a7f9 100644 --- a/aiogram_broadcaster/placeholder/placeholder.py +++ b/aiogram_broadcaster/placeholder/placeholder.py @@ -1,33 +1,41 @@ -from typing import Any +from collections.abc import Generator +from typing import TYPE_CHECKING, Optional -from aiogram.dispatcher.event.handler import CallableObject +from typing_extensions import Self +from aiogram_broadcaster.utils.chain import Chain -class Placeholder: - key: str - _value: Any +from .items.base import BasePlaceholderItem +from .items.jinja import JinjaPlaceholderDecorator +from .items.regexp import RegexpPlaceholderDecorator +from .items.string import StringPlaceholderDecorator - def __init__(self, key: str, value: Any) -> None: - self.key = key - if callable(value): - value = CallableObject(callback=value) - self._value = value - def __hash__(self) -> int: - return hash(self.key) +if TYPE_CHECKING: + from .items.base import BasePlaceholderDecorator - def __eq__(self, other: object) -> bool: - if not isinstance(other, Placeholder): - return False - return hash(self) == hash(other) + +class Placeholder(Chain["Placeholder"], sub_name="placeholder"): + def __init__(self, name: Optional[str] = None) -> None: + super().__init__(name=name) + + self.items: set[BasePlaceholderItem] = set() + self.jinja = JinjaPlaceholderDecorator(placeholder=self) + self.regexp = RegexpPlaceholderDecorator(placeholder=self) + self.string = StringPlaceholderDecorator(placeholder=self) + self.decorators: dict[str, BasePlaceholderDecorator] = { + "jinja": self.jinja, + "regexp": self.regexp, + "string": self.string, + } @property - def value(self) -> Any: - if isinstance(self._value, CallableObject): - return self._value.callback - return self._value - - async def get_value(self, **context: Any) -> Any: - if isinstance(self._value, CallableObject): - return await self._value.call(**context) - return self._value + def chain_items(self) -> Generator[BasePlaceholderItem, None, None]: + for placeholder in self.chain_tail: + yield from placeholder.items + + def register(self, *items: BasePlaceholderItem) -> Self: + if not items: + raise ValueError("At least one item must be provided to register.") + self.items.update(items) + return self diff --git a/aiogram_broadcaster/placeholder/registry.py b/aiogram_broadcaster/placeholder/registry.py deleted file mode 100644 index b9b4152..0000000 --- a/aiogram_broadcaster/placeholder/registry.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import Any, Callable, Generator, Iterator, Mapping, Optional, Set, Tuple - -from aiogram.dispatcher.event.handler import CallbackType -from typing_extensions import Self - -from aiogram_broadcaster.utils.chain import Chain - -from .item import PlaceholderItem -from .placeholder import Placeholder - - -class PlaceholderRegistry(Chain["PlaceholderRegistry"], sub_name="placeholder"): - placeholders: Set[Placeholder] - - def __init__(self, name: Optional[str] = None) -> None: - super().__init__(name=name) - - self.placeholders = set() - - def __setitem__(self, key: str, value: Any) -> None: - self.placeholders.add(Placeholder(key=key, value=value)) - - def __getitem__(self, item: str) -> Any: - return dict(self.chain_items)[item] - - def __iter__(self) -> Iterator[Tuple[str, Any]]: - return iter(self.chain_items) - - def __contains__(self, item: str) -> bool: - return item in self.chain_keys - - def __call__(self, key: str) -> Callable[[CallbackType], CallbackType]: - def wrapper(callback: CallbackType) -> CallbackType: - self[key] = callback - return callback - - return wrapper - - @property - def items(self) -> Tuple[Tuple[str, Any], ...]: - return tuple((placeholder.key, placeholder.value) for placeholder in self.placeholders) - - @property - def keys(self) -> Set[str]: - return {placeholder.key for placeholder in self.placeholders} - - @property - def chain_placeholders(self) -> Generator[Placeholder, None, None]: - for registry in self.chain_tail: - yield from registry.placeholders - - @property - def chain_items(self) -> Generator[Tuple[str, Any], None, None]: - for registry in self.chain_tail: - yield from registry.items - - @property - def chain_keys(self) -> Generator[str, None, None]: - for registry in self.chain_tail: - yield from registry.keys - - def register(self, *items: PlaceholderItem) -> Self: - if not items: - raise ValueError("At least one placeholder item must be provided to register.") - self.placeholders.update(item.as_placeholder() for item in items) - return self - - def add(self, __mapping: Optional[Mapping[str, Any]] = None, /, **kwargs: Any) -> Self: - if __mapping: - kwargs.update(__mapping) - if not kwargs: - raise ValueError("At least one argument must be provided.") - self.placeholders.update( - Placeholder(key=key, value=value) for key, value in kwargs.items() - ) - return self - - def _chain_bind(self, entity: "PlaceholderRegistry") -> None: - for self_registry in self.chain_tail: - for entity_registry in entity.chain_tail: - if collision_keys := self_registry.keys & entity_registry.keys: - raise RuntimeError( - f"Collision keys={list(collision_keys)!r} " - f"between PlaceholderRegistry(name={entity_registry.name!r}) " - f"and PlaceholderRegistry(name={self_registry.name!r}).", - ) - super()._chain_bind(entity=entity) diff --git a/aiogram_broadcaster/storages/__init__.py b/aiogram_broadcaster/storages/__init__.py index e69de29..542aff1 100644 --- a/aiogram_broadcaster/storages/__init__.py +++ b/aiogram_broadcaster/storages/__init__.py @@ -0,0 +1,31 @@ +# ruff: noqa: TCH004 + +__all__ = ( + "BaseStorage", + "FileStorage", + "MongoDBStorage", + "RedisStorage", + "SQLAlchemyStorage", +) + + +from typing import TYPE_CHECKING as _TYPE_CHECKING + +from aiogram_broadcaster.utils.lazy_importer import lazy_importer as _lazy_importer + +from .base import BaseStorage +from .file import FileStorage + + +if _TYPE_CHECKING: + from .mongodb import MongoDBStorage + from .redis import RedisStorage + from .sqlalchemy import SQLAlchemyStorage + + +__getattr__ = _lazy_importer( + package=__name__, + MongoDBStorage=".mongodb", + RedisStorage=".redis", + SQLAlchemyStorage=".sqlalchemy", +) diff --git a/aiogram_broadcaster/storages/base.py b/aiogram_broadcaster/storages/base.py index cb79f20..a5c5f1d 100644 --- a/aiogram_broadcaster/storages/base.py +++ b/aiogram_broadcaster/storages/base.py @@ -1,49 +1,50 @@ from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, AsyncIterable from contextlib import asynccontextmanager -from typing import AsyncGenerator, Dict, Set +from typing import Optional from pydantic import BaseModel, ConfigDict, Field, JsonValue, SerializeAsAny from aiogram_broadcaster.contents.base import BaseContent -from aiogram_broadcaster.mailer.chat_engine import ChatsRegistry -from aiogram_broadcaster.mailer.settings import MailerSettings +from aiogram_broadcaster.intervals.base import BaseInterval +from aiogram_broadcaster.mailer.chats import Chats class StorageRecord(BaseModel): model_config = ConfigDict(validate_assignment=True) + chats: Chats content: SerializeAsAny[BaseContent] - chats: ChatsRegistry - settings: MailerSettings + interval: Optional[SerializeAsAny[BaseInterval]] = None bot_id: int - context: Dict[str, SerializeAsAny[JsonValue]] = Field(default_factory=dict) + context: dict[str, JsonValue] = Field(default_factory=dict) -class BaseMailerStorage(ABC): +class BaseStorage(ABC): + @asynccontextmanager + async def update_record(self, mailer_id: int) -> AsyncGenerator[StorageRecord, None]: + record = await self.get_record(mailer_id=mailer_id) + try: + yield record + finally: + await self.set_record(mailer_id=mailer_id, record=record) + @abstractmethod - async def get_mailer_ids(self) -> Set[int]: + def get_records(self) -> AsyncIterable[tuple[int, StorageRecord]]: pass @abstractmethod - async def get(self, mailer_id: int) -> StorageRecord: + async def set_record(self, mailer_id: int, record: StorageRecord) -> None: pass @abstractmethod - async def set(self, mailer_id: int, record: StorageRecord) -> None: + async def get_record(self, mailer_id: int) -> StorageRecord: pass @abstractmethod - async def delete(self, mailer_id: int) -> None: + async def delete_record(self, mailer_id: int) -> None: pass - @asynccontextmanager - async def update(self, mailer_id: int) -> AsyncGenerator[StorageRecord, None]: - record = await self.get(mailer_id=mailer_id) - try: - yield record - finally: - await self.set(mailer_id=mailer_id, record=record) - @abstractmethod async def startup(self) -> None: pass diff --git a/aiogram_broadcaster/storages/file.py b/aiogram_broadcaster/storages/file.py index 46dc705..1d674fd 100644 --- a/aiogram_broadcaster/storages/file.py +++ b/aiogram_broadcaster/storages/file.py @@ -1,78 +1,73 @@ from asyncio import Lock +from collections.abc import AsyncIterable +from os import PathLike from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Union +from typing import Any, Optional, Union from aiofiles import open from pydantic import BaseModel, ConfigDict, Field -from .base import BaseMailerStorage, StorageRecord - - -if TYPE_CHECKING: - from os import PathLike +from .base import BaseStorage, StorageRecord class StorageRecords(BaseModel): model_config = ConfigDict(validate_assignment=True) - records: Dict[int, Any] = Field(default_factory=dict) - + records: dict[int, Any] = Field(default_factory=dict) -class FileMailerStorage(BaseMailerStorage): - file: Path - _lock: Optional[Lock] - def __init__(self, filename: Union[str, "PathLike[str]", Path] = ".mailers.json") -> None: +class FileStorage(BaseStorage): + def __init__(self, filename: Union[str, PathLike[str], Path] = ".mailers.json") -> None: self.file = Path(filename) - self._lock = None + self._lock: Optional[Lock] = None @property def lock(self) -> Lock: - """Lazy initialization to correctly catch the event loop.""" - if self._lock is None: + if not self._lock: self._lock = Lock() return self._lock - async def get_mailer_ids(self) -> Set[int]: + async def get_records(self) -> AsyncIterable[tuple[int, StorageRecord]]: async with self.lock: - result = await self.read() - return set(result.records) + records = await self.read_records() + for mailer_id, record in records.records.items(): + yield mailer_id, StorageRecord.model_validate(obj=record) - async def get(self, mailer_id: int) -> StorageRecord: + async def set_record(self, mailer_id: int, record: StorageRecord) -> None: async with self.lock: - result = await self.read() - return StorageRecord(**result.records[mailer_id]) + records = await self.read_records() + records.records[mailer_id] = record.model_dump(mode="json", exclude_defaults=True) + await self.write_records(records=records) - async def set(self, mailer_id: int, record: StorageRecord) -> None: + async def get_record(self, mailer_id: int) -> StorageRecord: async with self.lock: - result = await self.read() - result.records[mailer_id] = record.model_dump(mode="json", exclude_defaults=True) - await self.write(records=result) + records = await self.read_records() + return StorageRecord.model_validate(obj=records.records[mailer_id]) - async def delete(self, mailer_id: int) -> None: + async def delete_record(self, mailer_id: int) -> None: async with self.lock: - result = await self.read() - del result.records[mailer_id] - await self.write(records=result) - - async def read(self) -> StorageRecords: - async with open(file=self.file, mode="r", encoding="utf-8") as file: - data = await file.read() - return StorageRecords.model_validate_json(json_data=data) - - async def write(self, records: StorageRecords) -> None: - async with open(file=self.file, mode="w", encoding="utf-8") as file: - data = records.model_dump_json(exclude_defaults=True) - await file.write(data) + records = await self.read_records() + del records.records[mailer_id] + await self.write_records(records=records) async def startup(self) -> None: if self.file.exists(): if not self.file.is_file(): - raise RuntimeError(f"The filename '{self.file.name}' is not a file.") + raise RuntimeError(f"The file '{self.file.name}' is not a file.") if self.file.stat().st_size > 0: return - data = StorageRecords() - await self.write(records=data) + records = StorageRecords() + await self.write_records(records=records) async def shutdown(self) -> None: pass + + async def read_records(self) -> StorageRecords: + async with open(file=self.file, encoding="utf-8") as file: + data = await file.read() + return StorageRecords.model_validate_json(json_data=data) + + async def write_records(self, records: StorageRecords) -> None: + async with open(file=self.file, mode="w", encoding="utf-8") as file: + data = records.model_dump_json(exclude_defaults=True) + await file.write(data) diff --git a/aiogram_broadcaster/storages/mongodb.py b/aiogram_broadcaster/storages/mongodb.py index c036c00..d2d6f22 100644 --- a/aiogram_broadcaster/storages/mongodb.py +++ b/aiogram_broadcaster/storages/mongodb.py @@ -1,21 +1,28 @@ -from typing import Any, Set +from collections.abc import AsyncIterable, Mapping +from typing import Any, Optional -from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection from typing_extensions import Self -from .base import BaseMailerStorage, StorageRecord +from aiogram_broadcaster.utils.exceptions import DependencyNotFoundError +from .base import BaseStorage, StorageRecord + + +try: + from motor.motor_asyncio import AsyncIOMotorClient +except ImportError as error: + raise DependencyNotFoundError( + feature_name="MongoDBStorage", + module_name="motor", + extra_name="mongo", + ) from error -DEFAULT_DATABASE_NAME = "broadcaster" -DEFAULT_COLLECTION_NAME = "mailers" +DEFAULT_DATABASE_NAME = "aiogram_broadcaster" +DEFAULT_COLLECTION_NAME = "mailers" -class MongoDBMailerStorage(BaseMailerStorage): - client: AsyncIOMotorClient - database_name: str - collection_name: str - collection: AsyncIOMotorCollection +class MongoDBStorage(BaseStorage): def __init__( self, client: AsyncIOMotorClient, @@ -31,24 +38,18 @@ def __init__( def from_url( cls, url: str, + client_options: Optional[Mapping[str, Any]] = None, database_name: str = DEFAULT_DATABASE_NAME, collection_name: str = DEFAULT_COLLECTION_NAME, - **client_options: Any, ) -> Self: - client = AsyncIOMotorClient(url, **client_options) + client = AsyncIOMotorClient(host=url, **(client_options or {})) return cls(client=client, database_name=database_name, collection_name=collection_name) - async def get_mailer_ids(self) -> Set[int]: - result = await self.collection.distinct(key="_id") - return set(result) - - async def get(self, mailer_id: int) -> StorageRecord: - result = await self.collection.find_one(filter={"_id": mailer_id}) - if not result: - raise LookupError("Document not found.") - return StorageRecord(**result) + async def get_records(self) -> AsyncIterable[tuple[int, StorageRecord]]: + async for document in self.collection.find(): + yield document["_id"], StorageRecord.model_validate(obj=document) - async def set(self, mailer_id: int, record: StorageRecord) -> None: + async def set_record(self, mailer_id: int, record: StorageRecord) -> None: data = record.model_dump(mode="json", exclude_defaults=True) await self.collection.update_one( filter={"_id": mailer_id}, @@ -56,7 +57,13 @@ async def set(self, mailer_id: int, record: StorageRecord) -> None: upsert=True, ) - async def delete(self, mailer_id: int) -> None: + async def get_record(self, mailer_id: int) -> StorageRecord: + document = await self.collection.find_one(filter={"_id": mailer_id}) + if not document: + raise LookupError + return StorageRecord.model_validate(obj=document) + + async def delete_record(self, mailer_id: int) -> None: await self.collection.delete_one(filter={"_id": mailer_id}) async def startup(self) -> None: diff --git a/aiogram_broadcaster/storages/redis.py b/aiogram_broadcaster/storages/redis.py index 20b824f..465f468 100644 --- a/aiogram_broadcaster/storages/redis.py +++ b/aiogram_broadcaster/storages/redis.py @@ -1,20 +1,28 @@ -from typing import Any, Optional, Set, Union +from collections.abc import AsyncIterable, Mapping +from typing import Any, Optional, Union -from redis.asyncio import ConnectionPool, Redis from typing_extensions import Self -from .base import BaseMailerStorage, StorageRecord +from aiogram_broadcaster.utils.exceptions import DependencyNotFoundError + +from .base import BaseStorage, StorageRecord + + +try: + from redis.asyncio import ConnectionPool, Redis +except ImportError as error: + raise DependencyNotFoundError( + feature_name="RedisStorage", + module_name="redis", + extra_name="redis", + ) from error DEFAULT_KEY_PREFIX = "mailer" DEFAULT_KEY_SEPERATOR = ":" -class RedisMailerStorage(BaseMailerStorage): - redis: Redis - key_prefix: str - key_seperator: str - +class RedisStorage(BaseStorage): def __init__( self, redis: Redis, @@ -26,57 +34,58 @@ def __init__( self.key_seperator = key_seperator @classmethod - def from_pool( + def from_connection_pool( cls, - pool: ConnectionPool, + connection_pool: ConnectionPool, key_prefix: str = DEFAULT_KEY_PREFIX, key_seperator: str = DEFAULT_KEY_SEPERATOR, ) -> Self: - redis = Redis.from_pool(connection_pool=pool) + redis = Redis.from_pool(connection_pool=connection_pool) return cls(redis=redis, key_prefix=key_prefix, key_seperator=key_seperator) @classmethod def from_url( cls, url: str, + connection_options: Optional[Mapping[str, Any]] = None, key_prefix: str = DEFAULT_KEY_PREFIX, key_seperator: str = DEFAULT_KEY_SEPERATOR, - **connection_options: Any, ) -> Self: - connection_pool = ConnectionPool.from_url(url=url, **connection_options) - redis = Redis.from_pool(connection_pool=connection_pool) + connection_pool = ConnectionPool.from_url(url=url, **(connection_options or {})) + redis = Redis(connection_pool=connection_pool) return cls(redis=redis, key_prefix=key_prefix, key_seperator=key_seperator) - async def get_mailer_ids(self) -> Set[int]: + async def get_records(self) -> AsyncIterable[tuple[int, StorageRecord]]: pattern = self.build_key() - result = await self.redis.keys(pattern=pattern) - return set(map(self.parse_key, result)) + keys = await self.redis.keys(pattern=pattern) + for mailer_id in map(self.parse_key, keys): + yield mailer_id, await self.get_record(mailer_id=mailer_id) - async def get(self, mailer_id: int) -> StorageRecord: - key = self.build_key(mailer_id=mailer_id) - result = await self.redis.get(name=key) - return StorageRecord.model_validate_json(json_data=result) - - async def set(self, mailer_id: int, record: StorageRecord) -> None: + async def set_record(self, mailer_id: int, record: StorageRecord) -> None: key = self.build_key(mailer_id=mailer_id) data = record.model_dump_json(exclude_defaults=True) await self.redis.set(name=key, value=data) - async def delete(self, mailer_id: int) -> None: + async def get_record(self, mailer_id: int) -> StorageRecord: + key = self.build_key(mailer_id=mailer_id) + data = await self.redis.get(name=key) + return StorageRecord.model_validate_json(json_data=data) + + async def delete_record(self, mailer_id: int) -> None: key = self.build_key(mailer_id=mailer_id) await self.redis.delete(key) + async def startup(self) -> None: + pass + + async def shutdown(self) -> None: + await self.redis.aclose(close_connection_pool=True) + def build_key(self, mailer_id: Optional[int] = None) -> str: - pattern = (self.key_prefix, str(mailer_id or "*")) - return self.key_seperator.join(pattern) + key = (self.key_prefix, str(mailer_id or "*")) + return self.key_seperator.join(key) def parse_key(self, key: Union[bytes, str]) -> int: key_string = key.decode() if isinstance(key, bytes) else key _, mailer_id = key_string.split(self.key_seperator) return int(mailer_id) - - async def startup(self) -> None: - pass - - async def shutdown(self) -> None: - await self.redis.aclose(close_connection_pool=True) diff --git a/aiogram_broadcaster/storages/sqlalchemy.py b/aiogram_broadcaster/storages/sqlalchemy.py index f10c44f..1e1f140 100644 --- a/aiogram_broadcaster/storages/sqlalchemy.py +++ b/aiogram_broadcaster/storages/sqlalchemy.py @@ -1,37 +1,45 @@ -from typing import Any, Dict, Optional, Set, Union, cast - -from sqlalchemy import ( - URL, - BigInteger, - Column, - MetaData, - String, - Table, - delete, - insert, - select, - update, -) -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, - async_sessionmaker, - create_async_engine, -) -from typing_extensions import Self - -from .base import BaseMailerStorage, StorageRecord - - -DEFAULT_TABLE_NAME = "mailers" +from collections.abc import AsyncIterable, Mapping +from typing import Any, Optional, Union, cast +from typing_extensions import Self -class SQLAlchemyMailerStorage(BaseMailerStorage): - session_maker: async_sessionmaker[AsyncSession] - table_name: str - table: Table - +from aiogram_broadcaster.utils.exceptions import DependencyNotFoundError + +from .base import BaseStorage, StorageRecord + + +try: + from sqlalchemy import ( + URL, + BigInteger, + Column, + MetaData, + String, + Table, + delete, + insert, + select, + update, + ) + from sqlalchemy.exc import IntegrityError + from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, + ) +except ImportError as error: + raise DependencyNotFoundError( + feature_name="SQLAlchemyStorage", + module_name="sqlalchemy", + extra_name="sqlalchemy", + ) from error + + +DEFAULT_TABLE_NAME = "aiogram_broadcaster" + + +class SQLAlchemyStorage(BaseStorage): def __init__( self, session_maker: async_sessionmaker[AsyncSession], @@ -41,29 +49,40 @@ def __init__( self.table_name = table_name self.table = Table( - self.table_name, + table_name, MetaData(), - Column("id", BigInteger(), index=True, unique=True, nullable=False, primary_key=True), - Column("data", String(), nullable=False), + Column( + "id", + BigInteger(), + index=True, + unique=True, + nullable=False, + primary_key=True, + ), + Column( + "data", + String(), + nullable=False, + ), ) @classmethod def from_engine( cls, engine: AsyncEngine, + session_options: Optional[Mapping[str, Any]] = None, table_name: str = DEFAULT_TABLE_NAME, - **session_options: Any, ) -> Self: - session_maker = async_sessionmaker(bind=engine, **session_options) + session_maker = async_sessionmaker(bind=engine, **(session_options or {})) return cls(session_maker=session_maker, table_name=table_name) @classmethod def from_url( cls, url: Union[str, URL], + engine_options: Optional[Mapping[str, Any]] = None, + session_options: Optional[Mapping[str, Any]] = None, table_name: str = DEFAULT_TABLE_NAME, - engine_options: Optional[Dict[str, Any]] = None, - session_options: Optional[Dict[str, Any]] = None, ) -> Self: engine = create_async_engine(url=url, **(engine_options or {})) session_maker = async_sessionmaker(bind=engine, **(session_options or {})) @@ -73,19 +92,14 @@ def from_url( def engine(self) -> AsyncEngine: return cast(AsyncEngine, self.session_maker.kw["bind"]) - async def get_mailer_ids(self) -> Set[int]: - statement = select(self.table.c.id) + async def get_records(self) -> AsyncIterable[tuple[int, StorageRecord]]: + statement = select(self.table) async with self.session_maker() as session: result = await session.execute(statement=statement) - return set(result.scalars().all()) + for mailer_id, data in result.all(): + yield mailer_id, StorageRecord.model_validate_json(json_data=data) - async def get(self, mailer_id: int) -> StorageRecord: - statement = select(self.table.c.data).where(self.table.c.id == mailer_id) - async with self.session_maker() as session: - result = await session.execute(statement=statement) - return StorageRecord.model_validate_json(json_data=result.scalar_one()) - - async def set(self, mailer_id: int, record: StorageRecord) -> None: + async def set_record(self, mailer_id: int, record: StorageRecord) -> None: data = record.model_dump_json(exclude_defaults=True) insert_statement = insert(self.table).values(id=mailer_id, data=data) update_statement = update(self.table).where(self.table.c.id == mailer_id).values(data=data) @@ -97,7 +111,14 @@ async def set(self, mailer_id: int, record: StorageRecord) -> None: await session.execute(statement=update_statement) await session.commit() - async def delete(self, mailer_id: int) -> None: + async def get_record(self, mailer_id: int) -> StorageRecord: + statement = select(self.table.c.data).where(self.table.c.id == mailer_id) + async with self.session_maker() as session: + result = await session.execute(statement=statement) + data = result.scalar_one() + return StorageRecord.model_validate_json(json_data=data) + + async def delete_record(self, mailer_id: int) -> None: statement = delete(self.table).where(self.table.c.id == mailer_id) async with self.session_maker() as session: await session.execute(statement=statement) @@ -108,4 +129,4 @@ async def startup(self) -> None: await connection.run_sync(self.table.create, checkfirst=True) async def shutdown(self) -> None: - await self.engine.dispose(close=True) + await self.engine.dispose() diff --git a/aiogram_broadcaster/utils/callable_model.py b/aiogram_broadcaster/utils/callable_model.py new file mode 100644 index 0000000..433029f --- /dev/null +++ b/aiogram_broadcaster/utils/callable_model.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Any + +from aiogram.dispatcher.event.handler import CallableObject +from pydantic import BaseModel + + +class CallableModel(BaseModel, ABC): + _callback: CallableObject + + @abstractmethod + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + pass + + async def call(self, **context: Any) -> Any: + return await self._callback.call(**context) + + def model_post_init(self, __context: Any) -> None: # noqa: PYI063 + super().model_post_init(__context) + self._callback = CallableObject(callback=self.__call__) diff --git a/aiogram_broadcaster/utils/chain.py b/aiogram_broadcaster/utils/chain.py index 1be014f..84d0502 100644 --- a/aiogram_broadcaster/utils/chain.py +++ b/aiogram_broadcaster/utils/chain.py @@ -1,90 +1,65 @@ -from typing import Any, ClassVar, Generator, Generic, List, Optional, Type, TypeVar +from collections.abc import Generator +from typing import Any, ClassVar, Generic, Optional, TypeVar +from typing_extensions import Self -EntityType = TypeVar("EntityType", bound="Chain[Any]") +ChainType = TypeVar("ChainType", bound="Chain[Any]") -class Chain(Generic[EntityType]): - __chain_entity__: Type[EntityType] - __chain_sub_name__: ClassVar[str] + +class ChainBindError(Exception): + pass + + +class Chain(Generic[ChainType]): + __chain_object__: ChainType __chain_root__: ClassVar[bool] - name: str - head: Optional[EntityType] - tail: List[EntityType] + __chain_sub_name__: ClassVar[str] - def __init_subclass__(cls, sub_name: Optional[str] = None, **kwargs: Any) -> None: - if not hasattr(cls, "__chain_entity__"): + def __init_subclass__(cls, **kwargs: Any) -> None: + if not hasattr(cls, "__chain_object__"): + cls.__chain_object__ = cls cls.__chain_root__ = False - cls.__chain_entity__ = cls - cls.__chain_sub_name__ = sub_name or cls.__name__.lower() + cls.__chain_sub_name__ = kwargs.pop("sub_name", cls.__name__.lower()) super().__init_subclass__(**kwargs) def __init__(self, name: Optional[str] = None) -> None: - self.name = name or hex(id(self)) - self.head = None - self.tail = [] + self.name = hex(id(self)) if name is None else name + self.head: Optional[ChainType] = None + self.tail: list[ChainType] = [] def __repr__(self) -> str: - fields = [] - fields.append(f"name={self.name!r}") - if self.tail: - fields.append(f"nested={list(self.tail)!r}") - fields_sting = ", ".join(fields) - return f"{type(self).__name__}({fields_sting})" - - def __str__(self) -> str: - fields = [] - fields.append(f"name={self.name!r}") - if self.head: - fields.append(f"parent={self.head!s}") - fields_sting = ", ".join(fields) - return f"{type(self).__name__}({fields_sting})" + return f"{type(self).__name__}(name='{self.name}')" @property - def chain_head(self: EntityType) -> Generator[EntityType, None, None]: - entity: Optional[EntityType] = self - while entity: - yield entity - entity = entity.head + def chain_head(self: ChainType) -> Generator[ChainType, None, None]: + chain: Optional[ChainType] = self + while chain: + yield chain + chain = chain.head @property - def chain_tail(self: EntityType) -> Generator[EntityType, None, None]: + def chain_tail(self: ChainType) -> Generator[ChainType, None, None]: yield self - for entity in self.tail: - yield from entity.chain_tail + for chain in self.tail: + yield from chain.chain_tail - def bind(self: EntityType, *entities: EntityType) -> EntityType: - if not entities: - raise ValueError( - f"At least one {self.__chain_sub_name__} must be provided to bind.", - ) - for entity in entities: - if not isinstance(entity, self.__chain_entity__): - raise TypeError( - f"The {self.__chain_sub_name__} must be an instance of " - f"{self.__chain_entity__.__name__}, not a {type(entity).__name__}.", + def bind(self, *chains: ChainType) -> Self: + if not chains: + raise ValueError(f"At least one {self.__chain_sub_name__} must be provided to bind.") + for chain in chains: + if self == chain: + raise ChainBindError(f"Cannot bind a {chain} to itself.") + if chain.head: + raise ChainBindError(f"The {chain} is already bound to {chain.head}.") + if chain in self.chain_head: + raise ChainBindError( + f"The {chain} is already part of {self.__chain_sub_name__} sequence.", ) - entity._chain_bind(entity=self) # noqa: SLF001 - return entities[-1] if len(entities) == 1 else self - - def _chain_bind(self: EntityType, entity: EntityType) -> None: - if self == entity: - raise ValueError( - f"Cannot bind the {self.__chain_sub_name__} on itself.", - ) - if self.head: - raise RuntimeError( - f"The {self.__chain_entity__.__name__}(name={self.name!r}) is already attached to " - f"{self.__chain_entity__.__name__}(name={self.head.name!r}).", - ) - if self in entity.chain_head: - raise RuntimeError( - "Circular referencing detected.", - ) - if self.__chain_root__: - raise RuntimeError( - f"{type(self).__name__}(name={self.name!r}) cannot be attached to " - f"another {self.__chain_sub_name__}.", - ) - self.head = entity - entity.tail.append(self) + if chain.__chain_root__: + raise ChainBindError( + f"Cannot bind the {chain} to another {self.__chain_sub_name__}.", + ) + chain.head = self + self.tail.append(chain) + return self diff --git a/aiogram_broadcaster/utils/exceptions.py b/aiogram_broadcaster/utils/exceptions.py new file mode 100644 index 0000000..b8a14c0 --- /dev/null +++ b/aiogram_broadcaster/utils/exceptions.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import ClassVar + + +class BroadcasterError(Exception): + pass + + +class DetailedBroadcasterError(BroadcasterError): + message: ClassVar[str] + + def __str__(self) -> str: + return self.message.format_map(vars(self)) + + +@dataclass +class DependencyNotFoundError(DetailedBroadcasterError, ImportError): + message = ( + "The required dependency '{module_name}' for '{feature_name}' was not found. " + "To install it, run: `pip install {module_name}` " + "or via extra: `pip install aiogram_broadcaster[{extra_name}]`." + ) + + feature_name: str + module_name: str + extra_name: str + + +@dataclass +class MailerError(DetailedBroadcasterError, RuntimeError): + mailer_id: int + + +class MailerStopError(MailerError): + message = "Mailer id {mailer_id} cannot be stopped." + + +class MailerStartError(MailerError): + message = "Mailer id {mailer_id} cannot be started." + + +class MailerDeleteError(MailerError): + message = "Mailer id {mailer_id} cannot be deleted." + + +class MailerExtendError(MailerError): + message = "Mailer id {mailer_id} cannot be extended." + + +class MailerResetError(MailerError): + message = "Mailer id {mailer_id} cannot be reset." diff --git a/aiogram_broadcaster/utils/id_generator.py b/aiogram_broadcaster/utils/id_generator.py new file mode 100644 index 0000000..e24a38d --- /dev/null +++ b/aiogram_broadcaster/utils/id_generator.py @@ -0,0 +1,13 @@ +from typing import Optional, Protocol +from uuid import uuid4 + + +class IntContainer(Protocol): + def __contains__(self, item: int) -> bool: ... + + +def generate_id(container: Optional[IntContainer] = None) -> int: + while True: + new_id = hash(uuid4()) + if not container or new_id not in container: + return new_id diff --git a/aiogram_broadcaster/utils/interrupt.py b/aiogram_broadcaster/utils/interrupt.py deleted file mode 100644 index e88b09b..0000000 --- a/aiogram_broadcaster/utils/interrupt.py +++ /dev/null @@ -1,21 +0,0 @@ -from contextlib import suppress -from functools import partial -from typing import NoReturn - -from aiogram.dispatcher.event.bases import CancelHandler, SkipHandler - - -class Interrupt(Exception): # noqa: N818 - pass - - -def interrupt() -> NoReturn: - raise Interrupt - - -suppress_interrupt = partial( - suppress, - Interrupt, - CancelHandler, - SkipHandler, -) diff --git a/aiogram_broadcaster/utils/lazy_importer.py b/aiogram_broadcaster/utils/lazy_importer.py new file mode 100644 index 0000000..947ef4c --- /dev/null +++ b/aiogram_broadcaster/utils/lazy_importer.py @@ -0,0 +1,12 @@ +from importlib import import_module +from typing import Any, Callable + + +def lazy_importer(package: str, **targets: str) -> Callable[[str], Any]: + def wrapper(name: str) -> Any: + if name not in targets: + raise AttributeError + module = import_module(name=targets[name], package=package) + return getattr(module, name) + + return wrapper diff --git a/aiogram_broadcaster/utils/logger.py b/aiogram_broadcaster/utils/logger.py new file mode 100644 index 0000000..2432d93 --- /dev/null +++ b/aiogram_broadcaster/utils/logger.py @@ -0,0 +1,4 @@ +from logging import getLogger + + +logger = getLogger(name="aiogram_broadcaster") diff --git a/aiogram_broadcaster/utils/loggers.py b/aiogram_broadcaster/utils/loggers.py deleted file mode 100644 index e35072f..0000000 --- a/aiogram_broadcaster/utils/loggers.py +++ /dev/null @@ -1,5 +0,0 @@ -from logging import getLogger - - -pool = getLogger(name="aiogram_broadcaster.pool") -mailer = getLogger(name="aiogram_broadcaster.mailer") diff --git a/aiogram_broadcaster/utils/retry_after_handler.py b/aiogram_broadcaster/utils/retry_after_handler.py new file mode 100644 index 0000000..8f7fa67 --- /dev/null +++ b/aiogram_broadcaster/utils/retry_after_handler.py @@ -0,0 +1,27 @@ +from aiogram import F +from aiogram.exceptions import TelegramRetryAfter + +from aiogram_broadcaster.event.event import Event +from aiogram_broadcaster.mailer.mailer import Mailer +from aiogram_broadcaster.utils.logger import logger +from aiogram_broadcaster.utils.sleep import sleep + + +async def handle_retry_after(mailer: Mailer, chat_id: int, delay: float) -> None: + logger.info( + "Mailer id=%d waiting %.2f seconds to resend the content to chat id=%d.", + mailer.id, + delay, + chat_id, + ) + if await sleep(event=mailer._stop_event, delay=delay): # noqa: SLF001 + await mailer.send(chat_id=chat_id) + + +def setup_retry_after_handler(event: Event) -> Event: + event.failed_send.register( + handle_retry_after, + F.error.cast(type).is_(TelegramRetryAfter), + F.error.retry_after.as_("delay"), + ) + return event diff --git a/aiogram_broadcaster/utils/sleep.py b/aiogram_broadcaster/utils/sleep.py new file mode 100644 index 0000000..5500c7f --- /dev/null +++ b/aiogram_broadcaster/utils/sleep.py @@ -0,0 +1,12 @@ +from asyncio import Event, TimeoutError, wait_for + + +async def sleep(event: Event, delay: float) -> bool: + if event.is_set(): + return False + try: + await wait_for(fut=event.wait(), timeout=delay) + except TimeoutError: + return True + else: + return False diff --git a/aiogram_broadcaster/utils/union_model.py b/aiogram_broadcaster/utils/union_model.py new file mode 100644 index 0000000..5363e30 --- /dev/null +++ b/aiogram_broadcaster/utils/union_model.py @@ -0,0 +1,55 @@ +from typing import Any, ClassVar + +from pydantic import ( + BaseModel, + SerializerFunctionWrapHandler, + ValidatorFunctionWrapHandler, + model_serializer, + model_validator, +) +from typing_extensions import Self + + +VALIDATOR_KEY = "__V" + + +class UnionModel(BaseModel): + _registry: ClassVar[dict[str, type[BaseModel]]] = {} + + def __init_subclass__(cls, **kwargs: Any) -> None: + if kwargs.pop("register", True): + cls.register() + super().__init_subclass__(**kwargs) + + @classmethod + def register(cls) -> type[Self]: + cls._registry[cls.__name__] = cls + return cls + + @classmethod + def unregister(cls) -> type[Self]: + cls._registry.pop(cls.__name__, None) + return cls + + @classmethod + def is_registered(cls) -> bool: + return cls.__name__ in cls._registry + + @model_validator(mode="wrap") + @classmethod + def _model_validator(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + if not isinstance(value, dict): + return handler(value) + validator_name = value.pop(VALIDATOR_KEY, None) + if validator_name is None: + return handler(value) + validator = cls._registry.get(validator_name) + if not validator: + raise ValueError(f"{validator_name} is not registered.") + return validator.model_validate(obj=value) + + @model_serializer(mode="wrap", return_type=Any) + def _model_serializer(self, handler: SerializerFunctionWrapHandler) -> Any: + data = handler(self) + data[VALIDATOR_KEY] = type(self).__name__ + return data diff --git a/tests/test_contents/__init__.py b/butcher/__init__.py similarity index 100% rename from tests/test_contents/__init__.py rename to butcher/__init__.py diff --git a/butcher/__main__.py b/butcher/__main__.py new file mode 100644 index 0000000..75d498f --- /dev/null +++ b/butcher/__main__.py @@ -0,0 +1,35 @@ +import typing +from pathlib import Path + +import aiogram + +from .parser import parse_nodes +from .targets import TARGETS +from .template import INIT_TEMPLATE, TEMPLATE + + +CONTENTS_PATH = Path(__file__).parent.parent / "aiogram_broadcaster" / "contents" + + +def main() -> None: + for target in TARGETS: + content = TEMPLATE.render( + nodes=list(parse_nodes(obj=target.method)), + typing_all=typing.__all__, + aiogram_types_all=aiogram.types.__all__, + aiogram_methods_all=aiogram.methods.__all__, + content_name=target.content_name, + call_result=target.call_result, + method_exec=target.method_exec, + put_model_extra=target.put_model_extra, + model_extra_nodes=target.model_extra_nodes, + ) + content_file = CONTENTS_PATH / (target.content_file_name + ".py") + content_file.write_text(data=content) + content = INIT_TEMPLATE.render(targets=TARGETS) + init_file = CONTENTS_PATH / "__init__.py" + init_file.write_text(data=content) + + +if __name__ == "__main__": + main() diff --git a/butcher/normalizer.py b/butcher/normalizer.py new file mode 100644 index 0000000..80a675b --- /dev/null +++ b/butcher/normalizer.py @@ -0,0 +1,49 @@ +import typing +from typing import Any, Literal, Optional, Union, _SpecialForm, get_args, get_origin + +import aiogram + + +def normalize_type(annotation: Any) -> str: + if isinstance(annotation, str): + annotation = eval( + annotation, + { + **typing.__dict__, + **aiogram.types.__dict__, + "Default": aiogram.client.default.Default, + }, + ) + + origin = get_origin(annotation) + args = get_args(annotation) + + if annotation is Ellipsis: + return "..." + + if origin is set or origin is frozenset or origin is list: + return f"{origin.__name__.capitalize()}[{normalize_type(annotation=args[0])}]" + + if origin is tuple: + return f"Tuple[{', '.join(map(normalize_type, args))}]" + + if origin is dict: + return f"Dict[{normalize_type(annotation=args[0])}, {normalize_type(annotation=args[1])}]" + + if origin is Optional: + return f"Optional[{normalize_type(annotation=args[0])}]" + + if origin is Union: + if type(None) in args: + without_none_args = tuple(arg for arg in args if arg is not type(None)) + return f"Optional[{normalize_type(Union[without_none_args])}]" + return f"Union[{', '.join(map(normalize_type, args))}]" + + if origin is Literal: + return f"Literal[{', '.join(map(repr, args))}]" + + if isinstance(annotation, _SpecialForm): + return annotation._name + if hasattr(annotation, "__name__"): + return annotation.__name__ + return str(annotation) diff --git a/butcher/parser.py b/butcher/parser.py new file mode 100644 index 0000000..43dc324 --- /dev/null +++ b/butcher/parser.py @@ -0,0 +1,36 @@ +from collections.abc import Generator +from inspect import signature +from typing import Any, NamedTuple, Optional + +from .normalizer import normalize_type + + +EXCLUDED_NAMES = { + "self", + "extra_data", + "kwargs", + "chat_id", + "message_thread_id", + "reply_parameters", + "allow_sending_without_reply", + "reply_to_message_id", + "disable_web_page_preview", +} + + +class FieldNode(NamedTuple): + name: str + annotation: str + default: Optional[str] = None + + +def parse_nodes(obj: Any) -> Generator[FieldNode, None, None]: + parameters = signature(obj=obj).parameters + for name, param in parameters.items(): + if name in EXCLUDED_NAMES: + continue + yield FieldNode( + name=name, + annotation=normalize_type(annotation=param.annotation), + default=None if param.default is param.empty else str(param.default), + ) diff --git a/butcher/targets.py b/butcher/targets.py new file mode 100644 index 0000000..7119210 --- /dev/null +++ b/butcher/targets.py @@ -0,0 +1,230 @@ +from typing import Any, NamedTuple, Optional + +from aiogram.methods import ( + CopyMessage, + CopyMessages, + ForwardMessage, + ForwardMessages, + SendAnimation, + SendAudio, + SendChatAction, + SendContact, + SendDice, + SendDocument, + SendGame, + SendInvoice, + SendLocation, + SendMediaGroup, + SendMessage, + SendPaidMedia, + SendPhoto, + SendPoll, + SendSticker, + SendVenue, + SendVideo, + SendVideoNote, + SendVoice, +) +from aiogram.types import Message + +from .parser import FieldNode + + +class Target(NamedTuple): + method: Any + content_name: str + content_file_name: str + call_result: str + method_exec: str + put_model_extra: bool = True + model_extra_nodes: Optional[list[FieldNode]] = None + + +TARGETS = ( + Target( + method=CopyMessage, + content_name="FromChatCopyMessageContent", + content_file_name="from_chat_copy_message", + call_result="CopyMessage", + method_exec="CopyMessage", + ), + Target( + method=CopyMessages, + content_name="FromChatCopyMessagesContent", + content_file_name="from_chat_copy_messages", + call_result="CopyMessages", + method_exec="CopyMessages", + ), + Target( + method=ForwardMessage, + content_name="FromChatForwardMessageContent", + content_file_name="from_chat_forward_message", + call_result="ForwardMessage", + method_exec="ForwardMessage", + ), + Target( + method=ForwardMessages, + content_name="FromChatForwardMessagesContent", + content_file_name="from_chat_forward_messages", + call_result="ForwardMessages", + method_exec="ForwardMessages", + ), + Target( + method=SendAnimation, + content_name="AnimationContent", + content_file_name="animation", + call_result="SendAnimation", + method_exec="SendAnimation", + ), + Target( + method=SendAudio, + content_name="AudioContent", + content_file_name="audio", + call_result="SendAudio", + method_exec="SendAudio", + ), + Target( + method=SendChatAction, + content_name="ChatActionContent", + content_file_name="chat_action", + call_result="SendChatAction", + method_exec="SendChatAction", + ), + Target( + method=SendContact, + content_name="ContactContent", + content_file_name="contact", + call_result="SendContact", + method_exec="SendContact", + ), + Target( + method=SendDice, + content_name="DiceContent", + content_file_name="dice", + call_result="SendDice", + method_exec="SendDice", + ), + Target( + method=SendDocument, + content_name="DocumentContent", + content_file_name="document", + call_result="SendDocument", + method_exec="SendDocument", + ), + Target( + method=SendGame, + content_name="GameContent", + content_file_name="game", + call_result="SendGame", + method_exec="SendGame", + ), + Target( + method=SendInvoice, + content_name="InvoiceContent", + content_file_name="invoice", + call_result="SendInvoice", + method_exec="SendInvoice", + ), + Target( + method=SendLocation, + content_name="LocationContent", + content_file_name="location", + call_result="SendLocation", + method_exec="SendLocation", + ), + Target( + method=SendMediaGroup, + content_name="MediaGroupContent", + content_file_name="media_group", + call_result="SendMediaGroup", + method_exec="SendMediaGroup", + ), + Target( + method=SendMessage, + content_name="TextContent", + content_file_name="text", + call_result="SendMessage", + method_exec="SendMessage", + ), + Target( + method=SendPaidMedia, + content_name="PaidMediaContent", + content_file_name="paid_media", + call_result="SendPaidMedia", + method_exec="SendPaidMedia", + ), + Target( + method=SendPhoto, + content_name="PhotoContent", + content_file_name="photo", + call_result="SendPhoto", + method_exec="SendPhoto", + ), + Target( + method=SendPoll, + content_name="PollContent", + content_file_name="poll", + call_result="SendPoll", + method_exec="SendPoll", + ), + Target( + method=SendSticker, + content_name="StickerContent", + content_file_name="sticker", + call_result="SendSticker", + method_exec="SendSticker", + ), + Target( + method=SendVenue, + content_name="VenueContent", + content_file_name="venue", + call_result="SendVenue", + method_exec="SendVenue", + ), + Target( + method=SendVideo, + content_name="VideoContent", + content_file_name="video", + call_result="SendVideo", + method_exec="SendVideo", + ), + Target( + method=SendVideoNote, + content_name="VideoNoteContent", + content_file_name="video_note", + call_result="SendVideoNote", + method_exec="SendVideoNote", + ), + Target( + method=SendVoice, + content_name="VoiceContent", + content_file_name="voice", + call_result="SendVoice", + method_exec="SendVoice", + ), + Target( + method=Message.send_copy, + content_name="MessageSendContent", + content_file_name="message_send", + call_result="Union[ForwardMessage, SendAnimation, SendAudio, SendContact, SendDocument, SendLocation, SendMessage, SendPhoto, SendPoll, SendDice, SendSticker, SendVenue, SendVideo, SendVideoNote, SendVoice]", + method_exec="self.message.send_copy", + put_model_extra=False, + model_extra_nodes=[FieldNode(name="message", annotation="Message")], + ), + Target( + method=Message.forward, + content_name="MessageForwardContent", + content_file_name="message_forward", + call_result="ForwardMessage", + method_exec="self.message.forward", + model_extra_nodes=[FieldNode(name="message", annotation="Message")], + ), + Target( + method=Message.copy_to, + content_name="MessageCopyContent", + content_file_name="message_copy", + call_result="CopyMessage", + method_exec="self.message.copy_to", + model_extra_nodes=[FieldNode(name="message", annotation="Message")], + ), +) diff --git a/butcher/template.py b/butcher/template.py new file mode 100644 index 0000000..b6e0a61 --- /dev/null +++ b/butcher/template.py @@ -0,0 +1,78 @@ +from jinja2 import Template + + +TEMPLATE = Template( + source=""" +# THIS CODE WAS AUTO-GENERATED VIA `butcher` + +from typing import {{ typing_all|join(', ') }} +from datetime import datetime, timedelta + +from aiogram.types import {{ aiogram_types_all|join(', ') }} +from aiogram.methods import {{ aiogram_methods_all|join(', ') }} +from aiogram.client.default import Default + +from .base import BaseContent + + +class {{ content_name }}(BaseContent): + {% if model_extra_nodes is not none %} + {% for node in model_extra_nodes %} + {{ node.name }}: {{ node.annotation }}{% if node.default is not none %} = {{ node.default }}{% endif %}\n + {% endfor %} + {% endif %} + {% for node in nodes %} + {{ node.name }}: {{ node.annotation }}{% if node.default is not none %} = {{ node.default }}{% endif %}\n + {% endfor %} + + async def __call__(self, chat_id: int) -> {{ call_result }}: + return {{ method_exec }}( + chat_id=chat_id, + {% for node in nodes %} + {{ node.name }}=self.{{ node.name }}, + {% endfor %} + {% if put_model_extra %} + **(self.model_extra or {}), + {% endif %} + ) + + if TYPE_CHECKING: + def __init__( + self, + *, + {% if model_extra_nodes is not none %} + {% for node in model_extra_nodes %} + {{ node.name }}: {{ node.annotation }}{% if node.default is not none %} = ...{% endif %}, + {% endfor %} + {% endif %} + {% for node in nodes %} + {{ node.name }}: {{ node.annotation }}{% if node.default is not none %} = ...{% endif %}, + {% endfor %} + **kwargs: Any, + ) -> None: ... +""", + trim_blocks=True, + lstrip_blocks=True, +) + + +INIT_TEMPLATE = Template( + source=""" +__all__ = ( + {% set names = ['"BaseContent"', '"adapters"'] %} + {% for target in targets %} + {% set _ = names.append('"' + target.content_name + '"') %} + {% endfor %} + {{ names | join(', ') }} +) + + +from . import adapters +from .base import BaseContent +{% for target in targets %} +from .{{ target.content_file_name }} import {{ target.content_name }} +{% endfor %} +""", + trim_blocks=True, + lstrip_blocks=True, +) diff --git a/examples/events.py b/examples/events.py index 4337780..f4361f9 100644 --- a/examples/events.py +++ b/examples/events.py @@ -1,64 +1,66 @@ import logging import sys -from typing import Any -from aiogram import Bot, Dispatcher, Router, html +from aiogram import Bot, Dispatcher, Router from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode -from aiogram.exceptions import TelegramForbiddenError from aiogram.types import Message -from aiogram_broadcaster import Broadcaster, EventRegistry, Mailer +from aiogram_broadcaster import Broadcaster, Event, Mailer from aiogram_broadcaster.contents import MessageSendContent +from aiogram_broadcaster.intervals import SimpleInterval +from aiogram_broadcaster.utils.retry_after_handler import setup_retry_after_handler -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list +TOKEN = "123:Abc" +CHATS = {230912392, 122398104, 39431920120} + router = Router(name=__name__) -event = EventRegistry(name=__name__) +event = Event(name=__name__) @router.message() -async def process_any_message(message: Message, broadcaster: Broadcaster, bot: Bot) -> Any: +async def process_any_message(message: Message, broadcaster: Broadcaster) -> None: content = MessageSendContent(message=message) + interval = SimpleInterval(interval=0.4) mailer = await broadcaster.create_mailer( + chats=CHATS, content=content, - chats=USER_IDS, - bot=bot, - interval=1, + interval=interval, + destroy_on_complete=True, ) mailer.start() @event.started() -async def mailer_started(mailer: Mailer[MessageSendContent], bot: Bot) -> None: - await mailer.content.message.as_(bot=bot).reply(text="Start broadcasting...") +async def process_mailer_started(mailer: Mailer[MessageSendContent], bot: Bot) -> None: + await mailer.content.message.as_(bot=bot).reply(text="Broadcasting started.") @event.stopped() -async def mailer_stopped(mailer: Mailer[MessageSendContent], bot: Bot) -> None: - await mailer.content.message.as_(bot=bot).reply(text="Stop broadcasting...") +async def process_mailer_stopped(mailer: Mailer[MessageSendContent], bot: Bot) -> None: + await mailer.content.message.as_(bot=bot).reply(text="Broadcasting stopped.") @event.completed() -async def mailer_completed(mailer: Mailer[MessageSendContent], bot: Bot) -> None: +async def process_mailer_completed(mailer: Mailer[MessageSendContent], bot: Bot) -> None: await mailer.content.message.as_(bot=bot).reply( text=( - "Broadcasting completed!\n" - f"Mailer ID: {mailer.id}\n" - f"{html.blockquote(str(mailer.statistic))}" + "Broadcasting completed.\n" + f"Total chats: {len(mailer.chats.total)}\n" + f"Processed chats: {len(mailer.chats.processed)} | " + f"{mailer.chats.processed % mailer.chats.total:.2f}%\n" + f"Pending chats: {len(mailer.chats.pending)} | " + f"{mailer.chats.pending % mailer.chats.total:.2f}%\n" + f"Failed chats: {len(mailer.chats.failed)} | " + f"{mailer.chats.failed % mailer.chats.total:.2f}%\n" + f"Success chats: {len(mailer.chats.success)} | " + f"{mailer.chats.success % mailer.chats.total:.2f}%\n" ), ) -@event.failed_sent() -async def mailer_failed_sent(chat_id: int, error: Exception) -> None: - if not isinstance(error, TelegramForbiddenError): - return - # Do something... - - def main() -> None: logging.basicConfig(level=logging.INFO, stream=sys.stdout) @@ -67,9 +69,10 @@ def main() -> None: dispatcher = Dispatcher() dispatcher.include_router(router) - broadcaster = Broadcaster() - broadcaster.event.bind(event) + broadcaster = Broadcaster(bot) broadcaster.setup(dispatcher=dispatcher) + broadcaster.event.bind(event) + setup_retry_after_handler(event=broadcaster.event) dispatcher.run_polling(bot) diff --git a/examples/lazy_content.py b/examples/lazy_content.py index 31bb682..e10ed47 100644 --- a/examples/lazy_content.py +++ b/examples/lazy_content.py @@ -1,56 +1,55 @@ import logging import sys from secrets import choice -from typing import Any, List from aiogram import Bot, Dispatcher, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from aiogram.filters import CommandStart from aiogram.types import Message from pydantic import SerializeAsAny from aiogram_broadcaster import Broadcaster -from aiogram_broadcaster.contents import BaseContent, LazyContent, TextContent +from aiogram_broadcaster.contents import BaseContent, TextContent +from aiogram_broadcaster.contents.adapters import LazyContentAdapter -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list +TOKEN = "123:Abc" +CHATS = {230912392, 122398104, 39431920120} + router = Router(name=__name__) -class RandomizedContent(LazyContent): - contents: List[SerializeAsAny[BaseContent]] +class RandomizedContentAdapter(LazyContentAdapter): + contents: list[SerializeAsAny[BaseContent]] async def __call__(self) -> BaseContent: return choice(self.contents) @router.message(CommandStart()) -async def process_start_command(message: Message, broadcaster: Broadcaster, bot: Bot) -> Any: - content = RandomizedContent( +async def process_start_command(message: Message, broadcaster: Broadcaster) -> None: + content = RandomizedContentAdapter( contents=[ - TextContent(text="Hello!"), - TextContent(text="Hi!"), + TextContent(text="Hi there!"), + TextContent(text="Greetings!"), + TextContent(text="Hey! How's it going?"), ], ) - mailer = await broadcaster.create_mailer( - content=content, - chats=USER_IDS, - bot=bot, - interval=1, - ) + mailer = await broadcaster.create_mailer(chats=CHATS, content=content) mailer.start() - await message.answer(text="Run broadcasting...") def main() -> None: logging.basicConfig(level=logging.INFO, stream=sys.stdout) - bot = Bot(token=TOKEN) + default = DefaultBotProperties(parse_mode=ParseMode.HTML) + bot = Bot(token=TOKEN, default=default) dispatcher = Dispatcher() dispatcher.include_router(router) - broadcaster = Broadcaster() + broadcaster = Broadcaster(bot) broadcaster.setup(dispatcher=dispatcher) dispatcher.run_polling(bot) diff --git a/examples/key_based_content.py b/examples/mapped_content.py similarity index 58% rename from examples/key_based_content.py rename to examples/mapped_content.py index aaea4cf..19d6954 100644 --- a/examples/key_based_content.py +++ b/examples/mapped_content.py @@ -1,59 +1,56 @@ import logging import sys -from typing import Any, Optional +from typing import Optional from aiogram import Bot, Dispatcher, Router -from aiogram.exceptions import TelegramBadRequest +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramAPIError from aiogram.filters import CommandStart from aiogram.types import Message from aiogram_broadcaster import Broadcaster -from aiogram_broadcaster.contents import KeyBasedContent, TextContent +from aiogram_broadcaster.contents import TextContent +from aiogram_broadcaster.contents.adapters import MappedContentAdapter -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list +TOKEN = "123:Abc" +CHATS = {230912392, 122398104, 39431920120} -router = Router(name=__name__) +router = Router(name=__name__) -class LanguageBasedContent(KeyBasedContent): - """Content based on the user's language.""" +class LanguageContentAdapter(MappedContentAdapter): async def __call__(self, chat_id: int, bot: Bot) -> Optional[str]: try: member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) - except TelegramBadRequest: + except TelegramAPIError: return None else: return member.user.language_code @router.message(CommandStart()) -async def process_start_command(message: Message, broadcaster: Broadcaster, bot: Bot) -> Any: - content = LanguageBasedContent( +async def process_start_command(message: Message, broadcaster: Broadcaster) -> None: + content = LanguageContentAdapter( default=TextContent(text="Hello!"), uk=TextContent(text="Привіт!"), ru=TextContent(text="Привет!"), ) - mailer = await broadcaster.create_mailer( - content=content, - chats=USER_IDS, - bot=bot, - interval=1, - ) + mailer = await broadcaster.create_mailer(chats=CHATS, content=content) mailer.start() - await message.answer(text="Run broadcasting...") def main() -> None: logging.basicConfig(level=logging.INFO, stream=sys.stdout) - bot = Bot(token=TOKEN) + default = DefaultBotProperties(parse_mode=ParseMode.HTML) + bot = Bot(token=TOKEN, default=default) dispatcher = Dispatcher() dispatcher.include_router(router) - broadcaster = Broadcaster() + broadcaster = Broadcaster(bot) broadcaster.setup(dispatcher=dispatcher) dispatcher.run_polling(bot) diff --git a/examples/mre.py b/examples/mre.py deleted file mode 100644 index 698e357..0000000 --- a/examples/mre.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -import sys -from typing import Any - -from aiogram import Bot, Dispatcher, Router -from aiogram.types import Message - -from aiogram_broadcaster import Broadcaster, DefaultMailerSettings -from aiogram_broadcaster.contents import MessageSendContent -from aiogram_broadcaster.storages.file import FileMailerStorage - - -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list - -router = Router(name=__name__) - - -@router.message() -async def process_any_message(message: Message, broadcaster: Broadcaster) -> Any: - # Creating content based on the Message - content = MessageSendContent(message=message) - - mailer = await broadcaster.create_mailer( - content=content, - chats=USER_IDS, - destroy_on_complete=True, - ) - - # The mailer launch method starts mailing to chats as an asyncio task. - mailer.start() - - await message.reply(text="Run broadcasting...") - - -def main() -> None: - logging.basicConfig(level=logging.INFO, stream=sys.stdout) - - bot = Bot(token=TOKEN) - dispatcher = Dispatcher() - dispatcher.include_router(router) - - storage = FileMailerStorage() - default = DefaultMailerSettings(interval=1, preserve=True) - broadcaster = Broadcaster(bot, storage=storage, default=default) - broadcaster.setup(dispatcher=dispatcher) - - dispatcher.run_polling(bot) - - -if __name__ == "__main__": - main() diff --git a/examples/multibot.py b/examples/multibot.py index 71befe6..ba622a1 100644 --- a/examples/multibot.py +++ b/examples/multibot.py @@ -1,48 +1,41 @@ import logging import sys -from typing import Any, Tuple from aiogram import Bot, Dispatcher, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from aiogram.types import Message from aiogram_broadcaster import Broadcaster from aiogram_broadcaster.contents import MessageSendContent -TOKENS = {"1234:Abc", "5678:Cdv"} -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list +TOKENS = ["123:Abc", "456:Obl"] +CHATS = {230912392, 122398104, 39431920120} + router = Router(name=__name__) @router.message() -async def process_any_message( - message: Message, - broadcaster: Broadcaster, - bots: Tuple[Bot, ...], -) -> Any: +async def process_any_message(message: Message, broadcaster: Broadcaster) -> None: content = MessageSendContent(message=message) - mailers = await broadcaster.create_mailers( - *bots, - content=content, - chats=USER_IDS, - interval=1, - ) - mailers.start() - await message.reply(text="Run broadcasting...") + mailer_group = await broadcaster.create_mailers(chats=CHATS, content=content) + mailer_group.start() def main() -> None: logging.basicConfig(level=logging.INFO, stream=sys.stdout) - bots = [Bot(token=token) for token in TOKENS] + default = DefaultBotProperties(parse_mode=ParseMode.HTML) + bot = [Bot(token=token, default=default) for token in TOKENS] dispatcher = Dispatcher() dispatcher.include_router(router) - broadcaster = Broadcaster() + broadcaster = Broadcaster(*bot) broadcaster.setup(dispatcher=dispatcher) - dispatcher.run_polling(*bots) + dispatcher.run_polling(*bot) if __name__ == "__main__": diff --git a/examples/placeholders.py b/examples/placeholders.py index acf7659..50f264c 100644 --- a/examples/placeholders.py +++ b/examples/placeholders.py @@ -1,58 +1,41 @@ import logging import sys -from datetime import datetime, tzinfo -from typing import Any, Optional +from typing import Optional from aiogram import Bot, Dispatcher, Router from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramAPIError from aiogram.filters import CommandStart from aiogram.types import Message -from aiogram_broadcaster import Broadcaster, PlaceholderItem, PlaceholderRegistry +from aiogram_broadcaster import Broadcaster, Placeholder from aiogram_broadcaster.contents import TextContent -TOKEN = "1234:Abc" -USER_IDS = {78238238, 78378343, 98765431, 12345678} # Your user IDs list +TOKEN = "123:Abc" +CHATS = {230912392, 122398104, 39431920120} -router = Router(name=__name__) -placeholder = PlaceholderRegistry(name=__name__) - - -@placeholder(key="mention") -async def mention_placeholder(chat_id: int, bot: Bot) -> str: - member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) - return member.user.mention_html(name=member.user.first_name) - - -class TimePlaceholder(PlaceholderItem, key="time"): - tz: Optional[tzinfo] - fmt: str - - def __init__( - self, - tz: Optional[tzinfo] = None, - fmt: str = "%T", - ) -> None: - self.tz = tz - self.fmt = fmt - async def __call__(self) -> str: - return datetime.now(self.tz).time().strftime(self.fmt) +router = Router(name=__name__) +placeholder = Placeholder(name=__name__) @router.message(CommandStart()) -async def process_start_command(message: Message, broadcaster: Broadcaster, bot: Bot) -> Any: - content = TextContent(text="Hello, $mention! Current time: $time") - mailer = await broadcaster.create_mailer( - content=content, - chats=USER_IDS, - bot=bot, - interval=1, - ) +async def process_start_command(message: Message, broadcaster: Broadcaster) -> None: + content = TextContent(text="Hello, $name!") + mailer = await broadcaster.create_mailer(chats=CHATS, content=content) mailer.start() - await message.reply(text="Run broadcasting...") + + +@placeholder.string(name="name") +async def name_placeholder(chat_id: int, bot: Bot) -> Optional[str]: + try: + member = await bot.get_chat_member(chat_id=chat_id, user_id=chat_id) + except TelegramAPIError: + return None + else: + return member.user.first_name def main() -> None: @@ -63,10 +46,9 @@ def main() -> None: dispatcher = Dispatcher() dispatcher.include_router(router) - broadcaster = Broadcaster() - broadcaster.placeholder.bind(placeholder) - broadcaster.placeholder.register(TimePlaceholder()) + broadcaster = Broadcaster(bot) broadcaster.setup(dispatcher=dispatcher) + broadcaster.placeholder.bind(placeholder) dispatcher.run_polling(bot) diff --git a/pyproject.toml b/pyproject.toml index 80be2e0..3263bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "aiogram_broadcaster" description = "A lightweight aiogram-based library for broadcasting Telegram messages." -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -25,12 +25,10 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed" @@ -49,35 +47,43 @@ keywords = [ "wrapper" ] dependencies = [ - "aiogram>=3.7.0" + "aiogram>=3.10.0" ] [project.optional-dependencies] +all = [ + "aiogram_broadcaster[dev,test,butcher,sqlalchemy,mongo,redis,jinja]" +] dev = [ - "mypy>=1.10.0", - "ruff>=0.4.0", - "types-aiofiles>=23.2.0" + "mypy>=1.11.0", + "ruff>=0.6.4", + "types-aiofiles>=24.1.0", + "motor-types>=1.0.0b4" ] test = [ "pytest>=8.2.0", "pytest-asyncio>=0.23.0", - "pytest-lazy-fixtures>=1.0.0", - "pytest-cov>=5.0.0", - "pytest-html>=4.1.0" ] -redis = [ - "redis[hiredis]>=5.0.0" +butcher = [ + "jinja2>=3.1.0" +] +sqlalchemy = [ + "sqlalchemy>=2.0.0" ] mongo = [ - "motor>=3.0.0" + "motor>=3.5.0" ] -sqlalchemy = [ - "SQLAlchemy>=2.0.0" +redis = [ + "redis[hiredis]>=5.0.0" +] +jinja = [ + "jinja2>=3.1.0" ] + [project.urls] Homepage = "https://github.com/loRes228/aiogram_broadcaster.git" -Documentation = "https://github.com/loRes228/aiogram_broadcaster#readme" +Documentation = "https://github.com/loRes228/aiogram_broadcaster/wiki" "Source code" = "https://github.com/loRes228/aiogram_broadcaster.git" "Issue Tracker" = "https://github.com/loRes228/aiogram_broadcaster/issues" @@ -85,8 +91,15 @@ Documentation = "https://github.com/loRes228/aiogram_broadcaster#readme" [tool.hatch.version] path = "aiogram_broadcaster/__meta__.py" +[tool.hatch.envs.default] +path = ".venv" + +[tool.hatch.metadata] +allow-direct-references = true + [tool.ruff] +target-version = "py39" cache-dir = ".cache/ruff" line-length = 99 output-format = "full" @@ -101,10 +114,11 @@ select = [ "ALL" ] extend-select = [ - "ANN204" # missing-return-type-special-method + "ANN204", # missing-return-type-special-method ] ignore = [ "D", # pydocstyle + "A", # flake8-builtins "CPY", # flake8-copyright "FA", # flake8-future-annotations "ANN", # flake8-annotations @@ -120,15 +134,15 @@ ignore = [ "PLR0915", # too-many-statements "PLR0916", # too-many-boolean-expressions "PLR0917", # too-many-positional - "PLR1702" # too-many-nested-blocks + "PLR1702", # too-many-nested-blocks + "PLW3201", # bad-dunder-method-name ] logger-objects = [ - "aiogram_broadcaster.utils.loggers.pool", - "aiogram_broadcaster.utils.loggers.mailer" + "aiogram_broadcaster.utils.logger.logger" ] [tool.ruff.lint.per-file-ignores] -"tests/test_*.py" = [ +"tests/**/test_*.py" = [ "S101" # assert ] @@ -145,7 +159,7 @@ strict = true [tool.mypy] -python_version = "3.8" +python_version = "3.9" cache_dir = ".cache/mypy" strict = true show_error_context = true @@ -160,18 +174,5 @@ plugins = [ [tool.pytest.ini_options] cache_dir = ".cache/pytest" testpaths = "tests" -asyncio_mode = "auto" addopts = "-vv" - - -[tool.coverage.run] -data_file = ".cache/.coverage" -parallel = true - -[tool.coverage.report] -exclude_lines = [ - "if __name__ == .__main__.:", - "pragma: no cover", - "if TYPE_CHECKING:", - "@(abc\\.)?abstractmethod" -] +asyncio_mode = "auto" diff --git a/tests/test_contents/test_base.py b/tests/test_contents/test_base.py deleted file mode 100644 index f29dd7e..0000000 --- a/tests/test_contents/test_base.py +++ /dev/null @@ -1,78 +0,0 @@ -import re - -import pytest -from pydantic import ValidationError - -from aiogram_broadcaster.contents.base import VALIDATOR_KEY, BaseContent - - -class DummyContent(BaseContent, register=False): - async def __call__(self, **kwargs): - return {"method": "dummy_method", **kwargs} - - -class TestBaseContent: - def test_registration(self): - assert not DummyContent.is_registered() - DummyContent.register() - assert DummyContent.is_registered() - DummyContent.unregister() - assert not DummyContent.is_registered() - with pytest.raises( - TypeError, - match="BaseContent cannot be registered.", - ): - BaseContent.register() - - def test_validate_invalid_type(self): - with pytest.raises(ValidationError): - BaseContent.model_validate(object()) - - def test_double_registration(self): - DummyContent.register() - with pytest.raises( - RuntimeError, - match="The content 'DummyContent' is already registered.", - ): - DummyContent.register() - DummyContent.unregister() - - def test_unregister_non_registered(self): - with pytest.raises( - RuntimeError, - match="The content 'DummyContent' is not registered.", - ): - DummyContent.unregister() - - async def test_callable(self): - content = DummyContent() - result = await content(param="value") - assert result == {"method": "dummy_method", "param": "value"} - - async def test_as_method(self): - content = DummyContent() - result = await content.as_method(param="value") - assert result == {"method": "dummy_method", "param": "value"} - - def test_validation(self): - DummyContent.register() - valid_data = {VALIDATOR_KEY: "DummyContent", "param": "value"} - content = DummyContent.model_validate(valid_data) - assert content.param == "value" - - invalid_data = {VALIDATOR_KEY: "NonExistentContent", "param": "value"} - with pytest.raises( - ValidationError, - match=re.escape( - "Content 'NonExistentContent' has not been registered, " - "you can register using the 'NonExistentContent.register()' method.", - ), - ): - BaseContent.model_validate(invalid_data) - DummyContent.unregister() - - def test_serialization(self): - content = DummyContent(param="value") - serialized = content.model_dump() - assert serialized[VALIDATOR_KEY] == "DummyContent" - assert serialized["param"] == "value" diff --git a/tests/test_contents/test_contents.py b/tests/test_contents/test_contents.py deleted file mode 100644 index 3ca6a7a..0000000 --- a/tests/test_contents/test_contents.py +++ /dev/null @@ -1,195 +0,0 @@ -import pytest -from aiogram.methods.base import TelegramMethod -from aiogram.methods.copy_message import CopyMessage -from aiogram.methods.forward_message import ForwardMessage -from aiogram.methods.send_animation import SendAnimation -from aiogram.methods.send_audio import SendAudio -from aiogram.methods.send_chat_action import SendChatAction -from aiogram.methods.send_contact import SendContact -from aiogram.methods.send_dice import SendDice -from aiogram.methods.send_document import SendDocument -from aiogram.methods.send_game import SendGame -from aiogram.methods.send_invoice import SendInvoice -from aiogram.methods.send_location import SendLocation -from aiogram.methods.send_media_group import SendMediaGroup -from aiogram.methods.send_message import SendMessage -from aiogram.methods.send_photo import SendPhoto -from aiogram.methods.send_poll import SendPoll -from aiogram.methods.send_sticker import SendSticker -from aiogram.methods.send_venue import SendVenue -from aiogram.methods.send_video import SendVideo -from aiogram.methods.send_video_note import SendVideoNote -from aiogram.methods.send_voice import SendVoice - -from aiogram_broadcaster.contents.animation import AnimationContent -from aiogram_broadcaster.contents.audio import AudioContent -from aiogram_broadcaster.contents.chat_action import ChatActionContent -from aiogram_broadcaster.contents.contact import ContactContent -from aiogram_broadcaster.contents.dice import DiceContent -from aiogram_broadcaster.contents.document import DocumentContent -from aiogram_broadcaster.contents.from_chat import FromChatCopyContent, FromChatForwardContent -from aiogram_broadcaster.contents.game import GameContent -from aiogram_broadcaster.contents.invoice import InvoiceContent -from aiogram_broadcaster.contents.location import LocationContent -from aiogram_broadcaster.contents.media_group import MediaGroupContent -from aiogram_broadcaster.contents.message import ( - MessageCopyContent, - MessageForwardContent, - MessageSendContent, -) -from aiogram_broadcaster.contents.photo import PhotoContent -from aiogram_broadcaster.contents.poll import PollContent -from aiogram_broadcaster.contents.sticker import StickerContent -from aiogram_broadcaster.contents.text import TextContent -from aiogram_broadcaster.contents.venue import VenueContent -from aiogram_broadcaster.contents.video import VideoContent -from aiogram_broadcaster.contents.video_note import VideoNoteContent -from aiogram_broadcaster.contents.voice import VoiceContent - - -@pytest.mark.parametrize( - ("expected_method", "content_class", "content_data"), - [ - ( - SendAnimation, - AnimationContent, - {"animation": "test"}, - ), - ( - SendAudio, - AudioContent, - {"audio": "test"}, - ), - ( - SendChatAction, - ChatActionContent, - {"action": "test"}, - ), - ( - SendContact, - ContactContent, - {"phone_number": "test", "first_name": "test"}, - ), - ( - SendDice, - DiceContent, - {}, - ), - ( - SendDocument, - DocumentContent, - {"document": "test"}, - ), - ( - CopyMessage, - FromChatCopyContent, - {"from_chat_id": 0, "message_id": 0}, - ), - ( - ForwardMessage, - FromChatForwardContent, - {"from_chat_id": 0, "message_id": 0}, - ), - ( - SendGame, - GameContent, - {"game_short_name": "test"}, - ), - ( - SendInvoice, - InvoiceContent, - { - "title": "test", - "description": "test", - "payload": "test", - "provider_token": "test", - "currency": "test", - "prices": [{"label": "test", "amount": 0}], - }, - ), - ( - SendLocation, - LocationContent, - {"latitude": 0, "longitude": 0}, - ), - ( - SendMediaGroup, - MediaGroupContent, - {"media": [{"media": "test"}]}, - ), - ( - CopyMessage, - MessageCopyContent, - {"message": {"message_id": 0, "date": 0, "chat": {"id": 0, "type": "test"}}}, - ), - ( - ForwardMessage, - MessageForwardContent, - {"message": {"message_id": 0, "date": 0, "chat": {"id": 0, "type": "test"}}}, - ), - ( - TelegramMethod, - MessageSendContent, - { - "message": { - "message_id": 0, - "date": 0, - "chat": {"id": 0, "type": "test"}, - "text": "test", - }, - }, - ), - ( - SendPhoto, - PhotoContent, - {"photo": "test"}, - ), - ( - SendPoll, - PollContent, - {"question": "test", "options": ["test"]}, - ), - ( - SendSticker, - StickerContent, - {"sticker": "test"}, - ), - ( - SendMessage, - TextContent, - {"text": "test"}, - ), - ( - SendVenue, - VenueContent, - {"latitude": 0, "longitude": 0, "title": "test", "address": "test"}, - ), - ( - SendVideo, - VideoContent, - {"video": "test"}, - ), - ( - SendVideoNote, - VideoNoteContent, - {"video_note": "test"}, - ), - ( - SendVoice, - VoiceContent, - {"voice": "test"}, - ), - ], -) -class TestContents: - async def test_as_method(self, expected_method, content_class, content_data): - content = content_class(**content_data, test_extra=1) - result = await content.as_method(chat_id=1) - assert isinstance(result, expected_method) - assert result.chat_id == 1 - if content_class is MessageSendContent: - pytest.skip( - "MessageSendContent does not support passing extra, " - "since the Message.send_copy method does not accept **kwargs.", - ) - assert result.test_extra == 1 diff --git a/tests/test_contents/test_key_based.py b/tests/test_contents/test_key_based.py deleted file mode 100644 index ec653b0..0000000 --- a/tests/test_contents/test_key_based.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from aiogram_broadcaster.contents.base import BaseContent -from aiogram_broadcaster.contents.key_based import KeyBasedContent - - -class DummyContent(BaseContent, register=False): - async def __call__(self, **kwargs): - return { - "method": "dummy_method", - **kwargs, - **(self.model_extra or {}), - } - - -class DummyKeyBasedContent(KeyBasedContent, register=False): - async def __call__(self, **kwargs): - return "key1" - - -class TestKeyBasedContent: - async def test_key_based_content_as_method(self): - content = DummyKeyBasedContent( - key1=DummyContent(param="value1"), - key2=DummyContent(param="value2"), - default=DummyContent(param="default_value"), - ) - result = await content.as_method(context_param="context_value") - assert isinstance(result, dict) - assert result == { - "method": "dummy_method", - "param": "value1", - "context_param": "context_value", - } - - async def test_key_based_content_getitem(self): - content = DummyKeyBasedContent( - key1=DummyContent(param="value1"), - key2=DummyContent(param="value2"), - default=DummyContent(param="default_value"), - ) - assert content["key1"].param == "value1" - assert content["key2"].param == "value2" - assert content["key3"].param == "default_value" - - async def test_get_not_exists_key(self): - content = DummyKeyBasedContent( - key1=DummyContent(param="value1"), - key2=DummyContent(param="value2"), - ) - assert content["key1"].param == "value1" - assert content["key2"].param == "value2" - - with pytest.raises(KeyError): - content["not_exists_key"] - - def test_key_based_content_contains(self): - content = DummyKeyBasedContent( - key1=DummyContent(param="value1"), - key2=DummyContent(param="value2"), - default=DummyContent(param="default_value"), - ) - assert "key1" in content - assert "key2" in content - assert "key3" not in content - - def test_key_based_content_no_contents(self): - with pytest.raises( - ValueError, - match="At least one content must be specified.", - ): - DummyKeyBasedContent() diff --git a/tests/test_contents/test_lazy.py b/tests/test_contents/test_lazy.py deleted file mode 100644 index ed3aa02..0000000 --- a/tests/test_contents/test_lazy.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from aiogram_broadcaster.contents.base import BaseContent -from aiogram_broadcaster.contents.lazy import LazyContent - - -class DummyContent(BaseContent, register=False): - async def __call__(self, **kwargs): - return { - "method": "dummy_method", - **kwargs, - **(self.model_extra or {}), - } - - -class DummyLazyContent(LazyContent, register=False): - async def __call__(self, **kwargs): - return DummyContent(param="value") - - -class TestLazyContent: - async def test_lazy_content_as_method(self): - content = DummyLazyContent() - result = await content.as_method(context_param="context_value") - assert isinstance(result, dict) - assert result == { - "method": "dummy_method", - "param": "value", - "context_param": "context_value", - } - - async def test_lazy_content_as_method_invalid_return(self): - class InvalidLazyContent(LazyContent): - async def __call__(self, **kwargs): - return "not_a_base_content" - - content = InvalidLazyContent() - with pytest.raises( - TypeError, - match="The 'InvalidLazyContent' expected to return an content of " - "type BaseContent, not a str.", - ): - await content.as_method(context_param="context_value") diff --git a/tests/test_event/__init__.py b/tests/test_event/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_event/test_manager.py b/tests/test_event/test_manager.py deleted file mode 100644 index 4cee697..0000000 --- a/tests/test_event/test_manager.py +++ /dev/null @@ -1,65 +0,0 @@ -import unittest.mock - -import pytest - -from aiogram_broadcaster.event.manager import EventManager - - -class TestEventManager: - async def test_emit_event(self): - manager = EventManager() - - mock_callback1 = unittest.mock.Mock(return_value={"data1": "value1"}) - mock_callback2 = unittest.mock.Mock(return_value={"data2": "value2"}) - - manager.started.register(mock_callback1) - manager.started.register(mock_callback2) - - context = {"initial": "context"} - result = await manager.emit_event("started", **context) - - assert result == {"data1": "value1", "data2": "value2"} - mock_callback1.assert_called_once_with(**context) - mock_callback2.assert_called_once_with(**context, data1="value1") - - async def test_emit_event_no_callbacks(self): - manager = EventManager() - - context = {"initial": "context"} - result = await manager.emit_event("started", **context) - - assert result == {} - - async def test_emit_event_with_non_dict_result(self): - manager = EventManager() - - mock_callback = unittest.mock.Mock(return_value="non_dict_result") - manager.started.register(mock_callback) - - context = {"initial": "context"} - result = await manager.emit_event("started", **context) - - assert result == {} - mock_callback.assert_called_once_with(**context) - - @pytest.mark.parametrize( - "event_name", - [ - "started", - "stopped", - "completed", - "before_sent", - "success_sent", - "failed_sent", - ], - ) - async def test_emit_methods(self, event_name): - manager = EventManager() - - with unittest.mock.patch.object( - target=manager, - attribute="emit_event", - ) as mocked_emit_event: - emit_method = getattr(manager, f"emit_{event_name}") - await emit_method(data1="value1") - mocked_emit_event.assert_called_once_with(event_name, data1="value1") diff --git a/tests/test_event/test_observer.py b/tests/test_event/test_observer.py deleted file mode 100644 index 19ad23a..0000000 --- a/tests/test_event/test_observer.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from aiogram_broadcaster.event.observer import EventObserver - - -class TestEventObserver: - def test_register_callback(self): - observer = EventObserver() - - def callback(): - pass - - observer.register(callback) - assert len(observer) == 1 - assert list(observer)[0] == callback - - def test_register_multiple_callbacks(self): - observer = EventObserver() - - def callback1(): - pass - - def callback2(): - pass - - observer.register(callback1, callback2) - assert len(observer) == 2 - callbacks = list(observer) - assert callback1 in callbacks - assert callback2 in callbacks - - def test_register_no_callbacks(self): - observer = EventObserver() - - with pytest.raises( - ValueError, - match="At least one callback must be provided to register.", - ): - observer.register() - - def test_callable_registration(self): - observer = EventObserver() - - @observer() - def callback(): - pass - - assert len(observer) == 1 - assert list(observer)[0] == callback - - def test_register_fluent(self): - observer = EventObserver() - - def callback(): - pass - - assert observer.register(callback) == observer diff --git a/tests/test_event/test_registry.py b/tests/test_event/test_registry.py deleted file mode 100644 index 1575b91..0000000 --- a/tests/test_event/test_registry.py +++ /dev/null @@ -1,14 +0,0 @@ -from aiogram_broadcaster.event.registry import EventRegistry - - -class TestEventRegistry: - def test_observers(self): - registry = EventRegistry() - assert registry.started == registry.observers["started"] == registry["started"] - assert registry.stopped == registry.observers["stopped"] == registry["stopped"] - assert registry.completed == registry.observers["completed"] == registry["completed"] - assert registry.before_sent == registry.observers["before_sent"] == registry["before_sent"] - assert ( - registry.success_sent == registry.observers["success_sent"] == registry["success_sent"] - ) - assert registry.failed_sent == registry.observers["failed_sent"] == registry["failed_sent"] diff --git a/tests/test_mailer/test_settings.py b/tests/test_mailer/test_settings.py deleted file mode 100644 index 79844fd..0000000 --- a/tests/test_mailer/test_settings.py +++ /dev/null @@ -1,66 +0,0 @@ -import sys - -import pytest -from pydantic import ValidationError - -from aiogram_broadcaster.mailer.settings import DefaultMailerSettings, MailerSettings - - -class TestMailerSettings: - def test_default_values(self): - settings = MailerSettings() - assert settings.interval == 0 - assert settings.run_on_startup is False - assert settings.handle_retry_after is False - assert settings.destroy_on_complete is False - assert settings.disable_events is False - assert settings.preserved is True - assert settings.exclude_placeholders is None - - def test_invalid_interval(self): - with pytest.raises(ValidationError): - MailerSettings(interval=-1) - - -class TestDefaultMailerSettings: - def test_default_values(self): - settings = DefaultMailerSettings() - assert settings.interval == 0 - assert settings.dynamic_interval is False - assert settings.run_on_startup is False - assert settings.handle_retry_after is False - assert settings.destroy_on_complete is False - assert settings.preserve is False - - def test_invalid_interval(self): - with pytest.raises( - ValueError, - match="The interval must be greater than or equal to 0.", - ): - DefaultMailerSettings(interval=-1) - - def test_prepare_method(self): - default_settings = DefaultMailerSettings(interval=5, dynamic_interval=True) - prepared_settings = default_settings.prepare(run_on_startup=True, preserve=True) - assert prepared_settings.interval == 5 - assert prepared_settings.dynamic_interval is True - assert prepared_settings.run_on_startup is True - assert prepared_settings.handle_retry_after is False - assert prepared_settings.preserve is True - assert prepared_settings.destroy_on_complete is False - - def test_prepare_method_without_args(self): - default_settings = DefaultMailerSettings(interval=5, dynamic_interval=True, preserve=True) - prepared_settings = default_settings.prepare() - assert prepared_settings.interval == 5 - assert prepared_settings.dynamic_interval is True - assert prepared_settings.run_on_startup is False - assert prepared_settings.handle_retry_after is False - assert prepared_settings.preserve is True - assert prepared_settings.destroy_on_complete is False - - @pytest.mark.skipif(sys.version_info < (3, 12), reason="Requires Python 3.12 or higher.") - def test_dataclass_params(self): - dataclass_params = DefaultMailerSettings.__dataclass_params__ - assert dataclass_params.slots is True - assert dataclass_params.kw_only is True diff --git a/tests/test_mailer/test_task_manager.py b/tests/test_mailer/test_task_manager.py deleted file mode 100644 index 8bdb4e6..0000000 --- a/tests/test_mailer/test_task_manager.py +++ /dev/null @@ -1,64 +0,0 @@ -import asyncio -import unittest.mock - -import pytest - -from aiogram_broadcaster.mailer.task_manager import TaskManager - - -@pytest.fixture() -def task_manager(): - return TaskManager() - - -@pytest.fixture() -def task_coroutine(): - async def task_coroutine(): - pass - - return task_coroutine() - - -class TestTaskManager: - async def test_start(self, task_manager, task_coroutine): - assert not task_manager.started - task_manager.start(task_coroutine) - assert task_manager.started - assert not task_manager.waited - - async def test_start_already_started(self, task_manager, task_coroutine): - task_manager.start(task_coroutine) - with pytest.raises( - RuntimeError, - match="Task is already started.", - ): - task_manager.start(task_coroutine) - - async def test_wait(self, task_manager, task_coroutine): - task_manager.start(task_coroutine) - with unittest.mock.patch.object( - target=task_manager, - attribute="wait", - new_callable=unittest.mock.AsyncMock, - ) as mocked_wait: - await task_manager.wait() - mocked_wait.assert_awaited_once() - - async def test_wait_no_task(self, task_manager): - with pytest.raises(RuntimeError, match="No task for wait."): - await task_manager.wait() - - async def test_wait_already_waited(self, task_manager, task_coroutine): - task_manager.start(task_coroutine) - - with pytest.raises(RuntimeError, match="Task is already waited."): - await asyncio.gather(task_manager.wait(), task_manager.wait()) - - async def test_on_task_done(self, task_manager, task_coroutine): - task_manager.start(task_coroutine) - assert task_manager.started - assert not task_manager.waited - - task_manager._on_task_done(task_coroutine) - assert not task_manager.started - assert not task_manager.waited diff --git a/tests/test_placeholder/__init__.py b/tests/test_placeholder/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_placeholder/test_item.py b/tests/test_placeholder/test_item.py deleted file mode 100644 index d5ac3ce..0000000 --- a/tests/test_placeholder/test_item.py +++ /dev/null @@ -1,23 +0,0 @@ -from aiogram_broadcaster.placeholder.item import PlaceholderItem -from aiogram_broadcaster.placeholder.placeholder import Placeholder - - -class DummyPlaceholderItem(PlaceholderItem, key="test_key"): - async def __call__(self, **kwargs): - return "test_value" - - -class TestPlaceholderItem: - def test_key_assignment(self): - assert DummyPlaceholderItem.key == "test_key" - - def test_repr(self): - item = DummyPlaceholderItem() - assert repr(item) == "DummyPlaceholderItem(key='test_key')" - - def test_as_placeholder(self): - item = DummyPlaceholderItem() - placeholder = item.as_placeholder() - assert isinstance(placeholder, Placeholder) - assert placeholder.key == "test_key" - assert placeholder.value == item.__call__ diff --git a/tests/test_placeholder/test_manager.py b/tests/test_placeholder/test_manager.py deleted file mode 100644 index e52c9e8..0000000 --- a/tests/test_placeholder/test_manager.py +++ /dev/null @@ -1,115 +0,0 @@ -import unittest.mock -from string import Template - -import pydantic - -from aiogram_broadcaster.placeholder.manager import PlaceholderManager -from aiogram_broadcaster.utils.interrupt import Interrupt - - -class TestPlaceholderManager: - async def test_fetch_data_no_select_keys(self): - manager = PlaceholderManager() - data = await manager.fetch_data(set()) - assert data == {} - - async def test_fetch_data_with_select_keys(self): - manager = PlaceholderManager() - manager["key1"] = unittest.mock.Mock(return_value="value1") - manager["key2"] = unittest.mock.Mock(return_value="value2") - data = await manager.fetch_data({"key1", "key2"}) - assert data == {"key1": "value1", "key2": "value2"} - - async def test_fetch_data_missing_keys(self): - manager = PlaceholderManager() - manager["key1"] = unittest.mock.Mock(return_value="value1") - data = await manager.fetch_data({"key2"}) - assert data == {} - - async def test_fetch_data_interrupt(self): - manager = PlaceholderManager() - manager["key1"] = unittest.mock.Mock(side_effect=Interrupt) - data = await manager.fetch_data({"key1"}) - assert data == {} - - def test_extract_text_field_found(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - field = manager.extract_text_field(model()) - assert field == ("caption", "Hello, $name!") - - def test_extract_text_field_not_found(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", description=(str, "Some description")) - field = manager.extract_text_field(model()) - assert field is None - - def test_extract_keys_with_placeholders(self): - manager = PlaceholderManager() - template = manager.extract_keys(Template("Hello, $name!")) - assert template == {"name"} - - def test_extract_keys_no_placeholders(self): - manager = PlaceholderManager() - template = manager.extract_keys(Template("Hello, world!")) - assert template == set() - - async def test_render_no_keys(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model()) - assert rendered_model == model() - - async def test_render_all_keys_excluded(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model(), {"name"}) - assert rendered_model == model() - - async def test_render_no_placeholders_found(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", description=(str, "Some description")) - rendered_model = await manager.render(model()) - assert rendered_model == model() - - async def test_render_placeholders_and_data_fetched(self): - manager = PlaceholderManager() - manager["name"] = unittest.mock.Mock(return_value="John") - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model()) - assert rendered_model == model(caption="Hello, John!") - - async def test_render_placeholders_but_no_data_fetched(self): - manager = PlaceholderManager() - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model()) - assert rendered_model == model() - - async def test_render_interrupt(self): - manager = PlaceholderManager() - manager["name"] = unittest.mock.Mock(side_effect=Interrupt) - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model()) - assert rendered_model == model() - - async def test_render_empty_keys(self): - manager = PlaceholderManager() - manager["name"] = "test_value" - model = pydantic.create_model("TestModel", caption=(str, "Hello, $name!")) - rendered_model = await manager.render(model(), {"name"}) - assert rendered_model == model() - - async def test_render_without_field(self): - manager = PlaceholderManager() - manager["name"] = "test_value" - model = pydantic.create_model("TestModel", not_text_field=(str, "value")) - rendered_model = await manager.render(model()) - assert rendered_model == model() - - async def test_render_without_select_keys(self): - manager = PlaceholderManager() - manager["name"] = "test_value" - manager["age"] = "test_value" - model = pydantic.create_model("TestModel", text=(str, "Hello, $name!")) - rendered_model = await manager.render(model(), {"name"}) - assert rendered_model == model() diff --git a/tests/test_placeholder/test_placeholder.py b/tests/test_placeholder/test_placeholder.py deleted file mode 100644 index 2680a2e..0000000 --- a/tests/test_placeholder/test_placeholder.py +++ /dev/null @@ -1,43 +0,0 @@ -from aiogram_broadcaster.placeholder.placeholder import Placeholder - - -class TestPlaceholder: - def test_initialization_with_value(self): - value = "test_value" - placeholder = Placeholder(key="test_key", value=value) - assert placeholder.key == "test_key" - assert placeholder.value == value - - def test_initialization_with_callable_value(self): - async def mock_callback(**kwargs): - pass - - placeholder = Placeholder(key="test_key", value=mock_callback) - assert placeholder.key == "test_key" - assert placeholder.value == mock_callback - - def test_hash(self): - placeholder1 = Placeholder(key="key1", value="value1") - placeholder2 = Placeholder(key="key2", value="value2") - assert hash(placeholder1) != hash(placeholder2) - - def test_eq(self): - placeholder1 = Placeholder(key="key1", value="value1") - placeholder2 = Placeholder(key="key1", value="value2") - placeholder3 = Placeholder(key="key2", value="value1") - assert placeholder1 == placeholder2 - assert placeholder1 != placeholder3 - assert placeholder1 != "invalid_type" - - async def test_get_value_with_value(self): - value = "test_value" - placeholder = Placeholder(key="test_key", value=value) - assert await placeholder.get_value() == value - - async def test_get_value_with_callable(self): - async def mock_callback(**kwargs): - return "callback_result" - - placeholder = Placeholder(key="test_key", value=mock_callback) - result = await placeholder.get_value() - assert result == "callback_result" diff --git a/tests/test_placeholder/test_registry.py b/tests/test_placeholder/test_registry.py deleted file mode 100644 index 2f4aa95..0000000 --- a/tests/test_placeholder/test_registry.py +++ /dev/null @@ -1,137 +0,0 @@ -import re - -import pytest - -from aiogram_broadcaster.placeholder.item import PlaceholderItem -from aiogram_broadcaster.placeholder.placeholder import Placeholder -from aiogram_broadcaster.placeholder.registry import PlaceholderRegistry - - -class DummyPlaceholderItem(PlaceholderItem, key="test_key"): - async def __call__(self, **kwargs): - return "test_value" - - -class TestPlaceholderRegistry: - def test_initialization(self): - registry = PlaceholderRegistry(name="test_registry") - assert registry.name == "test_registry" - assert registry.placeholders == set() - - def test_add_placeholder(self): - registry = PlaceholderRegistry() - registry["key1"] = "value1" - assert registry.placeholders == {Placeholder(key="key1", value="value1")} - - def test_register_placeholder_items(self): - registry = PlaceholderRegistry() - item1 = DummyPlaceholderItem() - item2 = DummyPlaceholderItem() - registry.register(item1, item2) - assert registry.placeholders == {item1.as_placeholder(), item2.as_placeholder()} - registry = PlaceholderRegistry() - assert registry.register(item2) == registry - with pytest.raises( - ValueError, - match="At least one placeholder item must be provided to register.", - ): - registry.register() - - def test_add_method(self): - registry = PlaceholderRegistry() - registry.add(key1="value1", key2="value2") - assert registry.placeholders == { - Placeholder(key="key1", value="value1"), - Placeholder(key="key2", value="value2"), - } - assert registry.add({"key1": "value1"}) == registry - with pytest.raises(ValueError, match="At least one argument must be provided."): - registry.add() - - def test_items_property(self): - registry = PlaceholderRegistry() - registry["key1"] = "value1" - registry["key2"] = "value2" - assert sorted(registry.items) == [("key1", "value1"), ("key2", "value2")] - - def test_keys_property(self): - registry = PlaceholderRegistry() - registry["key1"] = "value1" - registry["key2"] = "value2" - assert registry.keys == {"key1", "key2"} - - def test_chain_placeholders_property(self): - registry1 = PlaceholderRegistry() - registry2 = PlaceholderRegistry() - registry1["key1"] = "value1" - registry2["key2"] = "value2" - registry1.bind(registry2) - assert set(registry1.chain_placeholders) == { - Placeholder(key="key1", value="value1"), - Placeholder(key="key2", value="value2"), - } - - def test_chain_items_property(self): - registry1 = PlaceholderRegistry() - registry2 = PlaceholderRegistry() - registry1["key1"] = "value1" - registry2["key2"] = "value2" - registry1.bind(registry2) - assert set(registry1.chain_items) == {("key1", "value1"), ("key2", "value2")} - - def test_chain_keys_property(self): - registry1 = PlaceholderRegistry() - registry2 = PlaceholderRegistry() - registry1["key1"] = "value1" - registry2["key2"] = "value2" - registry1.bind(registry2) - assert set(registry1.chain_keys) == {"key1", "key2"} - - def test_iter(self): - registry = PlaceholderRegistry() - registry["key1"] = "value1" - registry["key2"] = "value2" - assert sorted(registry) == [("key1", "value1"), ("key2", "value2")] - - def test_contains(self): - registry = PlaceholderRegistry() - registry["key1"] = "value1" - assert "key1" in registry - assert "key2" not in registry - - def test_chain_bind_no_collision(self): - registry1 = PlaceholderRegistry(name="registry1") - registry2 = PlaceholderRegistry(name="registry2") - registry1["key1"] = "value1" - registry2["key2"] = "value2" - - registry1.bind(registry2) - assert set(registry1.chain_placeholders) == { - Placeholder(key="key1", value="value1"), - Placeholder(key="key2", value="value2"), - } - - def test_chain_bind_with_collision(self): - registry1 = PlaceholderRegistry(name="registry1") - registry2 = PlaceholderRegistry(name="registry2") - registry1["key1"] = "value1" - registry2["key1"] = "value2" - - with pytest.raises( - RuntimeError, - match=re.escape( - "Collision keys=['key1'] between PlaceholderRegistry(name='registry1') " - "and PlaceholderRegistry(name='registry2').", - ), - ): - registry1.bind(registry2) - - def test_call(self): - registry = PlaceholderRegistry() - - @registry(key="test_key") - async def callback(): - return - - assert registry.placeholders == {Placeholder(key="test_key", value=callback)} - assert registry["test_key"] == callback diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_utils/test_chain.py b/tests/test_utils/test_chain.py deleted file mode 100644 index 331d072..0000000 --- a/tests/test_utils/test_chain.py +++ /dev/null @@ -1,156 +0,0 @@ -import re - -import pytest - -from aiogram_broadcaster.utils.chain import Chain - - -class MyChain(Chain["MyChain"], sub_name="chain"): - pass - - -class MyRootChain(MyChain): - __chain_root__ = True - - -class TestChainObject: - def test_class_attrs(self): - assert MyChain.__chain_entity__ is MyChain - assert MyChain.__chain_sub_name__ == "chain" - assert MyChain.__chain_root__ is False - - assert MyRootChain.__chain_entity__ is MyChain - assert MyRootChain.__chain_sub_name__ == "chain" - assert MyRootChain.__chain_root__ is True - - def test_init(self): - chain = MyChain(name="test_name") - assert chain.name == "test_name" - assert chain.head is None - assert chain.tail == [] - - def test_name(self): - chain = MyChain() - assert id(chain) == int(chain.name, 16) - - def test_chain_bind(self): - chain1 = MyChain() - chain2 = MyChain() - chain3 = MyChain() - chain1.bind(chain2) - chain2.bind(chain3) - - assert chain1.head is None - assert chain1.tail == [chain2] - assert tuple(chain1.chain_head) == (chain1,) - assert tuple(chain1.chain_tail) == (chain1, chain2, chain3) - - assert chain2.head is chain1 - assert chain2.tail == [chain3] - assert tuple(chain2.chain_head) == (chain2, chain1) - assert tuple(chain2.chain_tail) == (chain2, chain3) - - assert chain3.head is chain2 - assert chain3.tail == [] - assert tuple(chain3.chain_head) == (chain3, chain2, chain1) - assert tuple(chain3.chain_tail) == (chain3,) - - def test_parental_bind(self): - parent = MyChain() - child1 = MyChain() - child2 = MyChain() - parent.bind(child1, child2) - - assert parent.head is None - assert parent.tail == [child1, child2] - assert tuple(parent.chain_head) == (parent,) - assert tuple(parent.chain_tail) == (parent, child1, child2) - - assert child1.head is parent - assert child1.tail == [] - assert tuple(child1.chain_head) == (child1, parent) - assert tuple(child1.chain_tail) == (child1,) - - assert child2.head is parent - assert child2.tail == [] - assert tuple(child2.chain_head) == (child2, parent) - assert tuple(child2.chain_tail) == (child2,) - - def test_repr_and_str(self) -> None: - parent = MyChain(name="parent") - child = MyChain(name="child") - parent.bind(child) - - assert repr(parent) == "MyChain(name='parent', nested=[MyChain(name='child')])" - assert repr(child) == "MyChain(name='child')" - - assert str(parent) == "MyChain(name='parent')" - assert str(child) == "MyChain(name='child', parent=MyChain(name='parent'))" - - def test_one_fluent_bind(self): - chain1 = MyChain() - chain2 = MyChain() - assert chain1.bind(chain2) == chain2 - - def test_many_fluent_bind(self): - chain1 = MyChain() - chain2 = MyChain() - chain3 = MyChain() - assert chain1.bind(chain2, chain3) == chain1 - - def test_bind_without_args(self): - chain = MyChain() - with pytest.raises( - ValueError, - match="At least one chain must be provided to bind.", - ): - chain.bind() - - def test_bind_invalid_type(self): - chain = MyChain() - with pytest.raises( - TypeError, - match="The chain must be an instance of MyChain, not a str.", - ): - chain.bind("invalid type") - - def test_bind_itself(self): - chain = MyChain() - with pytest.raises( - ValueError, - match="Cannot bind the chain on itself.", - ): - chain.bind(chain) - - def test_bind_already_bind(self): - chain1 = MyChain(name="chain1") - chain2 = MyChain(name="chain2") - chain1.bind(chain2) - with pytest.raises( - RuntimeError, - match=re.escape( - "The MyChain(name='chain2') is already attached to MyChain(name='chain1').", - ), - ): - chain1.bind(chain2) - - def test_circular_bind(self): - chain1 = MyChain() - chain2 = MyChain() - chain1.bind(chain2) - with pytest.raises( - RuntimeError, - match="Circular referencing detected.", - ): - chain2.bind(chain1) - - def test_bind_root(self): - root_chain = MyRootChain(name="root_chain") - chain = MyChain(name="chain") - with pytest.raises( - RuntimeError, - match=re.escape( - "MyRootChain(name='root_chain') cannot be attached to another chain.", - ), - ): - chain.bind(root_chain) diff --git a/tests/test_utils/test_interrupt.py b/tests/test_utils/test_interrupt.py deleted file mode 100644 index 51d200d..0000000 --- a/tests/test_utils/test_interrupt.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from aiogram_broadcaster.utils.interrupt import Interrupt, interrupt, suppress_interrupt - - -class TestInterrupt: - @pytest.mark.parametrize("exception", suppress_interrupt.args) - def test_suppress_interrupt(self, exception): - with suppress_interrupt(): - raise exception - - def test_suppress_other_exception(self): - with pytest.raises(KeyError), suppress_interrupt(): - raise KeyError - - def test_raise_interrupt(self): - with pytest.raises(Interrupt): - interrupt()