aiobp 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.
- aiobp-0.1.0/LICENSE +19 -0
- aiobp-0.1.0/PKG-INFO +110 -0
- aiobp-0.1.0/README.md +97 -0
- aiobp-0.1.0/aiobp/__init__.py +7 -0
- aiobp-0.1.0/aiobp/config/__init__.py +27 -0
- aiobp-0.1.0/aiobp/config/__pycache__/__init__.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/__pycache__/annotations.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/__pycache__/conf.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/__pycache__/exceptions.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/annotations.py +116 -0
- aiobp-0.1.0/aiobp/config/conf.py +37 -0
- aiobp-0.1.0/aiobp/config/exceptions.py +10 -0
- aiobp-0.1.0/aiobp/config/json.py +36 -0
- aiobp-0.1.0/aiobp/config/loader/__init__.py +0 -0
- aiobp-0.1.0/aiobp/config/loader/__pycache__/__init__.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/loader/__pycache__/conf.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/config/loader/conf.py +39 -0
- aiobp-0.1.0/aiobp/config/loader/json.py +36 -0
- aiobp-0.1.0/aiobp/logging/__init__.py +4 -0
- aiobp-0.1.0/aiobp/logging/__pycache__/__init__.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/logging/__pycache__/custom.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/logging/__pycache__/log.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/logging/__pycache__/logging.cpython-311.pyc +0 -0
- aiobp-0.1.0/aiobp/logging/custom.py +100 -0
- aiobp-0.1.0/aiobp/logging/log.py +13 -0
- aiobp-0.1.0/aiobp/runner.py +228 -0
- aiobp-0.1.0/aiobp/task.py +20 -0
- aiobp-0.1.0/pyproject.toml +19 -0
aiobp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2024 INSOFT s.r.o.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
aiobp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aiobp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Boilerplate for asyncio service
|
|
5
|
+
Author: INSOFT s.r.o.
|
|
6
|
+
Requires-Python: >=3.9,<4.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
Boilerplate for asyncio service
|
|
14
|
+
===============================
|
|
15
|
+
|
|
16
|
+
This module provides boilerplate for microservices written in asyncio:
|
|
17
|
+
|
|
18
|
+
* Runner with task reference handler and graceful shutdown
|
|
19
|
+
* Configuration provider
|
|
20
|
+
* Logger with color support
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
|
|
25
|
+
from aiobp import runner
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
try:
|
|
29
|
+
await asyncio.sleep(60)
|
|
30
|
+
except asyncio.CancelledError:
|
|
31
|
+
print('Saving data...')
|
|
32
|
+
|
|
33
|
+
runner(main())
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
More complex example
|
|
38
|
+
--------------------
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
import aiohttp # just for example
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
from aiobp import create_task, on_shutdown, runner
|
|
46
|
+
from aiobp.config import InvalidConfigFile, sys_argv_or_filenames
|
|
47
|
+
from aiobp.config.conf import loader
|
|
48
|
+
from aiobp.logging import LoggingConfig, add_devel_log_level, log, setup_logging
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WorkerConfig:
|
|
52
|
+
"""Your microservice worker configuration"""
|
|
53
|
+
|
|
54
|
+
sleep: int = 5
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Config:
|
|
58
|
+
"""Put configurations together"""
|
|
59
|
+
|
|
60
|
+
worker: WorkerConfig
|
|
61
|
+
log: LoggingConfig
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def worker(config: WorkerConfig, client_session: aiohttp.ClientSession) -> int:
|
|
65
|
+
"""Perform service work"""
|
|
66
|
+
attempts = 0
|
|
67
|
+
try:
|
|
68
|
+
async with client_session.get('http://python.org') as resp:
|
|
69
|
+
assert resp.status == 200
|
|
70
|
+
log.debug('Page length %d', len(await resp.text()))
|
|
71
|
+
attempts += 1
|
|
72
|
+
await asyncio.sleep(config.sleep)
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
log.info('Doing some shutdown work')
|
|
75
|
+
await client_session.post('http://localhost/service/attempts', data={'attempts': attempts})
|
|
76
|
+
|
|
77
|
+
return attempts
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def service(config: Config):
|
|
81
|
+
"""Your microservice"""
|
|
82
|
+
client_session = aiohttp.ClientSession()
|
|
83
|
+
on_shutdown(client_session.close, after_tasks_cancel=True)
|
|
84
|
+
|
|
85
|
+
create_task(worker(config.worker, client_session), 'PythonFetcher')
|
|
86
|
+
|
|
87
|
+
# you can do some monitoring, statistics collection, etc.
|
|
88
|
+
# or just let the method finish and the runner will wait for Ctrl+C or kill
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
"""Example microservice"""
|
|
93
|
+
add_devel_log_level()
|
|
94
|
+
try:
|
|
95
|
+
config_filename = sys_argv_or_filenames('service.local.conf', 'service.conf')
|
|
96
|
+
config = loader(config_filename, Config)
|
|
97
|
+
except InvalidConfigFile as error:
|
|
98
|
+
print(f'Invalid configuration: {error}')
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
setup_logging(config.log)
|
|
102
|
+
log.info("Using config file: %s", config_filename)
|
|
103
|
+
|
|
104
|
+
runner(service(config))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == '__main__':
|
|
108
|
+
main()
|
|
109
|
+
```
|
|
110
|
+
|
aiobp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Boilerplate for asyncio service
|
|
2
|
+
===============================
|
|
3
|
+
|
|
4
|
+
This module provides boilerplate for microservices written in asyncio:
|
|
5
|
+
|
|
6
|
+
* Runner with task reference handler and graceful shutdown
|
|
7
|
+
* Configuration provider
|
|
8
|
+
* Logger with color support
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from aiobp import runner
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
try:
|
|
17
|
+
await asyncio.sleep(60)
|
|
18
|
+
except asyncio.CancelledError:
|
|
19
|
+
print('Saving data...')
|
|
20
|
+
|
|
21
|
+
runner(main())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
More complex example
|
|
26
|
+
--------------------
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
import aiohttp # just for example
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
from aiobp import create_task, on_shutdown, runner
|
|
34
|
+
from aiobp.config import InvalidConfigFile, sys_argv_or_filenames
|
|
35
|
+
from aiobp.config.conf import loader
|
|
36
|
+
from aiobp.logging import LoggingConfig, add_devel_log_level, log, setup_logging
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WorkerConfig:
|
|
40
|
+
"""Your microservice worker configuration"""
|
|
41
|
+
|
|
42
|
+
sleep: int = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Config:
|
|
46
|
+
"""Put configurations together"""
|
|
47
|
+
|
|
48
|
+
worker: WorkerConfig
|
|
49
|
+
log: LoggingConfig
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def worker(config: WorkerConfig, client_session: aiohttp.ClientSession) -> int:
|
|
53
|
+
"""Perform service work"""
|
|
54
|
+
attempts = 0
|
|
55
|
+
try:
|
|
56
|
+
async with client_session.get('http://python.org') as resp:
|
|
57
|
+
assert resp.status == 200
|
|
58
|
+
log.debug('Page length %d', len(await resp.text()))
|
|
59
|
+
attempts += 1
|
|
60
|
+
await asyncio.sleep(config.sleep)
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
log.info('Doing some shutdown work')
|
|
63
|
+
await client_session.post('http://localhost/service/attempts', data={'attempts': attempts})
|
|
64
|
+
|
|
65
|
+
return attempts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def service(config: Config):
|
|
69
|
+
"""Your microservice"""
|
|
70
|
+
client_session = aiohttp.ClientSession()
|
|
71
|
+
on_shutdown(client_session.close, after_tasks_cancel=True)
|
|
72
|
+
|
|
73
|
+
create_task(worker(config.worker, client_session), 'PythonFetcher')
|
|
74
|
+
|
|
75
|
+
# you can do some monitoring, statistics collection, etc.
|
|
76
|
+
# or just let the method finish and the runner will wait for Ctrl+C or kill
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
"""Example microservice"""
|
|
81
|
+
add_devel_log_level()
|
|
82
|
+
try:
|
|
83
|
+
config_filename = sys_argv_or_filenames('service.local.conf', 'service.conf')
|
|
84
|
+
config = loader(config_filename, Config)
|
|
85
|
+
except InvalidConfigFile as error:
|
|
86
|
+
print(f'Invalid configuration: {error}')
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
setup_logging(config.log)
|
|
90
|
+
log.info("Using config file: %s", config_filename)
|
|
91
|
+
|
|
92
|
+
runner(service(config))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == '__main__':
|
|
96
|
+
main()
|
|
97
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Service configuration"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .exceptions import InvalidConfigFile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sys_argv_or_filenames(*filenames: str) -> str:
|
|
10
|
+
"""Return usable configuration filename"""
|
|
11
|
+
if len(sys.argv) == 2:
|
|
12
|
+
filename = sys.argv[1]
|
|
13
|
+
if not os.path.isfile(filename):
|
|
14
|
+
raise InvalidConfigFile(f'Provided filename "{filename}" not found')
|
|
15
|
+
|
|
16
|
+
return filename
|
|
17
|
+
|
|
18
|
+
for filename in filenames:
|
|
19
|
+
if not os.path.isfile(filename):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
return filename
|
|
23
|
+
|
|
24
|
+
raise InvalidConfigFile(f'None of default filenames found: {", ".join(filenames)}')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["InvalidConfigFile", "sys_argv_or_filenames"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Configuration structure from class annotations
|
|
2
|
+
|
|
3
|
+
Given config:
|
|
4
|
+
|
|
5
|
+
class ServiceConfig:
|
|
6
|
+
host: str
|
|
7
|
+
port: int = 8080
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogConfig:
|
|
11
|
+
level: str = "DEBUG"
|
|
12
|
+
filename: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
service: ServiceConfig
|
|
17
|
+
log: LogConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Method get_options inspects classes recursively and return configuration map as
|
|
21
|
+
nested dicts of options/attributes (keys) having type and default value (vals):
|
|
22
|
+
|
|
23
|
+
config_map = get_options(Config)
|
|
24
|
+
{
|
|
25
|
+
# module name
|
|
26
|
+
"service": {
|
|
27
|
+
# option name: type, default value or AttributeError if default is not provided
|
|
28
|
+
"host": (str, AttributeError),
|
|
29
|
+
"port": (int, 8080),
|
|
30
|
+
},
|
|
31
|
+
"log": {
|
|
32
|
+
"level": (str, "DEBUG"),
|
|
33
|
+
"filename": (str, AttributeError),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Loader class constructor will use configuration map, fill values from configuration
|
|
39
|
+
file (override default values) and use set_options method to make instances of nested
|
|
40
|
+
configuration classes:
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Loader:
|
|
44
|
+
def __init__(self, filename):
|
|
45
|
+
config_map = get_options(Config)
|
|
46
|
+
|
|
47
|
+
# parse filename and fill values
|
|
48
|
+
|
|
49
|
+
set_options(self, config_map)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Finally what we need to use in our microservice:
|
|
53
|
+
|
|
54
|
+
config = Config(Loader('some.file'))
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from collections import namedtuple
|
|
58
|
+
from typing import Annotated, Optional, Type, Union, get_type_hints
|
|
59
|
+
|
|
60
|
+
from .exceptions import InvalidConfigFile, InvalidConfigImplementation
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
ConfigOption = namedtuple('Option', ['type', 'value'])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ConfigMap = dict[str, Union['ConfigMap', ConfigOption]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
CONFIG_TYPES = (
|
|
70
|
+
bool,
|
|
71
|
+
float,
|
|
72
|
+
int,
|
|
73
|
+
str,
|
|
74
|
+
Optional[bool],
|
|
75
|
+
Optional[float],
|
|
76
|
+
Optional[int],
|
|
77
|
+
Optional[str],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_options(config_class: Type[Annotated]) -> ConfigMap:
|
|
82
|
+
"""Extract configuration map recursively"""
|
|
83
|
+
config: ConfigMap = {}
|
|
84
|
+
|
|
85
|
+
options = get_type_hints(config_class)
|
|
86
|
+
if not options:
|
|
87
|
+
raise InvalidConfigImplementation(f'Configuration class "{config_class}" has no configuration options')
|
|
88
|
+
|
|
89
|
+
for option_name, option_type in options.items():
|
|
90
|
+
if option_type not in CONFIG_TYPES:
|
|
91
|
+
config[option_name] = get_options(option_type)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
default_value = getattr(config_class, option_name)
|
|
96
|
+
except AttributeError as error:
|
|
97
|
+
default_value = error
|
|
98
|
+
config[option_name] = ConfigOption(option_type, default_value)
|
|
99
|
+
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_options(config: Annotated, values: ConfigMap, path: Optional[list[str]] = None) -> Annotated:
|
|
104
|
+
"""Make instances of configuration classes and set values"""
|
|
105
|
+
for option_name, option_type in get_type_hints(config).items():
|
|
106
|
+
if option_type not in CONFIG_TYPES:
|
|
107
|
+
path = [option_name] if path is None else [*path, option_name]
|
|
108
|
+
value = set_options(option_type(), values.get(option_name), path)
|
|
109
|
+
else:
|
|
110
|
+
value = values.get(option_name)
|
|
111
|
+
if isinstance(value, Exception):
|
|
112
|
+
raise InvalidConfigFile(f'missing option "{option_name}" in section "{".".join(path)}"') from value
|
|
113
|
+
|
|
114
|
+
setattr(config, option_name, value)
|
|
115
|
+
|
|
116
|
+
return config
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""INI like configuration loader"""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Type
|
|
6
|
+
|
|
7
|
+
from .annotations import ConfigOption, get_options, set_options
|
|
8
|
+
from .exceptions import InvalidConfigImplementation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def loader(filename: str, config_class: Type[Annotated]) -> Annotated:
|
|
12
|
+
"""INI like configuration loader"""
|
|
13
|
+
conf = configparser.ConfigParser()
|
|
14
|
+
conf.read(filename)
|
|
15
|
+
|
|
16
|
+
config = get_options(config_class)
|
|
17
|
+
|
|
18
|
+
for section_name, options in config.items():
|
|
19
|
+
if not isinstance(options, dict):
|
|
20
|
+
raise InvalidConfigImplementation(f'Class "{config_class.__name__}" can\'t have direct option "{section_name}"')
|
|
21
|
+
|
|
22
|
+
for option_name, option in options.items():
|
|
23
|
+
if not isinstance(option, ConfigOption):
|
|
24
|
+
raise InvalidConfigImplementation(f'"{section_name}" can have only scalar attributes, not subsection "{option_name}"')
|
|
25
|
+
|
|
26
|
+
if isinstance(option.type, int):
|
|
27
|
+
get = conf.getint
|
|
28
|
+
elif isinstance(option.type, float):
|
|
29
|
+
get = conf.getfloat
|
|
30
|
+
elif isinstance(option.type, bool):
|
|
31
|
+
get = conf.getboolean
|
|
32
|
+
else:
|
|
33
|
+
get = conf.get
|
|
34
|
+
|
|
35
|
+
options[option_name] = get(section_name, option_name, fallback=option.value)
|
|
36
|
+
|
|
37
|
+
return set_options(config_class(), config)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Configration exceptions"""
|
|
2
|
+
|
|
3
|
+
class InvalidConfigFile(BaseException):
|
|
4
|
+
"""Invalid configuration file"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidConfigImplementation(BaseException):
|
|
8
|
+
"""Invalid usage of annotations for configuration"""
|
|
9
|
+
# you shouldn't catch this exception
|
|
10
|
+
# it indicates invalid code, not invalid configuration file itself
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Load configuration from JSON file"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Type
|
|
6
|
+
|
|
7
|
+
from .exceptions import InvalidConfigFile
|
|
8
|
+
from .annotations import get_options, set_options
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def loader(filename: str, config_class: Type[Annotated]) -> Annotated:
|
|
12
|
+
"""Load configuration from JSON file"""
|
|
13
|
+
with open(filename, 'r', encoding='ascii') as fp:
|
|
14
|
+
conf = json.load(fp)
|
|
15
|
+
|
|
16
|
+
if not isinstance(conf, dict):
|
|
17
|
+
# TODO: add unlimited depth support
|
|
18
|
+
raise InvalidConfigFile('JSON configuration structure must be dict[str, dict[str, any]]')
|
|
19
|
+
|
|
20
|
+
config = get_options(config_class)
|
|
21
|
+
|
|
22
|
+
for module_name, options in config.items():
|
|
23
|
+
module_conf = config.get(module_name, {})
|
|
24
|
+
for option_name, (option_type, default_value) in options.items():
|
|
25
|
+
try:
|
|
26
|
+
value = option_type(module_conf[option_name])
|
|
27
|
+
except KeyError:
|
|
28
|
+
value = default_value
|
|
29
|
+
except Exception as error:
|
|
30
|
+
value = error
|
|
31
|
+
|
|
32
|
+
options[option_name] = value
|
|
33
|
+
|
|
34
|
+
config_instance = config_class()
|
|
35
|
+
set_options(config_instance, config)
|
|
36
|
+
return config_instance
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""INI like configuration loader"""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Type
|
|
6
|
+
|
|
7
|
+
from ..annotations import ConfigOption, get_options, set_options
|
|
8
|
+
from ..exceptions import InvalidConfigImplementation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def loader(filename: str, config_class: Type[Annotated]) -> Annotated:
|
|
12
|
+
"""INI like configuration loader"""
|
|
13
|
+
conf = configparser.ConfigParser()
|
|
14
|
+
conf.read(filename)
|
|
15
|
+
|
|
16
|
+
config = get_options(config_class)
|
|
17
|
+
|
|
18
|
+
for section_name, options in config.items():
|
|
19
|
+
if not isinstance(options, dict):
|
|
20
|
+
raise InvalidConfigImplementation(f'Class "{config_class.__name__}" can\'t have direct option "{section_name}"')
|
|
21
|
+
|
|
22
|
+
for option_name, option in options.items():
|
|
23
|
+
if not isinstance(option, ConfigOption):
|
|
24
|
+
raise InvalidConfigImplementation(f'"{section_name}" can have only scalar attributes, not subsection "{option_name}"')
|
|
25
|
+
|
|
26
|
+
if isinstance(option.type, int):
|
|
27
|
+
get = conf.getint
|
|
28
|
+
elif isinstance(option.type, float):
|
|
29
|
+
get = conf.getfloat
|
|
30
|
+
elif isinstance(option.type, bool):
|
|
31
|
+
get = conf.getboolean
|
|
32
|
+
else:
|
|
33
|
+
get = conf.get
|
|
34
|
+
|
|
35
|
+
options[option_name] = get(section_name, option_name, fallback=option.value)
|
|
36
|
+
|
|
37
|
+
config_instance = config_class()
|
|
38
|
+
set_options(config_instance, config)
|
|
39
|
+
return config_instance
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Load configuration from JSON file"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Type
|
|
6
|
+
|
|
7
|
+
from ..exceptions import InvalidConfigFile
|
|
8
|
+
from ..annotations import get_options, set_options
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def loader(filename: str, config_class: Type[Annotated]) -> Annotated:
|
|
12
|
+
"""Load configuration from JSON file"""
|
|
13
|
+
with open(filename, 'r', encoding='ascii') as fp:
|
|
14
|
+
conf = json.load(fp)
|
|
15
|
+
|
|
16
|
+
if not isinstance(conf, dict):
|
|
17
|
+
# TODO: add unlimited depth support
|
|
18
|
+
raise InvalidConfigFile('JSON configuration structure must be dict[str, dict[str, any]]')
|
|
19
|
+
|
|
20
|
+
config = get_options(config_class)
|
|
21
|
+
|
|
22
|
+
for module_name, options in config.items():
|
|
23
|
+
module_conf = config.get(module_name, {})
|
|
24
|
+
for option_name, (option_type, default_value) in options.items():
|
|
25
|
+
try:
|
|
26
|
+
value = option_type(module_conf[option_name])
|
|
27
|
+
except KeyError:
|
|
28
|
+
value = default_value
|
|
29
|
+
except Exception as error:
|
|
30
|
+
value = error
|
|
31
|
+
|
|
32
|
+
options[option_name] = value
|
|
33
|
+
|
|
34
|
+
config_instance = config_class()
|
|
35
|
+
set_options(config_instance, config)
|
|
36
|
+
return config_instance
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Customize Python logger"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoggingConfig:
|
|
11
|
+
"""Loging configuration"""
|
|
12
|
+
|
|
13
|
+
level: str = 'DEBUG'
|
|
14
|
+
filename: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Color:
|
|
18
|
+
"""Console colors"""
|
|
19
|
+
|
|
20
|
+
black = "\x1b[0;30m"
|
|
21
|
+
brown = "\x1b[0;31m"
|
|
22
|
+
dark_green = "\x1b[0;32m"
|
|
23
|
+
orange = "\x1b[0;33m"
|
|
24
|
+
dark_blue = "\x1b[0;34m"
|
|
25
|
+
purple = "\x1b[0;35m"
|
|
26
|
+
teal = "\x1b[0;36m"
|
|
27
|
+
grey = "\x1b[0;37m"
|
|
28
|
+
red = "\x1b[0;91m"
|
|
29
|
+
green = "\x1b[0;92m"
|
|
30
|
+
yellow = "\x1b[0;93m"
|
|
31
|
+
blue = "\x1b[0;94m"
|
|
32
|
+
magenta = "\x1b[0;95m"
|
|
33
|
+
cyan = "\x1b[0;96m"
|
|
34
|
+
white = "\x1b[0;97m"
|
|
35
|
+
reset = "\x1b[0m"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PrefixExceptionFormatter(logging.Formatter):
|
|
39
|
+
"""Add date in front of each line of multiline exception/stack trace"""
|
|
40
|
+
|
|
41
|
+
def _prefix_text(self, prefix: str, stack: str, color: Color = Color.brown) -> str:
|
|
42
|
+
"""Prefix lines with date so we can easily grep by date and time"""
|
|
43
|
+
return "\n".join([f"{prefix} {color}{line}{Color.reset}" for line in stack.split("\n")][1:])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ColorFormatter(PrefixExceptionFormatter):
|
|
47
|
+
"""Color formatter for console logging"""
|
|
48
|
+
|
|
49
|
+
severity_color = {
|
|
50
|
+
logging.DEBUG: Color.grey,
|
|
51
|
+
logging.INFO: Color.white,
|
|
52
|
+
logging.WARNING: Color.orange,
|
|
53
|
+
logging.ERROR: Color.red,
|
|
54
|
+
logging.CRITICAL: Color.magenta,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
58
|
+
"""Format message according to its severity level"""
|
|
59
|
+
color = ColorFormatter.severity_color.get(record.levelno, Color.blue)
|
|
60
|
+
record.message = f"{color}{record.getMessage()}{Color.reset}"
|
|
61
|
+
if self.usesTime():
|
|
62
|
+
record.asctime = self.formatTime(record, self.datefmt)
|
|
63
|
+
s = self.formatMessage(record)
|
|
64
|
+
if record.exc_info:
|
|
65
|
+
# Cache the traceback text to avoid converting it multiple times
|
|
66
|
+
# (it's constant anyway)
|
|
67
|
+
if not record.exc_text:
|
|
68
|
+
record.exc_text = self.formatException(record.exc_info)
|
|
69
|
+
if record.exc_text:
|
|
70
|
+
if s[-1:] != "\n":
|
|
71
|
+
s = s + "\n"
|
|
72
|
+
s = s + self._prefix_text(record.asctime, record.exc_text)
|
|
73
|
+
if record.stack_info:
|
|
74
|
+
if s[-1:] != "\n":
|
|
75
|
+
s = s + "\n"
|
|
76
|
+
s = s + self._prefix_text(record.asctime, self.formatStack(record.stack_info))
|
|
77
|
+
return s
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def add_devel_log_level():
|
|
81
|
+
"""Register new logging level for development purposes"""
|
|
82
|
+
logging.addLevelName(1, "DEVEL")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def setup_logging(config: Optional[LoggingConfig] = LoggingConfig()) -> None:
|
|
86
|
+
"""Setup Python logger"""
|
|
87
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
88
|
+
console_handler.setFormatter(
|
|
89
|
+
ColorFormatter(f"%(asctime)s [{Color.green}%(filename)s:%(lineno)d{Color.reset}] %(message)s"),
|
|
90
|
+
)
|
|
91
|
+
handlers = [console_handler]
|
|
92
|
+
|
|
93
|
+
if config.filename is not None:
|
|
94
|
+
file_handler = logging.handlers.WatchedFileHandler(filename=config.filename)
|
|
95
|
+
file_handler.setFormatter(
|
|
96
|
+
PrefixExceptionFormatter("%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"),
|
|
97
|
+
)
|
|
98
|
+
handlers.append(file_handler)
|
|
99
|
+
|
|
100
|
+
logging.basicConfig(level=config.level, handlers=handlers)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Add dev and trace logging shortcuts"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
dev = functools.partial(logging.log, 1)
|
|
8
|
+
debug = logging.debug
|
|
9
|
+
info = logging.info
|
|
10
|
+
warning = logging.warning
|
|
11
|
+
error = logging.error
|
|
12
|
+
critical = logging.critical
|
|
13
|
+
trace = functools.partial(logging.log, logging.ERROR, exc_info=True)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Run asyncio coroutine as endless service and handle graceful shutdown"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from typing import Coroutine, Any, Optional, Callable, Union
|
|
9
|
+
|
|
10
|
+
from .logging import log
|
|
11
|
+
from .task import create_task
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__on_shutdown: list[tuple[Callable[..., Coroutine], list[Any], bool]] = [] # to gracefully close connections
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def on_shutdown(
|
|
18
|
+
# instead of ..., there should be generic TypeVar reflected to args
|
|
19
|
+
# however python type system doesn't seem to be capable of that yet
|
|
20
|
+
coroutine: Callable[..., Coroutine],
|
|
21
|
+
args: Optional[list[Any]] = None,
|
|
22
|
+
after_tasks_cancel: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Register coroutine to be called on graceful shutdown
|
|
25
|
+
|
|
26
|
+
Coroutines are called in LIFO order. By default, coroutines are called
|
|
27
|
+
BEFORE all tasks are canceled and awaited for graceful completion.
|
|
28
|
+
|
|
29
|
+
If after_tasks_cancel is set to True then the coroutine is called
|
|
30
|
+
AFTER canceling all tasks and waiting for their possible timeout.
|
|
31
|
+
Useful for ClientSession.close() for example.
|
|
32
|
+
"""
|
|
33
|
+
if args is None:
|
|
34
|
+
args = []
|
|
35
|
+
__on_shutdown.append((coroutine, args, after_tasks_cancel))
|
|
36
|
+
log_args = ",".join(repr(arg) for arg in args)
|
|
37
|
+
log_when = "after" if after_tasks_cancel else "before"
|
|
38
|
+
log.debug("Will call %s(%s) on shutdown %s tasks cancelation", coroutine, log_args, log_when)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_awaitable(awaitable: Union[asyncio.Task, Coroutine]) -> str:
|
|
42
|
+
"""Make good task description for log"""
|
|
43
|
+
coroutine = awaitable.get_coro() if isinstance(awaitable, asyncio.Task) else awaitable
|
|
44
|
+
if not asyncio.iscoroutine(coroutine):
|
|
45
|
+
return repr(coroutine)
|
|
46
|
+
|
|
47
|
+
code = coroutine.cr_code
|
|
48
|
+
|
|
49
|
+
cwd = os.getcwd()
|
|
50
|
+
filename = code.co_filename
|
|
51
|
+
if filename.startswith(cwd):
|
|
52
|
+
filename = filename[len(cwd) + 1 :]
|
|
53
|
+
|
|
54
|
+
if awaitable == coroutine: # it was coroutine, not a task
|
|
55
|
+
return f'<{filename}:{code.co_firstlineno} method "{code.co_name}">'
|
|
56
|
+
|
|
57
|
+
return f'<{awaitable.get_name()} {filename}:{code.co_firstlineno} metod "{code.co_name}">'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Needed up to Python 3.10, when we upgrade to Python 3.11 we can use builtin asyncio.run()
|
|
61
|
+
def runner(service: Coroutine, shutdown_timeout: float = 5.0) -> None:
|
|
62
|
+
"""Run given service in asyncio.Task and handle SIGTERM/KeyboardInterrupt"""
|
|
63
|
+
loop = asyncio.get_event_loop()
|
|
64
|
+
# main does:
|
|
65
|
+
# 1. start given service coroutine as task
|
|
66
|
+
# 2. add SIGTERM and SIGINT handlers
|
|
67
|
+
# 3. waits for kill or KeyboardInterrupt
|
|
68
|
+
loop.run_until_complete(__main(service))
|
|
69
|
+
# graceful_shutdown does:
|
|
70
|
+
# 1. calls one by one all registered coroutines via on_shutdown(...) in LIFO order
|
|
71
|
+
# 2. cancel all tasks in parallel (via gather)
|
|
72
|
+
# 3. await tasks to finish in specified shutdown_timeout
|
|
73
|
+
# 4. calls all registered coroutines via on_shutdown(..., after_tasks_cancel=True) in LIFO order
|
|
74
|
+
loop.run_until_complete(__graceful_shutdown(shutdown_timeout))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def __main(service: Coroutine) -> None:
|
|
78
|
+
"""Run main service in task and await for SIGTERM"""
|
|
79
|
+
create_task(service, "MainTask")
|
|
80
|
+
|
|
81
|
+
shutdown_event = asyncio.Event()
|
|
82
|
+
loop = asyncio.get_event_loop()
|
|
83
|
+
loop.add_signal_handler(signal.SIGTERM, shutdown_event.set)
|
|
84
|
+
loop.add_signal_handler(signal.SIGINT, shutdown_event.set)
|
|
85
|
+
# endless wait for shutdown
|
|
86
|
+
try:
|
|
87
|
+
await shutdown_event.wait()
|
|
88
|
+
except asyncio.CancelledError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def __graceful_shutdown(timeout: float = 5.0) -> None:
|
|
93
|
+
"""Gracefully shutdown coroutines and tasks"""
|
|
94
|
+
log.info(f"Graceful shutdown up to {timeout:.3f} s...")
|
|
95
|
+
remains = timeout
|
|
96
|
+
remains = await __shutdown_coroutines(timeout=remains, after_tasks_cancel=False)
|
|
97
|
+
remains = await __shutdown_tasks(timeout=remains)
|
|
98
|
+
remains = await __shutdown_coroutines(timeout=remains, after_tasks_cancel=True)
|
|
99
|
+
if remains > 0:
|
|
100
|
+
log.info("Shutdown completed in %.3f s", (timeout - remains))
|
|
101
|
+
else:
|
|
102
|
+
log.warning("Shutdown not completed within timeout!")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def __shutdown_coroutines(timeout: float, after_tasks_cancel: bool) -> float:
|
|
106
|
+
"""Await for on_shutdown callbacks finish with timeout"""
|
|
107
|
+
log.debug(f"Shutting down coroutines {'after' if after_tasks_cancel else 'before'} tasks cancel...")
|
|
108
|
+
|
|
109
|
+
for coro, args, when in __on_shutdown[::-1]:
|
|
110
|
+
if when != after_tasks_cancel:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
start = time.time()
|
|
114
|
+
log_args = ",".join(repr(arg) for arg in args)
|
|
115
|
+
try:
|
|
116
|
+
coroutine = coro(*args)
|
|
117
|
+
except Exception:
|
|
118
|
+
log.trace('Exception in %s(%s)', coro.__name__, log_args)
|
|
119
|
+
took = time.time() - start
|
|
120
|
+
timeout -= took
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if not asyncio.iscoroutine(coroutine): # we may get just plain synchronous method
|
|
124
|
+
took = time.time() - start
|
|
125
|
+
timeout -= took
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
result = await asyncio.wait_for(coroutine, timeout=timeout)
|
|
130
|
+
log.debug("Coroutine finished: %s(%s) -> %r", log_awaitable(coro), log_args, result)
|
|
131
|
+
except asyncio.TimeoutError:
|
|
132
|
+
log.error("Coroutine did not finish in timeout: %s(%s)", log_awaitable(coro), log_args)
|
|
133
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
134
|
+
log.error("Coroutine shutdown failed %s(%s): %r", log_awaitable(coro), log_args, error)
|
|
135
|
+
took = time.time() - start
|
|
136
|
+
timeout -= took
|
|
137
|
+
|
|
138
|
+
return timeout
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def __shutdown_tasks(timeout: float) -> float:
|
|
142
|
+
"""Gather tasks and await for their finish with timeout"""
|
|
143
|
+
log.debug('Shutting down tasks...')
|
|
144
|
+
|
|
145
|
+
start = time.time()
|
|
146
|
+
|
|
147
|
+
tasks = asyncio.all_tasks()
|
|
148
|
+
tasks.discard(asyncio.current_task()) # don't touch ourselves
|
|
149
|
+
for task in tasks:
|
|
150
|
+
task.cancel()
|
|
151
|
+
# We must await.sleep(0) here so task.cancel() is actually processed
|
|
152
|
+
# and await in the task's coroutine is interrupted.
|
|
153
|
+
await asyncio.sleep(0)
|
|
154
|
+
|
|
155
|
+
for task in tasks.copy():
|
|
156
|
+
if __task_failed(task, "%s await must be in try/except!") or task.done():
|
|
157
|
+
# don't wait for failed task because they never gather
|
|
158
|
+
tasks.remove(task)
|
|
159
|
+
|
|
160
|
+
if not tasks:
|
|
161
|
+
log.debug("No tasks to gracefully shutdown")
|
|
162
|
+
took = time.time() - start
|
|
163
|
+
return timeout - took
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
log.debug(f"Waiting up to {timeout:.3f} s for tasks to finish...")
|
|
167
|
+
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=timeout)
|
|
168
|
+
log.debug("All tasks finished within timeout")
|
|
169
|
+
except asyncio.TimeoutError:
|
|
170
|
+
log.warning('Nope, they get interrupted')
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
for task in tasks:
|
|
174
|
+
if not task.done():
|
|
175
|
+
# Task didn't finish before timeout. Is it a bug in task code executed after cancel
|
|
176
|
+
# or is the timeout too short to finish all needed work by task?
|
|
177
|
+
log.error("Task did not finish gracefully: %s", log_awaitable(task))
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
__task_failed(task, "%s didn't finish in shutdown timeout")
|
|
181
|
+
|
|
182
|
+
took = time.time() - start
|
|
183
|
+
return timeout - took
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def __prepare_coroutines(after_tasks_cancel: bool) -> set[asyncio.Task]:
|
|
187
|
+
"""Prepare coroutines for shutdown"""
|
|
188
|
+
tasks = set()
|
|
189
|
+
for coro, args, when in __on_shutdown[::-1]:
|
|
190
|
+
if when != after_tasks_cancel:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
coroutine = coro(*args)
|
|
195
|
+
except Exception:
|
|
196
|
+
log_args = ",".join(repr(arg) for arg in args)
|
|
197
|
+
log.trace('Exception in %s(%s)', coro.__name__, log_args)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if not asyncio.iscoroutine(coroutine): # we may get just plain synchronous method
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
tasks.add(create_task(coroutine, name="OnShutdown"))
|
|
205
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
206
|
+
log_args = ",".join(repr(arg) for arg in args)
|
|
207
|
+
log.error("Unable to prepare for graceful shutdown %s(%s): %r", coro, log_args, error)
|
|
208
|
+
|
|
209
|
+
return tasks
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def __task_failed(task: asyncio.Task, canceled_msg: str) -> bool:
|
|
213
|
+
"""Log task result/exception and return if exception happened in its coroutine"""
|
|
214
|
+
if not task.done():
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
result = task.result()
|
|
219
|
+
log.debug("Task %s finished with result: %r", log_awaitable(task), result)
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
except asyncio.CancelledError:
|
|
223
|
+
log.trace(canceled_msg, log_awaitable(task))
|
|
224
|
+
|
|
225
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
226
|
+
log.trace("%s in task %s: %s", error.__class__.__name__, log_awaitable(task), error)
|
|
227
|
+
|
|
228
|
+
return True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Keep reference to running asyncio tasks"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from typing import Coroutine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__tasks: set[asyncio.Task] = set() # to avoid garbage collection by holding reference
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_task(coroutine: Coroutine, name: str) -> asyncio.Task:
|
|
12
|
+
"""Creates task and keeps reference until the task is done
|
|
13
|
+
|
|
14
|
+
Argument "name" is optional in asyncio.task(), however we require it
|
|
15
|
+
to make our code easier to debug.
|
|
16
|
+
"""
|
|
17
|
+
task = asyncio.create_task(coroutine, name=name)
|
|
18
|
+
__tasks.add(task)
|
|
19
|
+
task.add_done_callback(__tasks.discard)
|
|
20
|
+
return task
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "aiobp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Boilerplate for asyncio service"
|
|
5
|
+
authors = ["INSOFT s.r.o."]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{ include = "aiobp" }]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.9"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.group.dev.dependencies]
|
|
13
|
+
mypy = "^1.8.0"
|
|
14
|
+
pylint = "^3.0.3"
|
|
15
|
+
ruff = "^0.1.13"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|