Skip to content

Commit

Permalink
add blocking service mixin
Browse files Browse the repository at this point in the history
  • Loading branch information
pohmelie committed Dec 30, 2023
1 parent 8605c02 commit ada53dc
Show file tree
Hide file tree
Showing 9 changed files with 536 additions and 110 deletions.
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,8 @@ repos:
name: Check with ruff
entry: ruff
args: ["check", "--fix", "."]

- <<: *python-linters
id: mypy
name: Validate types with MyPy
entry: mypy
6 changes: 6 additions & 0 deletions history.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# x.x.x (xx-xx-xxxx)

# 0.10.0 (xx-xx-xxxx)
- add `BlockingServiceMixin` with subset of functionality from `ServiceMixin` that can be used in non-async code
- add `AsyncioServiceMixin` as an alias to `ServiceMixin`
- `ServiceMixin` is now deprecated and will be removed in 1.0.0
- add mypy type hints

# 0.9.1 (11-01-2022)
- prevent multiple calls to `set_exception`

Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dev = [
"pre-commit",
"black",
"ruff",
"mypy",
]

[build-system]
Expand Down Expand Up @@ -67,3 +68,17 @@ log_format = "%(asctime)s.%(msecs)03d %(name)-20s %(levelname)-8s %(filename)-15
log_date_format = "%H:%M:%S"
log_level = "DEBUG"
asyncio_mode = "strict"

[tool.mypy]
files = "src/facet"
strict = true
ignore_missing_imports = true
allow_subclassing_any = true
allow_untyped_calls = true
pretty = true
show_error_codes = true
implicit_reexport = true
allow_untyped_decorators = true
warn_unused_ignores = false
warn_return_any = false
namespace_packages = true
229 changes: 164 additions & 65 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,80 @@
# Facet
Service manager for asyncio (and classic blocking code since version 0.10.0).

[![Github actions status for master branch](https://github.com/pohmelie/facet/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/pohmelie/facet/actions/workflows/ci.yaml)
[![Codecov coverage for master branch](https://codecov.io/gh/pohmelie/facet/branch/master/graph/badge.svg)](https://codecov.io/gh/pohmelie/facet)
[![Pypi version](https://img.shields.io/pypi/v/facet.svg)](https://pypi.org/project/facet/)
[![Pypi downloads count](https://img.shields.io/pypi/dm/facet)](https://pypi.org/project/facet/)

Service manager for asyncio.

# Reason
- [Facet](#facet)
- [Reasons](#reasons)
- [Asyncio](#asyncio)
- [Blocking code](#blocking-code)
- [Features](#features)
- [License](#license)
- [Requirements](#requirements)
- [Usage](#usage)
- [Asyncio](#asyncio-1)
- [Blocking code](#blocking-code-1)
- [API](#api)
- [Asyncio](#asyncio-2)
- [`start`](#start)
- [`stop`](#stop)
- [`dependencies`](#dependencies)
- [`add_task`](#add_task)
- [`run`](#run)
- [`wait`](#wait)
- [`graceful_shutdown_timeout`](#graceful_shutdown_timeout)
- [`running`](#running)
- [Blocking code](#blocking-code-2)
- [`start`](#start-1)
- [`stop`](#stop-1)
- [`dependencies`](#dependencies-1)
- [`running`](#running-1)

## Reasons
### Asyncio
[`mode`](https://github.com/ask/mode) tries to do too much job:
- Messy callbacks (`on_start`, `on_started`, `on_crashed`, etc.).
- Inheritance restrict naming and forces `super()` calls.
- Forced logging module and logging configuration.
### Blocking code
- [`ExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack) is too low-level to manage services.
- Common api for async and blocking worlds.

# Features
## Features
- Simple (`start`, `stop`, `dependencies` and `add_task`).
- Configurable via inheritance (graceful shutdown timeout).
- Mixin (no `super()` required).
- Requires no runner engine (`Worker`, `Runner`, etc.) just plain `await` or `async with`.
- Requires no runner engine (`Worker`, `Runner`, etc.) just plain `await` or `async with`/`with`.

# License
## License
`facet` is offered under MIT license.

# Requirements
* python 3.6+

# Usage
``` python
import asyncio
import logging
## Requirements
- python 3.11+

from facet import ServiceMixin
## Usage

### Asyncio

class B(ServiceMixin):
``` python
import asyncio
from facet import AsyncioServiceMixin

class B(AsyncioServiceMixin):
def __init__(self):
self.value = 0

async def start(self):
self.value += 1
logging.info("b started")
print("b started")

async def stop(self):
self.value -= 1
logging.info("b stopped")


class A(ServiceMixin):
print("b stopped")

class A(AsyncioServiceMixin):
def __init__(self):
self.b = B()

Expand All @@ -56,26 +83,24 @@ class A(ServiceMixin):
return [self.b]

async def start(self):
logging.info("a started")
print("a started")

async def stop(self):
logging.info("a stopped")
print("a stopped")


logging.basicConfig(level=logging.DEBUG)
asyncio.run(A().run())
```
This will produce:
```
INFO:root:b started
INFO:root:a started
b started
a started
```
Start and stop order determined by strict rule: **dependencies must be started first and stopped last**. That is why `B` starts before `A`. Since `A` may use `B` in `start` routine.

Hit `ctrl-c` and you will see:
```
INFO:root:a stopped
INFO:root:b stopped
a stopped
b stopped
Traceback (most recent call last):
...
KeyboardInterrupt
Expand All @@ -98,33 +123,29 @@ asyncio.run(main())

Another service feature is `add_task` method:
``` python
class A(ServiceMixin):

class A(AsyncioServiceMixin):
async def task(self):
await asyncio.sleep(1)
logging.info("task done")
print("task done")

async def start(self):
self.add_task(self.task())
logging.info("start done")
print("start done")


logging.basicConfig(level=logging.DEBUG)
asyncio.run(A().run())
```
This will lead to background task creation and handling:
```
INFO:root:start done
INFO:root:task done
start done
task done
```
Any non-handled exception on background task will lead the whole service stack crashed. This is also a key feature to fall down fast and loud.

All background tasks will be cancelled and awaited on service stop.

You can manage dependencies start/stop to start sequently, parallel or mixed. Like this:
``` python
class A(ServiceMixin):

class A(AsyncioServiceMixin):
def __init__(self):
self.b = B()
self.c = C()
Expand All @@ -141,65 +162,143 @@ This leads to first `b` and `c` starts parallel, after they successfully started

The rule here is **first nesting level is sequential, second nesting level is parallel**

# API
### Blocking code
Since version 0.10.0 `facet` can be used in blocking code with pretty same rules. **But with limited API**. For example:
``` python
from facet import BlockingServiceMixin

class B(BlockingServiceMixin):
def __init__(self):
self.value = 0

def start(self):
self.value += 1
print("b started")

def stop(self):
self.value -= 1
print("b stopped")

class A(BlockingServiceMixin):
def __init__(self):
self.b = B()

@property
def dependencies(self):
return [self.b]

def start(self):
print("a started")

def stop(self):
print("a stopped")

with A() as a:
assert a.b.value == 1
```
This will produce:
```
b started
a started
a stopped
b stopped
```
As you can see, there is no `wait` method. Waiting and background tasks are on user shoulders and technically can be implemented with `concurrent.futures` module. But `facet` do not provide such functionality, since there are a lot of ways to do it: `threading`/`multiprocessing` and their primitives.

Also, there are no «sequential, parallel and mixed starts/stops for dependencies» feature. So, just put dependencies in `dependencies` property as a plain `list` and they will be started/stopped sequentially.

## API
### Asyncio
Here is public methods you get on inheritance/mixin:
## `wait`

#### `start`
``` python
async def wait(self):
async def start(self):
pass
```
Wait for service stop. Service must be started. This is useful when you use service as a context manager.
Start routine.

## `run`
#### `stop`
``` python
async def run(self):
async def stop(self):
pass
```
Stop routine.

#### `dependencies`
``` python
@property
def dependencies(self) -> list[AsyncioServiceMixin | list[AsyncioServiceMixin]]:
return []
```
Should return iterable of current service dependencies instances.

#### `add_task`
``` python
def add_task(self, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
```
Add background task.

#### `run`
``` python
async def run(self) -> None:
```
Run service and wait until it stop.

## `graceful_shutdown_timeout`
#### `wait`
``` python
async def wait(self) -> None:
```
Wait for service stop. Service must be started. This is useful when you use service as a context manager.

#### `graceful_shutdown_timeout`
``` python
@property
def graceful_shutdown_timeout(self):
def graceful_shutdown_timeout(self) -> int:
return 10
```
How much total time in seconds wait for stop routines. This property can be overriden with subclass:
``` python
class CustomServiceMixin(ServiceMixin):
class CustomServiceMixin(AsyncioServiceMixin):
@property
def graceful_shutdown_timeout(self):
return 60
```

## `dependencies`
``` python
@property
def dependencies(self):
return []
```
Should return iterable of current service dependencies instances.

## `running`
#### `running`
``` python
@property
def running(self) -> bool:
```
Check if service is running

## `add_task`
``` python
def add_task(self, coro) -> asyncio.Task:
```
Add background task.
### Blocking code

## `start`
#### `start`
``` python
async def start(self):
def start(self):
pass
```
Start routine.

## `stop`
#### `stop`
``` python
async def stop(self):
def stop(self):
pass
```
Stop routine.

#### `dependencies`
``` python
@property
def dependencies(self) -> list[BlockingServiceMixin | list[BlockingServiceMixin]]:
return []
```
Should return iterable of current service dependencies instances.

#### `running`
``` python
@property
def running(self) -> bool:
```
Check if service is running
Loading

0 comments on commit ada53dc

Please sign in to comment.