Skip to content

Commit

Permalink
Add service package (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
lijok authored Sep 25, 2023
1 parent dc4299d commit d2d8c5b
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 1 deletion.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ The `log` package contains an externally configurable logging interface which su
### [`secrets`](https://github.com/lijok/python-sdk/blob/main/src/python_sdk/secrets/__init__.py)
The `secrets` package provides a simple interface for reading in secrets stored in secrets stores such as AWS Secrets Manager.

### [`service`](https://github.com/lijok/python-sdk/blob/main/src/python_sdk/service/__init__.py)
The `service` package provides a uvicorn-like service component for running non-webapp services (workers).

### [`testing`](https://github.com/lijok/python-sdk/blob/main/src/python_sdk/testing/__init__.py)
The `testing` package provides a highly opinionated acceptance test suite for enforcing code quality.

Expand Down
3 changes: 3 additions & 0 deletions src/python_sdk/service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from python_sdk.service._app import App as App
from python_sdk.service._service import Service as Service
from python_sdk.service._service_config import Config as Config
9 changes: 9 additions & 0 deletions src/python_sdk/service/_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import typing


class App(typing.Protocol):
async def start(self) -> None:
...

async def stop(self) -> None:
...
111 changes: 111 additions & 0 deletions src/python_sdk/service/_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import asyncio
import datetime
import logging
import signal
import sys
import threading
import types

from python_sdk.service import _service_config

_HANDLED_SIGNALS = (
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
signal.SIGTERM, # Unix signal 15. Sent by `kill <pid>`.
)
if sys.platform == "win32":
_HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break.


class Service:
config: _service_config.Config
should_exit: bool
should_force_exit: bool
started_at: datetime.datetime | None

def __init__(self, config: _service_config.Config) -> None:
self.config = config
self.should_exit = False
self.should_force_exit = False
self.started_at = None

@property
def elapsed_since_started(self) -> datetime.timedelta:
if not self.started_at:
return datetime.timedelta(seconds=0)
return datetime.datetime.now(tz=datetime.timezone.utc) - self.started_at

@property
def _on_tick_callbacks(self) -> asyncio.Future:
return asyncio.gather(
self._set_should_exit_flag_if_arrived_at_configured_run_for(),
)

def run(self) -> None:
asyncio.run(self._run())

def run_in_background(self) -> asyncio.Task[None]:
return asyncio.create_task(self._run())

async def _run(self) -> None:
self._reset()
logging.debug("Starting service.")
await self._startup()
if self.should_exit:
logging.debug("Service stopped before it got started.")
return
await self._main_loop()
await self._shutdown()

logging.debug("Service stopped.")

def _reset(self) -> None:
"""
Resets the service so it may be reused after a previous run finished.
"""
self.should_exit = False
self.should_force_exit = False
self.started_at = None

async def _startup(self) -> None:
self._install_signal_handlers()

async def _shutdown(self) -> None:
return

async def _main_loop(self) -> None:
self.started_at = datetime.datetime.now(tz=datetime.timezone.utc)
task = asyncio.create_task(self.config.app.start())
while not self.should_exit and not task.done():
await asyncio.sleep(self.config.tick_interval.total_seconds())
await self._tick()
if not task.done():
await self.config.app.stop()

async def _tick(self) -> None:
await self._on_tick_callbacks

def _install_signal_handlers(self) -> None:
if threading.current_thread() is not threading.main_thread():
# Signals can only be listened to from the main thread.
return

loop = asyncio.get_event_loop()

try:
for sig in _HANDLED_SIGNALS:
loop.add_signal_handler(sig, self._handle_signal, sig, None)
except NotImplementedError:
# Windows
for sig in _HANDLED_SIGNALS:
signal.signal(sig, self._handle_signal)

def _handle_signal(self, sig: int, frame: types.FrameType | None) -> None:
logging.info(f"Received signal. signal={signal.Signals(sig).name}")
if self.should_exit and sig == signal.SIGINT:
self.should_force_exit = True
else:
self.should_exit = True

async def _set_should_exit_flag_if_arrived_at_configured_run_for(self) -> None:
if self.config.run_for and self.elapsed_since_started >= self.config.run_for:
self.should_exit = True
23 changes: 23 additions & 0 deletions src/python_sdk/service/_service_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import datetime
import typing

from python_sdk.service import _app


class Config:
app: _app.App
run_for: datetime.timedelta | None
tick_interval: datetime.timedelta

def __init__(
self,
app: _app.App,
run_for: datetime.timedelta | None = None,
):
self.app = app
self.run_for = run_for
self.tick_interval = datetime.timedelta(seconds=0.1)

@property
def as_dict(self) -> dict[str, typing.Any]:
return {"app": self.app, "run_for": self.run_for, "tick_interval": self.tick_interval}
Empty file added tests/unit/service/__init__.py
Empty file.
44 changes: 44 additions & 0 deletions tests/unit/service/test_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import asyncio
import datetime

from python_sdk import service


class App:
should_stop: bool
counter: int

def __init__(self) -> None:
self.should_stop = False
self.counter = 0

async def start(self) -> None:
while not self.should_stop:
self.counter += 1
await asyncio.sleep(0.1)

async def stop(self) -> None:
self.should_stop = True


async def test_service_can_be_run() -> None:
app = App()
SERVICE = service.Service(config=service.Config(app=app))
task = SERVICE.run_in_background()
await asyncio.sleep(0.1)
assert app.counter > 0
await app.stop()
await asyncio.sleep(0.1)
assert task.done()


async def test_service_can_be_run_for_specified_amount_of_time() -> None:
app = App()
SERVICE = service.Service(config=service.Config(app=app, run_for=datetime.timedelta(seconds=0.5)))
task = SERVICE.run_in_background()
await asyncio.sleep(1)
assert app.counter > 0
assert app.counter < 7
assert app.should_stop
assert SERVICE.should_exit
assert task.done()
2 changes: 1 addition & 1 deletion version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.11.0
0.12.0

0 comments on commit d2d8c5b

Please sign in to comment.