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.
@@ -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,11 @@
1
+ from ..protocols import Broker
2
+
3
+
4
+ __all__ = [
5
+ 'broker_nats',
6
+ ]
7
+
8
+
9
+ def broker_nats() -> Broker:
10
+ from ._nats import BrokerNats
11
+ return BrokerNats()
@@ -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)