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 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,7 @@
1
+ __version__ = '0.1.0'
2
+
3
+ from .logging import log
4
+ from .runner import runner, on_shutdown
5
+ from .task import create_task
6
+
7
+ __all__ = ["create_task", "log", "on_shutdown", "runner"]
@@ -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"]
@@ -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
@@ -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
@@ -0,0 +1,4 @@
1
+ from . import log
2
+ from .custom import LoggingConfig, add_devel_log_level, setup_logging
3
+
4
+ __all__ = ["LoggingConfig", "add_devel_log_level", "log", "setup_logging"]
@@ -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"