vasyan-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vasyan_core-0.1.0/PKG-INFO +14 -0
- vasyan_core-0.1.0/README.md +0 -0
- vasyan_core-0.1.0/pyproject.toml +20 -0
- vasyan_core-0.1.0/src/vasyan_core/__init__.py +18 -0
- vasyan_core-0.1.0/src/vasyan_core/brokers/__init__.py +11 -0
- vasyan_core-0.1.0/src/vasyan_core/brokers/_nats.py +39 -0
- vasyan_core-0.1.0/src/vasyan_core/cli.py +14 -0
- vasyan_core-0.1.0/src/vasyan_core/config.py +30 -0
- vasyan_core-0.1.0/src/vasyan_core/logger.py +17 -0
- vasyan_core-0.1.0/src/vasyan_core/protocols.py +19 -0
- vasyan_core-0.1.0/src/vasyan_core/py.typed +0 -0
- vasyan_core-0.1.0/src/vasyan_core/service.py +47 -0
- vasyan_core-0.1.0/src/vasyan_core/systemd.py +59 -0
- vasyan_core-0.1.0/src/vasyan_core/utils.py +88 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: vasyan-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TODO
|
|
5
|
+
Author: Roman Karzhavin
|
|
6
|
+
Author-email: Roman Karzhavin <karzhavin.rd@ya.ru>
|
|
7
|
+
Requires-Dist: fire>=0.7.1
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: nats-py>=2.14.0
|
|
10
|
+
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vasyan_core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "TODO"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Roman Karzhavin", email = "karzhavin.rd@ya.ru" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fire>=0.7.1",
|
|
12
|
+
"loguru>=0.7.3",
|
|
13
|
+
"nats-py>=2.14.0",
|
|
14
|
+
"pydantic>=2.12.5",
|
|
15
|
+
"pyyaml>=6.0.3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.8.22,<0.9.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'logger',
|
|
3
|
+
'configure_logger',
|
|
4
|
+
'brokers',
|
|
5
|
+
'protocols',
|
|
6
|
+
'utils',
|
|
7
|
+
'Cli',
|
|
8
|
+
'Config',
|
|
9
|
+
'ProtoService',
|
|
10
|
+
'SystemdWrapper',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
from . import brokers, protocols, utils
|
|
14
|
+
from .cli import Cli
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .logger import configure_logger, logger
|
|
17
|
+
from .service import ProtoService
|
|
18
|
+
from .systemd import SystemdWrapper
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import nats
|
|
2
|
+
from nats.aio.client import Client
|
|
3
|
+
from nats.aio.subscription import Subscription
|
|
4
|
+
|
|
5
|
+
from ..protocols import SubscribeCallback
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BrokerNats:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._connection: Client | None = None
|
|
11
|
+
self._subscribes: list[Subscription] = []
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def connection(self) -> Client:
|
|
15
|
+
if self._connection is None:
|
|
16
|
+
raise Exception('TODO')
|
|
17
|
+
return self._connection
|
|
18
|
+
|
|
19
|
+
async def connect(self) -> None:
|
|
20
|
+
self._connection = await nats.connect()
|
|
21
|
+
|
|
22
|
+
async def disconnect(self) -> None:
|
|
23
|
+
for sub in self._subscribes:
|
|
24
|
+
await sub.unsubscribe()
|
|
25
|
+
await self.connection.drain()
|
|
26
|
+
self._connection = None
|
|
27
|
+
|
|
28
|
+
async def send(self, subject: str, payload: bytes) -> None:
|
|
29
|
+
await self.connection.publish(
|
|
30
|
+
subject=subject,
|
|
31
|
+
payload=payload
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def subscribe(self, subject: str, callback: SubscribeCallback) -> None:
|
|
35
|
+
subscribe = await self.connection.subscribe(
|
|
36
|
+
subject=subject,
|
|
37
|
+
cb=callback
|
|
38
|
+
)
|
|
39
|
+
self._subscribes.append(subscribe)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
from .service import ProtoService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cli:
|
|
8
|
+
def __init__(self, service: type[ProtoService]) -> None:
|
|
9
|
+
self._service_class = service
|
|
10
|
+
|
|
11
|
+
def run(self, *, config: str, name: str | None = None) -> None:
|
|
12
|
+
config_path = pathlib.Path(config)
|
|
13
|
+
service = self._service_class(config_path, name)
|
|
14
|
+
asyncio.run(service.run())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
LoggerLevel = typing.Annotated[
|
|
8
|
+
typing.Literal['TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL'],
|
|
9
|
+
pydantic.BeforeValidator(lambda x: x.upper())
|
|
10
|
+
]
|
|
11
|
+
GenericConfig = typing.TypeVar('GenericConfig', bound='Config')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoggerConfig(pydantic.BaseModel):
|
|
15
|
+
level: LoggerLevel = 'INFO'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Config(pydantic.BaseModel):
|
|
19
|
+
logger: LoggerConfig = pydantic.Field(default_factory=LoggerConfig)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _read_config_file(cls, config_path: pathlib.Path, service_name: str) -> dict:
|
|
23
|
+
with open(config_path, 'r') as f:
|
|
24
|
+
data = yaml.safe_load(f)
|
|
25
|
+
return data.get(service_name, {})
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def load(cls, config_path: pathlib.Path, service_name: str) -> typing.Self:
|
|
29
|
+
raw_data = cls._read_config_file(config_path, service_name)
|
|
30
|
+
return cls.model_validate(raw_data)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
from .config import LoggerConfig
|
|
6
|
+
|
|
7
|
+
logger.remove()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logger(config: LoggerConfig | None = None) -> None:
|
|
11
|
+
logger_level = config.level if config else 'DEBUG'
|
|
12
|
+
logger.add(
|
|
13
|
+
sys.stdout,
|
|
14
|
+
colorize=True,
|
|
15
|
+
format='<level>{message}</level>',
|
|
16
|
+
level=logger_level
|
|
17
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrokerMessage(typing.Protocol):
|
|
5
|
+
subject: str
|
|
6
|
+
data: bytes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SubscribeCallback = typing.Callable[[BrokerMessage], typing.Awaitable[None]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Broker(typing.Protocol):
|
|
13
|
+
async def connect(self) -> None: ...
|
|
14
|
+
|
|
15
|
+
async def disconnect(self) -> None: ...
|
|
16
|
+
|
|
17
|
+
async def send(self, subject: str, payload: bytes) -> None: ...
|
|
18
|
+
|
|
19
|
+
async def subscribe(self, subject: str, callback: SubscribeCallback) -> None: ...
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import asyncio
|
|
3
|
+
import pathlib
|
|
4
|
+
import signal
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from .config import GenericConfig
|
|
8
|
+
from .logger import configure_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProtoService(abc.ABC, typing.Generic[GenericConfig]):
|
|
12
|
+
__service_name__: str
|
|
13
|
+
__class_config__: type[GenericConfig]
|
|
14
|
+
|
|
15
|
+
def __init__(self, config_path: pathlib.Path, service_name: str | None = None) -> None:
|
|
16
|
+
self._service_name = service_name or self.__service_name__
|
|
17
|
+
self._config = self.__class_config__.load(config_path, self._service_name)
|
|
18
|
+
configure_logger(self._config.logger)
|
|
19
|
+
self.__post_init__()
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
async def _run_service(self) -> None:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def _stop_service(self) -> None:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def _bind_stop_signals(self) -> None:
|
|
33
|
+
loop = asyncio.get_event_loop()
|
|
34
|
+
loop.add_signal_handler(signal.SIGINT, self._stop_service)
|
|
35
|
+
loop.add_signal_handler(signal.SIGTERM, self._stop_service)
|
|
36
|
+
|
|
37
|
+
async def _prepare_service(self) -> None:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
async def _cleanup_service(self) -> None:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
async def run(self) -> None:
|
|
44
|
+
self._bind_stop_signals()
|
|
45
|
+
await self._prepare_service()
|
|
46
|
+
await self._run_service()
|
|
47
|
+
await self._cleanup_service()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
from .utils import CommandResult, run_command
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SystemdWrapperError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SystemdWrapper:
|
|
12
|
+
def __init__(self, user: bool = True) -> None:
|
|
13
|
+
self._user = user
|
|
14
|
+
|
|
15
|
+
def run_command(self, command: str) -> CommandResult:
|
|
16
|
+
systemctl = 'systemctl --user' if self._user else 'systemctl'
|
|
17
|
+
return run_command(f'{systemctl} {command}')
|
|
18
|
+
|
|
19
|
+
def _get_units_folder_path(self) -> pathlib.Path:
|
|
20
|
+
if not self._user:
|
|
21
|
+
raise SystemdWrapperError('Global systemd units not support')
|
|
22
|
+
|
|
23
|
+
home_path = pathlib.Path(os.environ['HOME'])
|
|
24
|
+
return home_path / '.config/systemd/user'
|
|
25
|
+
|
|
26
|
+
def check_exists(self, unit_name: str) -> bool:
|
|
27
|
+
result = self.run_command(f'systemctl --user cat {unit_name} > /dev/null 2>&1')
|
|
28
|
+
return bool(result)
|
|
29
|
+
|
|
30
|
+
def daemon_reload(self) -> None:
|
|
31
|
+
if self.run_command('daemon-reload'):
|
|
32
|
+
return
|
|
33
|
+
raise SystemdWrapperError('Unable to reload systemd daemons')
|
|
34
|
+
|
|
35
|
+
def start(self, unit_name: str) -> None:
|
|
36
|
+
if self.run_command(f'start {unit_name}'):
|
|
37
|
+
return
|
|
38
|
+
raise SystemdWrapperError(f'Unable to start {unit_name} systemd unit')
|
|
39
|
+
|
|
40
|
+
def stop(self, unit_name: str, missing_ok: bool = False) -> None:
|
|
41
|
+
exists = self.check_exists(unit_name)
|
|
42
|
+
if not exists and missing_ok:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if self.run_command(f'stop {unit_name}'):
|
|
46
|
+
return
|
|
47
|
+
raise SystemdWrapperError(f'Unable to stop {unit_name} systemd unit')
|
|
48
|
+
|
|
49
|
+
def enable(self, unit_name: str) -> None:
|
|
50
|
+
if self.run_command(f'enable {unit_name}'):
|
|
51
|
+
return
|
|
52
|
+
raise SystemdWrapperError(f'Unable to enable {unit_name} systemd unit')
|
|
53
|
+
|
|
54
|
+
def create(self, unit_name: str, unit_data: str) -> None:
|
|
55
|
+
units_folder_path = self._get_units_folder_path()
|
|
56
|
+
units_folder_path.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
unit_path = units_folder_path.joinpath(unit_name)
|
|
59
|
+
unit_path.write_text(unit_data)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import functools
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
T = typing.TypeVar('T')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def asynchronous[T, **P](func: typing.Callable[P, T]) -> typing.Callable[P, typing.Awaitable[T]]:
|
|
16
|
+
@functools.wraps(func)
|
|
17
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
18
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
19
|
+
return wrapper
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclasses.dataclass(frozen=True)
|
|
23
|
+
class CommandResult:
|
|
24
|
+
stdout: str = ''
|
|
25
|
+
stderr: str = ''
|
|
26
|
+
return_code: int | None = None
|
|
27
|
+
|
|
28
|
+
def __bool__(self) -> bool:
|
|
29
|
+
return self.return_code == 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_command(command: str, wait: bool = True) -> CommandResult:
|
|
33
|
+
proc = subprocess.Popen(
|
|
34
|
+
[command],
|
|
35
|
+
stdout=subprocess.PIPE,
|
|
36
|
+
stderr=subprocess.PIPE,
|
|
37
|
+
shell=True,
|
|
38
|
+
)
|
|
39
|
+
if not wait:
|
|
40
|
+
return CommandResult()
|
|
41
|
+
|
|
42
|
+
stdout, stderr = proc.communicate()
|
|
43
|
+
return CommandResult(
|
|
44
|
+
stdout=stdout.decode().strip(),
|
|
45
|
+
stderr=stderr.decode().strip(),
|
|
46
|
+
return_code=proc.returncode
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def balanced_loop(
|
|
51
|
+
stop_event: asyncio.Event,
|
|
52
|
+
interval: float,
|
|
53
|
+
tick: float = 1.0
|
|
54
|
+
) -> typing.AsyncGenerator[float, None]:
|
|
55
|
+
interval = max(interval, 0)
|
|
56
|
+
start_time = last_yield = time.time()
|
|
57
|
+
yield last_yield
|
|
58
|
+
|
|
59
|
+
while not stop_event.is_set():
|
|
60
|
+
try:
|
|
61
|
+
time_to_yield = interval - (time.time() - start_time) % interval
|
|
62
|
+
except ZeroDivisionError:
|
|
63
|
+
time_to_yield = 0
|
|
64
|
+
|
|
65
|
+
if time_to_yield > tick:
|
|
66
|
+
await asyncio.sleep(tick)
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
await asyncio.sleep(time_to_yield)
|
|
70
|
+
last_yield = time.time()
|
|
71
|
+
yield last_yield
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_by_regex(lines: typing.Iterable[str],
|
|
75
|
+
regex: re.Pattern,
|
|
76
|
+
*,
|
|
77
|
+
group: int = 0
|
|
78
|
+
) -> typing.Generator[str, None, None]:
|
|
79
|
+
for line in lines:
|
|
80
|
+
if (regex_match := regex.search(line)) is None:
|
|
81
|
+
continue
|
|
82
|
+
yield regex_match.group(group)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def terminate(message: str, exit_code: int = 1) -> typing.NoReturn:
|
|
86
|
+
log_func = logger.info if exit_code == 0 else logger.error
|
|
87
|
+
log_func(message)
|
|
88
|
+
sys.exit(exit_code)
|