Compare commits

...

34 Commits
v1.5 ... v2.0.0

Author SHA1 Message Date
187187a8a2 Fixed tests workflow
All checks were successful
Tests / test (3.10) (push) Successful in 1m0s
Tests / test (3.11) (push) Successful in 1m2s
Tests / test (3.8) (push) Successful in 1m1s
Tests / test (3.9) (push) Successful in 1m30s
2023-08-07 11:40:50 +02:00
8d27f43cce Updated workflow 2023-08-07 11:24:48 +02:00
f181552fb8 Updated to 2.0.0 2023-08-07 11:21:49 +02:00
32b1acf4aa Updated to 0.2.2
All checks were successful
Tests / test (3.10) (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m13s
Tests / test (3.8) (push) Successful in 1m9s
Tests / test (3.9) (push) Successful in 1m1s
2023-08-06 22:00:14 +02:00
1c1c71d40b Fixed PyroClient.bot_locale 2023-08-06 21:59:48 +02:00
65838450ee Fixed typing of Dict
All checks were successful
Tests / test (3.10) (push) Successful in 1m9s
Tests / test (3.11) (push) Successful in 1m52s
Tests / test (3.8) (push) Successful in 1m23s
Tests / test (3.9) (push) Successful in 1m25s
2023-08-06 21:22:24 +02:00
3f39e07d04 Added coverage display 2023-08-06 21:21:55 +02:00
c5fdd60d13 Added container
Some checks failed
Tests / test (3.10) (push) Successful in 1m26s
Tests / test (3.11) (push) Successful in 1m1s
Tests / test (3.8) (push) Failing after 1m3s
Tests / test (3.9) (push) Successful in 1m11s
2023-08-06 21:11:06 +02:00
35fe69d2a8 Tried another Python versions set
Some checks reported warnings
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.8) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
2023-08-06 19:24:16 +02:00
3bb7ecca7e Updated python versions
Some checks failed
Tests / test (3.10.12) (push) Failing after 5s
Tests / test (3.11.3) (push) Failing after 5s
Tests / test (3.8.17) (push) Failing after 6s
Tests / test (3.9.17) (push) Failing after 4s
2023-08-06 19:14:44 +02:00
64aa2686ea Updated to 0.2.1
Some checks failed
Tests / test (3.10) (push) Failing after 55s
Tests / test (3.11) (push) Failing after 5s
Tests / test (3.8) (push) Failing after 6s
Tests / test (3.9) (push) Failing after 5s
2023-08-06 19:11:16 +02:00
461642a529 Added nested_set tests 2023-08-06 12:51:32 +02:00
e5c0f5c1d1 Added i18n tests 2023-08-06 12:51:23 +02:00
00b1058014 BotLocale now has default_locale 2023-08-06 12:51:06 +02:00
bc5be37ff1 locale is now Union[str, None] 2023-08-06 12:50:34 +02:00
7c756d7065 Switched versioning to semantic 2023-08-06 12:43:41 +02:00
3273b86b75 nested_set raises KeyError if not create_missing 2023-08-06 12:43:01 +02:00
783443e448 Changed imports 2023-08-06 12:42:37 +02:00
723cc40221 Fixed imports 2023-08-06 11:33:41 +02:00
b6537a50ae Changed path to tests/ 2023-08-06 02:08:02 +02:00
e508f37089 Added pytest to pyproject 2023-08-06 02:07:07 +02:00
d66bb9c93e Locale of _() now defaults to "en" 2023-08-06 01:44:51 +02:00
253c85985b Added some basic test 2023-08-06 01:25:32 +02:00
11d49fd476 Fixed escape_forward_slashes error 2023-08-06 01:25:08 +02:00
dc107ebdb3 Updated to 1.9 2023-07-26 14:12:13 +02:00
33c33d08e2 Allowed passing kwargs 2023-07-26 14:12:05 +02:00
295e77e403 Updated to 1.8 2023-07-03 10:57:00 +02:00
279a8e9d84 reports.chat_id can now be "owner" 2023-07-03 10:56:24 +02:00
ae374511cd Updated to 1.7 2023-06-30 10:32:16 +02:00
eb23d3e9b6 No more locations.locale in config 2023-06-30 10:31:49 +02:00
f4e74b5bc6 Added trailing slashes 2023-06-29 16:03:49 +02:00
7b8434ae71 Updated to 1.6 2023-06-29 15:59:06 +02:00
8c2054f496 Improved init flexibility 2023-06-29 15:58:50 +02:00
fe9cc3674f Removed duplicate py version 2023-06-29 15:58:20 +02:00
37 changed files with 804 additions and 130 deletions

View File

@@ -0,0 +1,32 @@
name: Tests
on:
push:
branches:
- main
tags-ignore:
- v*
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
env:
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox

8
.gitignore vendored
View File

@@ -161,8 +161,8 @@ cython_debug/
#.idea/ #.idea/
# Project # Project
venv venv/
venv_linux venv_linux/
venv_windows venv_windows/
.vscode .vscode/

View File

@@ -1,9 +0,0 @@
__name__ = "libbot"
__version__ = "1.5"
__license__ = "GPL3"
__author__ = "Profitroll"
from .__main__ import *
from . import sync
from . import i18n
from . import pyrogram

View File

@@ -1,15 +1,15 @@
[build-system] [build-system]
requires = ["setuptools>=62.6,<66"] requires = ["setuptools>=62.6", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "libbot" name = "libbot"
version = "1.5" dynamic = ["version", "dependencies", "optional-dependencies"]
authors = [{ name = "Profitroll" }] authors = [{ name = "Profitroll" }]
description = "Universal bot library with functions needed for basic Discord/Telegram bot development." description = "Universal bot library with functions needed for basic Discord/Telegram bot development."
readme = "README.md" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
license = { text = "GPL3" } license = { file = "LICENSE" }
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
@@ -22,33 +22,46 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Utilities", "Topic :: Utilities",
] ]
dependencies = ["aiofiles~=23.1.0"]
[project.optional-dependencies] [tool.setuptools.dynamic]
pycord = ["py-cord~=2.4.1"] version = { attr = "libbot.__version__" }
pyrogram = ["pyrogram~=2.0.106", "apscheduler~=3.10.1"] dependencies = { file = "requirements/_.txt" }
speed = ["ujson~=5.8.0"]
[tool.setuptools.dynamic.optional-dependencies]
dev = { file = "requirements/dev.txt" }
pycord = { file = "requirements/pycord.txt" }
pyrogram = { file = "requirements/pyrogram.txt" }
speed = { file = "requirements/speed.txt" }
[project.urls] [project.urls]
Source = "https://git.end-play.xyz/profitroll/LibBotUniversal" Source = "https://git.end-play.xyz/profitroll/LibBotUniversal"
Documentation = "https://git.end-play.xyz/profitroll/LibBotUniversal/wiki" Documentation = "https://git.end-play.xyz/profitroll/LibBotUniversal/wiki"
Tracker = "https://git.end-play.xyz/profitroll/LibBotUniversal/issues" Tracker = "https://git.end-play.xyz/profitroll/LibBotUniversal/issues"
[tool.setuptools] [tool.setuptools.packages.find]
packages = [ where = ["src"]
"libbot",
"libbot.sync",
"libbot.pyrogram",
"libbot.pyrogram.classes",
"libbot.i18n",
"libbot.i18n.sync",
"libbot.i18n.classes",
]
[tool.setuptools_scm]
[tool.black] [tool.black]
target-version = ['py38', 'py38', 'py39', 'py310', 'py311'] target-version = ['py38', 'py39', 'py310', 'py311']
[tool.isort] [tool.isort]
profile = "black" profile = "black"
[tool.pytest.ini_options]
minversion = "6.0"
python_files = ["test_*.py"]
pythonpath = "."
testpaths = ["tests"]
[tool.mypy]
namespace_packages = true
install_types = true
strict = true
show_error_codes = true
[tool.pylint.main]
extension-pkg-whitelist = ["ujson"]
py-version = 3.8
[tool.coverage.run]
source = ["libbot"]

1
requirements/_.txt Normal file
View File

@@ -0,0 +1 @@
aiofiles~=23.1.0

10
requirements/dev.txt Normal file
View File

@@ -0,0 +1,10 @@
black==23.7.0
isort==5.12.0
mypy==1.4.1
pylint==2.17.5
pytest-asyncio==0.21.1
pytest-cov==4.1.0
pytest==7.4.0
tox==4.6.4
types-aiofiles==23.1.0.5
types-ujson==5.8.0.1

1
requirements/pycord.txt Normal file
View File

@@ -0,0 +1 @@
py-cord~=2.4.1

View File

@@ -0,0 +1,2 @@
apscheduler~=3.10.1
pyrogram~=2.0.106

1
requirements/speed.txt Normal file
View File

@@ -0,0 +1 @@
ujson~=5.8.0

View File

@@ -1,3 +0,0 @@
[metadata]
description_file=README.md
license_files=LICENSE

View File

@@ -1,4 +0,0 @@
from setuptools import setup
if __name__ == "__main__":
setup()

6
src/libbot/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
__version__ = "2.0.0"
__license__ = "GPL3"
__author__ = "Profitroll"
from . import i18n, pyrogram, sync
from .__main__ import *

View File

@@ -35,6 +35,8 @@ async def json_write(data: Any, path: Union[str, Path]) -> None:
async with aiofiles.open(str(path), mode="w", encoding="utf-8") as f: async with aiofiles.open(str(path), mode="w", encoding="utf-8") as f:
await f.write( await f.write(
dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4) dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4)
if hasattr(dumps, "escape_forward_slashes")
else dumps(data, ensure_ascii=False, indent=4)
) )

View File

@@ -1,33 +1,37 @@
from os import listdir from os import listdir
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict, Union
from libbot import sync import libbot
from libbot.sync import config_get, json_read from libbot.i18n import sync
from libbot.i18n.classes.bot_locale import BotLocale
def _(key: str, *args: str, locale: str = sync.config_get("locale")) -> Any: async def _(
key: str,
*args: str,
locale: Union[str, None] = "en",
locales_root: Union[str, Path] = Path("locale"),
) -> Any:
"""Get value of locale string """Get value of locale string
### Args: ### Args:
* key (`str`): The last key of the locale's keys path * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]` * *args (`list`): Path to key like: `dict[args][key]`.
* locale (`str`): Locale to looked up in. Defaults to config's `"locale"` value * locale (`Union[str, None]`): Locale to looked up in. Defaults to `"en"`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `Any`: Value of provided locale key. Is usually `str`, `dict` or `list` * `Any`: Value of provided locale key. Is usually `str`, `dict` or `list`
""" """
if locale is None: locale = libbot.sync.config_get("locale") if locale is None else locale
locale = sync.config_get("locale")
try: try:
this_dict = json_read( this_dict = await libbot.json_read(Path(f"{locales_root}/{locale}.json"))
Path(f'{config_get("locale", "locations")}/{locale}.json')
)
except FileNotFoundError: except FileNotFoundError:
try: try:
this_dict = json_read( this_dict = await libbot.json_read(
Path(f'{config_get("locale", "locations")}/{config_get("locale")}.json') Path(f'{locales_root}/{await libbot.config_get("locale")}.json')
) )
except FileNotFoundError: except FileNotFoundError:
return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"' return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"'
@@ -42,26 +46,27 @@ def _(key: str, *args: str, locale: str = sync.config_get("locale")) -> Any:
return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"' return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"'
def in_all_locales(key: str, *args: str) -> list: async def in_all_locales(
key: str, *args: str, locales_root: Union[str, Path] = Path("locale")
) -> list:
"""Get value of the provided key and path in all available locales """Get value of the provided key and path in all available locales
### Args: ### Args:
* key (`str`): The last key of the locale's keys path. * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]`. * *args (`list`): Path to key like: `dict[args][key]`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `list`: List of values in all locales * `list`: List of values in all locales
""" """
output = [] output = []
files_locales = listdir(config_get("locale", "locations")) files_locales = listdir(locales_root)
valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales] valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales]
for lc in valid_locales: for lc in valid_locales:
try: try:
this_dict = json_read( this_dict = await libbot.json_read(Path(f"{locales_root}/{lc}.json"))
Path(f'{config_get("locale", "locations")}/{lc}.json')
)
except FileNotFoundError: except FileNotFoundError:
continue continue
@@ -77,26 +82,27 @@ def in_all_locales(key: str, *args: str) -> list:
return output return output
def in_every_locale(key: str, *args: str) -> Dict[str, Any]: async def in_every_locale(
key: str, *args: str, locales_root: Union[str, Path] = Path("locale")
) -> Dict[str, Any]:
"""Get value of the provided key and path in every available locale with locale tag """Get value of the provided key and path in every available locale with locale tag
### Args: ### Args:
* key (`str`): The last key of the locale's keys path. * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]`. * *args (`list`): Path to key like: `dict[args][key]`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `Dict[str, Any]`: Locale is a key and it's value from locale file is a value * `Dict[str, Any]`: Locale is a key and it's value from locale file is a value
""" """
output = {} output = {}
files_locales = listdir(config_get("locale", "locations")) files_locales = listdir(locales_root)
valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales] valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales]
for lc in valid_locales: for lc in valid_locales:
try: try:
this_dict = json_read( this_dict = await libbot.json_read(Path(f"{locales_root}/{lc}.json"))
Path(f'{config_get("locale", "locations")}/{lc}.json')
)
except FileNotFoundError: except FileNotFoundError:
continue continue

View File

@@ -8,25 +8,29 @@ from libbot import sync
class BotLocale: class BotLocale:
"""Small addon that can be used by bot clients' classes in order to minimize I/O""" """Small addon that can be used by bot clients' classes in order to minimize I/O"""
def __init__(self, locales_folder: Union[str, Path, None] = None) -> None: def __init__(
if locales_folder is None: self,
locales_folder = Path(sync.config_get("locale", "locations")) default_locale: Union[str, None] = "en",
elif isinstance(locales_folder, str): locales_root: Union[str, Path] = Path("locale"),
locales_folder = Path(locales_folder) ) -> None:
elif not isinstance(locales_folder, Path): if isinstance(locales_root, str):
raise TypeError("'locales_folder' must be a valid path or path-like object") locales_root = Path(locales_root)
elif not isinstance(locales_root, Path):
raise TypeError("'locales_root' must be a valid path or path-like object")
files_locales: list = listdir(locales_folder) files_locales: list = listdir(locales_root)
valid_locales: list = [ valid_locales: list = [
".".join(entry.split(".")[:-1]) for entry in files_locales ".".join(entry.split(".")[:-1]) for entry in files_locales
] ]
self.default: str = sync.config_get("locale") self.default: str = (
sync.config_get("locale") if default_locale is None else default_locale
)
self.locales: dict = {} self.locales: dict = {}
for lc in valid_locales: for lc in valid_locales:
self.locales[lc] = sync.json_read(Path(f"{locales_folder}/{lc}.json")) self.locales[lc] = sync.json_read(Path(f"{locales_root}/{lc}.json"))
def _(self, key: str, *args: str, locale: Union[str, None] = None) -> Any: def _(self, key: str, *args: str, locale: Union[str, None] = None) -> Any:
"""Get value of locale string """Get value of locale string

View File

@@ -1,35 +1,36 @@
from os import listdir from os import listdir
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict, Union
from libbot import config_get, json_read, sync import libbot
from libbot.i18n.classes.bot_locale import BotLocale
async def _(key: str, *args: str, locale: str = sync.config_get("locale")) -> Any: def _(
key: str,
*args: str,
locale: Union[str, None] = "en",
locales_root: Union[str, Path] = Path("locale"),
) -> Any:
"""Get value of locale string """Get value of locale string
### Args: ### Args:
* key (`str`): The last key of the locale's keys path * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]` * *args (`list`): Path to key like: `dict[args][key]`.
* locale (`str`): Locale to looked up in. Defaults to config's `"locale"` value * locale (`Union[str, None]`): Locale to looked up in. Defaults to `"en"`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `Any`: Value of provided locale key. Is usually `str`, `dict` or `list` * `Any`: Value of provided locale key. Is usually `str`, `dict` or `list`
""" """
if locale is None: if locale is None:
locale = sync.config_get("locale") locale = libbot.sync.config_get("locale")
try: try:
this_dict = await json_read( this_dict = libbot.sync.json_read(Path(f"{locales_root}/{locale}.json"))
Path(f'{await config_get("locale", "locations")}/{locale}.json')
)
except FileNotFoundError: except FileNotFoundError:
try: try:
this_dict = await json_read( this_dict = libbot.sync.json_read(
Path( Path(f'{locales_root}/{libbot.sync.config_get("locale")}.json')
f'{await config_get("locale", "locations")}/{await config_get("locale")}.json'
)
) )
except FileNotFoundError: except FileNotFoundError:
return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"' return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"'
@@ -44,26 +45,27 @@ async def _(key: str, *args: str, locale: str = sync.config_get("locale")) -> An
return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"' return f'⚠️ Locale in config is invalid: could not get "{key}" in {args} from locale "{locale}"'
async def in_all_locales(key: str, *args: str) -> list: def in_all_locales(
key: str, *args: str, locales_root: Union[str, Path] = Path("locale")
) -> list:
"""Get value of the provided key and path in all available locales """Get value of the provided key and path in all available locales
### Args: ### Args:
* key (`str`): The last key of the locale's keys path. * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]`. * *args (`list`): Path to key like: `dict[args][key]`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `list`: List of values in all locales * `list`: List of values in all locales
""" """
output = [] output = []
files_locales = listdir(await config_get("locale", "locations")) files_locales = listdir(locales_root)
valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales] valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales]
for lc in valid_locales: for lc in valid_locales:
try: try:
this_dict = await json_read( this_dict = libbot.sync.json_read(Path(f"{locales_root}/{lc}.json"))
Path(f'{await config_get("locale", "locations")}/{lc}.json')
)
except FileNotFoundError: except FileNotFoundError:
continue continue
@@ -79,26 +81,27 @@ async def in_all_locales(key: str, *args: str) -> list:
return output return output
async def in_every_locale(key: str, *args: str) -> Dict[str, Any]: def in_every_locale(
key: str, *args: str, locales_root: Union[str, Path] = Path("locale")
) -> Dict[str, Any]:
"""Get value of the provided key and path in every available locale with locale tag """Get value of the provided key and path in every available locale with locale tag
### Args: ### Args:
* key (`str`): The last key of the locale's keys path. * key (`str`): The last key of the locale's keys path.
* *args (`list`): Path to key like: `dict[args][key]`. * *args (`list`): Path to key like: `dict[args][key]`.
* locales_root (`Union[str, Path]`, *optional*): Folder where locales are located. Defaults to `Path("locale")`.
### Returns: ### Returns:
* `Dict[str, Any]`: Locale is a key and it's value from locale file is a value * `Dict[str, Any]`: Locale is a key and it's value from locale file is a value
""" """
output = {} output = {}
files_locales = listdir(await config_get("locale", "locations")) files_locales = listdir(locales_root)
valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales] valid_locales = [".".join(entry.split(".")[:-1]) for entry in files_locales]
for lc in valid_locales: for lc in valid_locales:
try: try:
this_dict = await json_read( this_dict = libbot.sync.json_read(Path(f"{locales_root}/{lc}.json"))
Path(f'{await config_get("locale", "locations")}/{lc}.json')
)
except FileNotFoundError: except FileNotFoundError:
continue continue

View File

@@ -1,9 +1,10 @@
import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from os import cpu_count, getpid from os import cpu_count, getpid
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Dict, List, Union from typing import Any, Dict, List, Union
try: try:
import pyrogram import pyrogram
@@ -43,34 +44,67 @@ logger = logging.getLogger(__name__)
class PyroClient(Client): class PyroClient(Client):
def __init__( def __init__(
self, scheduler: Union[AsyncIOScheduler, BackgroundScheduler, None] = None self,
name: str = "bot_client",
config: Union[Dict[str, Any], None] = None,
config_path: Union[str, Path] = Path("config.json"),
api_id: Union[int, None] = None,
api_hash: Union[str, None] = None,
bot_token: Union[str, None] = None,
workers: int = min(32, cpu_count() + 4),
locales_root: Union[str, Path, None] = None,
plugins_root: str = "plugins",
plugins_exclude: Union[List[str], None] = None,
sleep_threshold: int = 120,
max_concurrent_transmissions: int = 1,
commands_source: Union[Dict[str, dict], None] = None,
scheduler: Union[AsyncIOScheduler, BackgroundScheduler, None] = None,
**kwargs,
): ):
with open("config.json", "r", encoding="utf-8") as f: if config is None:
self.config: dict = loads(f.read()) with open(config_path, "r", encoding="utf-8") as f:
self.config: dict = loads(f.read())
else:
self.config = config
super().__init__( super().__init__(
name="bot_client", name=name,
api_id=self.config["bot"]["api_id"], api_id=self.config["bot"]["api_id"] if api_id is None else api_id,
api_hash=self.config["bot"]["api_hash"], api_hash=self.config["bot"]["api_hash"] if api_hash is None else api_hash,
bot_token=self.config["bot"]["bot_token"], bot_token=self.config["bot"]["bot_token"]
# Workers should be commented when using convopyro, otherwise if bot_token is None
else bot_token,
# Workers should be `min(32, cpu_count() + 4)`, otherwise
# handlers land in another event loop and you won't see them # handlers land in another event loop and you won't see them
workers=self.config["bot"]["workers"] workers=self.config["bot"]["workers"]
if "workers" in self.config["bot"] if "workers" in self.config["bot"]
else min(32, cpu_count() + 4), else workers,
plugins=dict(root="plugins", exclude=self.config["disabled_plugins"]), plugins=dict(
sleep_threshold=120, root=plugins_root,
exclude=self.config["disabled_plugins"]
if plugins_exclude is None
else plugins_exclude,
),
sleep_threshold=sleep_threshold,
max_concurrent_transmissions=self.config["bot"][ max_concurrent_transmissions=self.config["bot"][
"max_concurrent_transmissions" "max_concurrent_transmissions"
] ]
if "max_concurrent_transmissions" in self.config["bot"] if "max_concurrent_transmissions" in self.config["bot"]
else 1, else max_concurrent_transmissions,
**kwargs,
) )
self.owner: int = self.config["bot"]["owner"] self.owner: int = self.config["bot"]["owner"]
self.commands: List[PyroCommand] = [] self.commands: List[PyroCommand] = []
self.commands_source: Dict[str, dict] = (
self.config["commands"] if commands_source is None else commands_source
)
self.scoped_commands: bool = self.config["bot"]["scoped_commands"] self.scoped_commands: bool = self.config["bot"]["scoped_commands"]
self.start_time: float = 0 self.start_time: float = 0
self.bot_locale: BotLocale = BotLocale(Path(self.config["locations"]["locale"])) self.bot_locale: BotLocale = BotLocale(
default_locale=self.config["locale"],
locales_root=(Path("locale") if locales_root is None else locales_root)
)
self.default_locale: str = self.bot_locale.default self.default_locale: str = self.bot_locale.default
self.locales: dict = self.bot_locale.locales self.locales: dict = self.bot_locale.locales
@@ -82,7 +116,7 @@ class PyroClient(Client):
self.scopes_placeholders: Dict[str, int] = {"owner": self.owner} self.scopes_placeholders: Dict[str, int] = {"owner": self.owner}
async def start(self): async def start(self, register_commands: bool = True) -> None:
await super().start() await super().start()
self.start_time = time() self.start_time = time()
@@ -97,35 +131,50 @@ class PyroClient(Client):
try: try:
await self.send_message( await self.send_message(
chat_id=self.config["reports"]["chat_id"], chat_id=self.owner
if self.config["reports"]["chat_id"] == "owner"
else self.config["reports"]["chat_id"],
text=f"Bot started PID `{getpid()}`", text=f"Bot started PID `{getpid()}`",
) )
if self.scheduler is None: if self.scheduler is None:
return return
self.scheduler.add_job( if register_commands:
self.register_commands, self.scheduler.add_job(
trigger="date", self.register_commands,
run_date=datetime.now() + timedelta(seconds=5), trigger="date",
kwargs={"command_sets": await self.collect_commands()}, run_date=datetime.now() + timedelta(seconds=5),
) kwargs={"command_sets": await self.collect_commands()},
)
self.scheduler.start() self.scheduler.start()
except BadRequest: except BadRequest:
logger.warning("Unable to send message to report chat.") logger.warning("Unable to send message to report chat.")
async def stop(self): async def stop(self, exit_completely: bool = True) -> None:
try: try:
await self.send_message( await self.send_message(
chat_id=self.config["reports"]["chat_id"], chat_id=self.owner
if self.config["reports"]["chat_id"] == "owner"
else self.config["reports"]["chat_id"],
text=f"Bot stopped with PID `{getpid()}`", text=f"Bot stopped with PID `{getpid()}`",
) )
await asyncio.sleep(0.5)
except BadRequest: except BadRequest:
logger.warning("Unable to send message to report chat.") logger.warning("Unable to send message to report chat.")
await super().stop() await super().stop()
logger.warning("Bot stopped with PID %s.", getpid()) logger.warning("Bot stopped with PID %s.", getpid())
if exit_completely:
try:
exit()
except SystemExit as exp:
raise SystemExit(
"Bot has been shut down, this is not an application error!"
) from exp
async def collect_commands(self) -> Union[List[CommandSet], None]: async def collect_commands(self) -> Union[List[CommandSet], None]:
"""Gather list of the bot's commands """Gather list of the bot's commands
@@ -141,7 +190,7 @@ class PyroClient(Client):
command_sets = [] command_sets = []
# Iterate through all commands in config # Iterate through all commands in config
for command, contents in self.config["commands"].items(): for command, contents in self.commands_source.items():
# Iterate through all scopes of a command # Iterate through all scopes of a command
for scope in contents["scopes"]: for scope in contents["scopes"]:
if dumps(scope) not in scopes: if dumps(scope) not in scopes:
@@ -221,7 +270,7 @@ class PyroClient(Client):
def add_command( def add_command(
self, self,
command: str, command: str,
): ) -> None:
"""Add command to the bot's internal commands list """Add command to the bot's internal commands list
### Args: ### Args:
@@ -240,7 +289,7 @@ class PyroClient(Client):
async def register_commands( async def register_commands(
self, command_sets: Union[List[CommandSet], None] = None self, command_sets: Union[List[CommandSet], None] = None
): ) -> None:
"""Register commands stored in bot's 'commands' attribute""" """Register commands stored in bot's 'commands' attribute"""
if command_sets is None: if command_sets is None:
@@ -269,7 +318,9 @@ class PyroClient(Client):
language_code=command_set.language_code, language_code=command_set.language_code,
) )
async def remove_commands(self, command_sets: Union[List[CommandSet], None] = None): async def remove_commands(
self, command_sets: Union[List[CommandSet], None] = None
) -> None:
"""Remove commands stored in bot's 'commands' attribute""" """Remove commands stored in bot's 'commands' attribute"""
if command_sets is None: if command_sets is None:

View File

@@ -29,7 +29,11 @@ def json_write(data: Any, path: Union[str, Path]) -> None:
* path (`Union[str, Path]`): Path-like object or path as a string of a destination * path (`Union[str, Path]`): Path-like object or path as a string of a destination
""" """
with open(str(path), mode="w", encoding="utf-8") as f: with open(str(path), mode="w", encoding="utf-8") as f:
f.write(dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4)) f.write(
dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4)
if hasattr(dumps, "escape_forward_slashes")
else dumps(data, ensure_ascii=False, indent=4)
)
def nested_set(target: dict, value: Any, *path: str, create_missing=True) -> dict: def nested_set(target: dict, value: Any, *path: str, create_missing=True) -> dict:
@@ -51,7 +55,9 @@ def nested_set(target: dict, value: Any, *path: str, create_missing=True) -> dic
elif create_missing: elif create_missing:
d = d.setdefault(key, {}) d = d.setdefault(key, {})
else: else:
return target raise KeyError(
f"Key '{key}' is not found under path provided ({path}) and create_missing is False"
)
if path[-1] in d or create_missing: if path[-1] in d or create_missing:
d[path[-1]] = value d[path[-1]] = value
return target return target

6
tests/config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"locale": "en",
"bot": {
"bot_token": "sample_token"
}
}

20
tests/conftest.py Normal file
View File

@@ -0,0 +1,20 @@
from pathlib import Path
import pytest
from libbot.i18n import BotLocale
@pytest.fixture()
def location_config() -> Path:
return Path("tests/config.json")
@pytest.fixture()
def location_locale() -> Path:
return Path("tests/data/locale/")
@pytest.fixture()
def bot_locale(location_locale: Path) -> BotLocale:
return BotLocale(locales_root=location_locale)

1
tests/data/empty.json Normal file
View File

@@ -0,0 +1 @@
{}

3
tests/data/invalid.json Normal file
View File

@@ -0,0 +1,3 @@
{
"foo": 'bar'
}

11
tests/data/locale/en.json Normal file
View File

@@ -0,0 +1,11 @@
{
"foo": "bar",
"messages": {
"example": "okay"
},
"callbacks": {
"default": {
"nested": "sure"
}
}
}

11
tests/data/locale/uk.json Normal file
View File

@@ -0,0 +1,11 @@
{
"foo": "бар",
"messages": {
"example": "окей"
},
"callbacks": {
"default": {
"nested": "авжеж"
}
}
}

15
tests/data/test.json Normal file
View File

@@ -0,0 +1,15 @@
{
"foo": "bar",
"abcdefg": [
"higklmnop",
{
"lol": {
"kek": [
1.0000035,
0.2542,
1337
]
}
}
]
}

59
tests/test_bot_locale.py Normal file
View File

@@ -0,0 +1,59 @@
from pathlib import Path
from typing import Any, List, Union
import pytest
from libbot.i18n import BotLocale
@pytest.mark.parametrize(
"key, args, locale, expected",
[
("foo", [], None, "bar"),
("foo", [], "uk", "бар"),
("example", ["messages"], None, "okay"),
("example", ["messages"], "uk", "окей"),
("nested", ["callbacks", "default"], None, "sure"),
("nested", ["callbacks", "default"], "uk", "авжеж"),
],
)
def test_bot_locale_get(
key: str,
args: List[str],
locale: Union[str, None],
expected: Any,
bot_locale: BotLocale,
):
assert (
bot_locale._(key, *args, locale=locale)
if locale is not None
else bot_locale._(key, *args)
) == expected
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], ["bar", "бар"]),
("example", ["messages"], ["okay", "окей"]),
("nested", ["callbacks", "default"], ["sure", "авжеж"]),
],
)
def test_i18n_in_all_locales(
key: str, args: List[str], expected: Any, bot_locale: BotLocale
):
assert (bot_locale.in_all_locales(key, *args)) == expected
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], {"en": "bar", "uk": "бар"}),
("example", ["messages"], {"en": "okay", "uk": "окей"}),
("nested", ["callbacks", "default"], {"en": "sure", "uk": "авжеж"}),
],
)
def test_i18n_in_every_locale(
key: str, args: List[str], expected: Any, bot_locale: BotLocale
):
assert (bot_locale.in_every_locale(key, *args)) == expected

View File

@@ -0,0 +1,48 @@
from pathlib import Path
from typing import Any, List
import pytest
from libbot import config_get, config_set
@pytest.mark.asyncio
@pytest.mark.parametrize(
"args, expected",
[
(["locale"], "en"),
(["bot_token", "bot"], "sample_token"),
],
)
async def test_config_get_valid(args: List[str], expected: str, location_config: Path):
assert await config_get(args[0], *args[1:], config_file=location_config) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"args, expected",
[
(["bot_stonks", "bot"], pytest.raises(KeyError)),
],
)
async def test_config_get_invalid(
args: List[str], expected: Any, location_config: Path
):
with expected:
assert (
await config_get(args[0], *args[1:], config_file=location_config)
== expected
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"key, path, value",
[
("locale", [], "en"),
("bot_token", ["bot"], "sample_token"),
],
)
async def test_config_set(key: str, path: List[str], value: Any, location_config: Path):
await config_set(key, value, *path, config_file=location_config)
assert await config_get(key, *path, config_file=location_config) == value

42
tests/test_config_sync.py Normal file
View File

@@ -0,0 +1,42 @@
from pathlib import Path
from typing import Any, List
import pytest
from libbot import sync
@pytest.mark.parametrize(
"args, expected",
[
(["locale"], "en"),
(["bot_token", "bot"], "sample_token"),
],
)
def test_config_get_valid(args: List[str], expected: str, location_config: Path):
assert sync.config_get(args[0], *args[1:], config_file=location_config) == expected
@pytest.mark.parametrize(
"args, expected",
[
(["bot_stonks", "bot"], pytest.raises(KeyError)),
],
)
def test_config_get_invalid(args: List[str], expected: Any, location_config: Path):
with expected:
assert (
sync.config_get(args[0], *args[1:], config_file=location_config) == expected
)
@pytest.mark.parametrize(
"key, path, value",
[
("locale", [], "en"),
("bot_token", ["bot"], "sample_token"),
],
)
def test_config_set(key: str, path: List[str], value: Any, location_config: Path):
sync.config_set(key, value, *path, config_file=location_config)
assert sync.config_get(key, *path, config_file=location_config) == value

66
tests/test_i18n_async.py Normal file
View File

@@ -0,0 +1,66 @@
from pathlib import Path
from typing import Any, List, Union
import pytest
from libbot import i18n
@pytest.mark.asyncio
@pytest.mark.parametrize(
"key, args, locale, expected",
[
("foo", [], None, "bar"),
("foo", [], "uk", "бар"),
("example", ["messages"], None, "okay"),
("example", ["messages"], "uk", "окей"),
("nested", ["callbacks", "default"], None, "sure"),
("nested", ["callbacks", "default"], "uk", "авжеж"),
],
)
async def test_i18n_get(
key: str,
args: List[str],
locale: Union[str, None],
expected: Any,
location_locale: Path,
):
assert (
await i18n._(key, *args, locale=locale, locales_root=location_locale)
if locale is not None
else await i18n._(key, *args, locales_root=location_locale)
) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], ["bar", "бар"]),
("example", ["messages"], ["okay", "окей"]),
("nested", ["callbacks", "default"], ["sure", "авжеж"]),
],
)
async def test_i18n_in_all_locales(
key: str, args: List[str], expected: Any, location_locale: Path
):
assert (
await i18n.in_all_locales(key, *args, locales_root=location_locale)
) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], {"en": "bar", "uk": "бар"}),
("example", ["messages"], {"en": "okay", "uk": "окей"}),
("nested", ["callbacks", "default"], {"en": "sure", "uk": "авжеж"}),
],
)
async def test_i18n_in_every_locale(
key: str, args: List[str], expected: Any, location_locale: Path
):
assert (
await i18n.in_every_locale(key, *args, locales_root=location_locale)
) == expected

59
tests/test_i18n_sync.py Normal file
View File

@@ -0,0 +1,59 @@
from pathlib import Path
from typing import Any, List, Union
import pytest
from libbot.i18n import sync
@pytest.mark.parametrize(
"key, args, locale, expected",
[
("foo", [], None, "bar"),
("foo", [], "uk", "бар"),
("example", ["messages"], None, "okay"),
("example", ["messages"], "uk", "окей"),
("nested", ["callbacks", "default"], None, "sure"),
("nested", ["callbacks", "default"], "uk", "авжеж"),
],
)
def test_i18n_get(
key: str,
args: List[str],
locale: Union[str, None],
expected: Any,
location_locale: Path,
):
assert (
sync._(key, *args, locale=locale, locales_root=location_locale)
if locale is not None
else sync._(key, *args, locales_root=location_locale)
) == expected
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], ["bar", "бар"]),
("example", ["messages"], ["okay", "окей"]),
("nested", ["callbacks", "default"], ["sure", "авжеж"]),
],
)
def test_i18n_in_all_locales(
key: str, args: List[str], expected: Any, location_locale: Path
):
assert (sync.in_all_locales(key, *args, locales_root=location_locale)) == expected
@pytest.mark.parametrize(
"key, args, expected",
[
("foo", [], {"en": "bar", "uk": "бар"}),
("example", ["messages"], {"en": "okay", "uk": "окей"}),
("nested", ["callbacks", "default"], {"en": "sure", "uk": "авжеж"}),
],
)
def test_i18n_in_every_locale(
key: str, args: List[str], expected: Any, location_locale: Path
):
assert (sync.in_every_locale(key, *args, locales_root=location_locale)) == expected

64
tests/test_json_async.py Normal file
View File

@@ -0,0 +1,64 @@
try:
from ujson import JSONDecodeError, dumps
except ImportError:
from json import dumps, JSONDecodeError
from pathlib import Path
from typing import Any, Union
import pytest
from libbot import json_read, json_write
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path, expected",
[
(
"tests/data/test.json",
{
"foo": "bar",
"abcdefg": ["higklmnop", {"lol": {"kek": [1.0000035, 0.2542, 1337]}}],
},
),
("tests/data/empty.json", {}),
],
)
async def test_json_read_valid(path: Union[str, Path], expected: Any):
assert await json_read(path) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path, expected",
[
("tests/data/invalid.json", JSONDecodeError),
("tests/data/nonexistent.json", FileNotFoundError),
],
)
async def test_json_read_invalid(path: Union[str, Path], expected: Any):
with pytest.raises(expected):
await json_read(path)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"data, path",
[
(
{
"foo": "bar",
"abcdefg": ["higklmnop", {"lol": {"kek": [1.0000035, 0.2542, 1337]}}],
},
"tests/data/test.json",
),
({}, "tests/data/empty.json"),
],
)
async def test_json_write(data: Any, path: Union[str, Path]):
await json_write(data, path)
assert Path(path).is_file()
with open(path, "r", encoding="utf-8") as f:
assert f.read() == dumps(data, ensure_ascii=False, indent=4)

61
tests/test_json_sync.py Normal file
View File

@@ -0,0 +1,61 @@
try:
from ujson import JSONDecodeError, dumps
except ImportError:
from json import dumps, JSONDecodeError
from pathlib import Path
from typing import Any, Union
import pytest
from libbot import sync
@pytest.mark.parametrize(
"path, expected",
[
(
"tests/data/test.json",
{
"foo": "bar",
"abcdefg": ["higklmnop", {"lol": {"kek": [1.0000035, 0.2542, 1337]}}],
},
),
("tests/data/empty.json", {}),
],
)
def test_json_read_valid(path: Union[str, Path], expected: Any):
assert sync.json_read(path) == expected
@pytest.mark.parametrize(
"path, expected",
[
("tests/data/invalid.json", JSONDecodeError),
("tests/data/nonexistent.json", FileNotFoundError),
],
)
def test_json_read_invalid(path: Union[str, Path], expected: Any):
with pytest.raises(expected):
assert sync.json_read(path) == expected
@pytest.mark.parametrize(
"data, path",
[
(
{
"foo": "bar",
"abcdefg": ["higklmnop", {"lol": {"kek": [1.0000035, 0.2542, 1337]}}],
},
"tests/data/test.json",
),
({}, "tests/data/empty.json"),
],
)
def test_json_write(data: Any, path: Union[str, Path]):
sync.json_write(data, path)
assert Path(path).is_file()
with open(path, "r", encoding="utf-8") as f:
assert f.read() == dumps(data, ensure_ascii=False, indent=4)

63
tests/test_nested_set.py Normal file
View File

@@ -0,0 +1,63 @@
from typing import Any, Dict, List
import pytest
from libbot import sync
@pytest.mark.parametrize(
"target, value, path, create_missing, expected",
[
({"foo": "bar"}, "rab", ["foo"], True, {"foo": "rab"}),
({"foo": "bar"}, {"123": 456}, ["foo"], True, {"foo": {"123": 456}}),
(
{"foo": {"bar": {}}},
True,
["foo", "bar", "test"],
True,
{"foo": {"bar": {"test": True}}},
),
(
{"foo": {"bar": {}}},
True,
["foo", "bar", "test"],
False,
{"foo": {"bar": {}}},
),
],
)
def test_nested_set_valid(
target: Dict[str, Any],
value: Any,
path: List[str],
create_missing: bool,
expected: Any,
):
assert (
sync.nested_set(target, value, *path, create_missing=create_missing)
) == expected
@pytest.mark.parametrize(
"target, value, path, create_missing, expected",
[
(
{"foo": {"bar": {}}},
True,
["foo", "bar", "test1", "test2"],
False,
KeyError,
),
],
)
def test_nested_set_invalid(
target: Dict[str, Any],
value: Any,
path: List[str],
create_missing: bool,
expected: Any,
):
with pytest.raises(expected):
assert (
sync.nested_set(target, value, *path, create_missing=create_missing)
) == expected

23
tox.ini Normal file
View File

@@ -0,0 +1,23 @@
[tox]
minversion = 3.8.0
envlist = py38, py39, py310, py311
isolated_build = true
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
[testenv]
setenv =
PYTHONPATH = {toxinidir}
deps =
-r{toxinidir}/requirements/_.txt
-r{toxinidir}/requirements/dev.txt
-r{toxinidir}/requirements/pycord.txt
-r{toxinidir}/requirements/pyrogram.txt
-r{toxinidir}/requirements/speed.txt
commands =
pytest --basetemp={envtmpdir} --cov=libbot