Skip to content

Commit

Permalink
v0.0.2 (#2)
Browse files Browse the repository at this point in the history
* [new] Add new CI features and keyboard interrupt handling

* [docs] Update README and docstrings

* [test] Added tests for many features
  • Loading branch information
jeremyephron authored Apr 4, 2022
1 parent fc02327 commit 32d59cc
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 51 deletions.
11 changes: 5 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
name: CI

on:
pull_request:
paths:
- "*"
workflow_dispatch:
paths:
- "*"
pull_request:
paths:
- "**.py"
push:
paths:
- "setup.py"
- "pyterminate/*"
- "**.py"

jobs:
test:
Expand All @@ -25,7 +24,7 @@ jobs:
- name: Checkout source
uses: actions/checkout@v2

- name: Setup python
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: lint

on:
pull_request:
paths:
- "**.py"
push:
paths:
- "**.py"

jobs:
flake8:
runs-on: ubuntu-latest

steps:
- name: Checkout source
uses: actions/checkout@v2

- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.8
architecture: x64

- name: Run flake8
uses: py-actions/flake8@v2
with:
max-line-length: "90"
74 changes: 67 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,82 @@
# pyterminate

Reliably run cleanup upon program termination.
Reliably run cleanup code upon program termination.

## Table of Contents

- [Why does this exist?](#why-does-this-exist?)
- [What can it do?](#what-can-it-do?)
- [Quickstart](#quickstart)

## Why does this exist?

There are currently two builtin modules for handling termination behavior
in Python: [`atexit`](https://docs.python.org/3/library/atexit.html) and
[`signal`](https://docs.python.org/3/library/signal.html). However, using them
directly leads to a lot of repeated boilerplate code, and some non-obvious
behaviors that can be easy to accidentally get wrong, which is why I wrote this
package.

The `atexit` module is currently insufficient since it fails to handle signals.
The `signal` module is currently insufficient since it fails to handle normal
or exception-caused exits.

Typical approaches would include frequently repeated code registering a
function both with `atexit` and on desired signals. However, extra care
sometimes needs to be taken to ensure the function doesn't run twice (or is
idempotent), and that a previously registered signal handler gets called.

## What can it do?

This packages does or allows the following behavior:

- Register a function to be called on program termination
- Always on normal or exception-caused termination: `@pyterminate.register`
- Configurable for any desired signals:<br/>
`@pyterminate.register(signals=(signal.SIGINT, signal.SIGABRT))`

- Allows multiple functions to be registered

- Will call previous registered signal handlers

- Allows zero or non-zero exit codes on captured signals:<br/>
`@pyterminate.register(successful_exit=True)`

- Allows suppressing or throwing of `KeyboardInterrupt` on `SIGINT`:<br/>
`@pyterminate.register(keyboard_interrupt_on_sigint=True)`
- You may want to throw a `KeyboardInterrupt` if there is additional
exception handling defined.

- Allows functions to be unregistered: `pyterminate.unregister(func)`

- Ignore multiple repeated signals to allow the registered functions to
complete
- However, it can be canceled upon receipt of another signal. Desired
behavior could vary application to application, but this feels appropriate
for the most common known use cases.

## Quickstart

```bash
python3 -m pip install pyterminate
```

```python3
import signal

import pyterminate

@pyterminate.register(signals=(signal.SIGINT, signal.SIGTERM))
def cleanup():
@pyterminate.register(
args=(None,),
kwargs={"b": 42},
signals=(signal.SIGINT, signal.SIGTERM),
successful_exit=True,
keyboard_interrupt_on_sigint=True
)
def cleanup(*args, **kwargs):
...

# or

def cleanup(a, b):
...

pyterminate.register(cleanup, args=(None, 42))
pyterminate.register(cleanup, ...)
```
108 changes: 94 additions & 14 deletions pyterminate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
File:
-----
Module: pyterminate
-------------------
Defines decorators for registering and unregistering functions to be called
at program termination.
"""

Expand All @@ -9,31 +11,69 @@
import signal
import sys
from types import FrameType
from typing import Any, Callable, Dict, Optional, Tuple
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple


_registered_funcs = set()
_func_to_wrapper = {}
_signal_to_prev_handler = defaultdict(lambda: defaultdict(list))
_registered_funcs: Set[Callable] = set()
_func_to_wrapper: Dict[Callable, Callable] = {}
_signal_to_prev_handler: DefaultDict[Callable, DefaultDict[int, List[Callable]]] = (
defaultdict(lambda: defaultdict(list))
)


def register(
func: Optional[Callable] = None,
*,
args: tuple = tuple(),
kwargs: Optional[Dict[str, Any]] = None,
signals=(signal.SIGTERM,),
successful_exit: bool = False
signals: Tuple[int] = (signal.SIGTERM,),
successful_exit: bool = False,
keyboard_interrupt_on_sigint: bool = False,
) -> Callable:
kwargs = kwargs or {}
"""
Registers a function to be called at program termination or creates a
decorator to do so.
Args:
func: Function to register.
args: Positional arguments to pass to the function when called.
kwargs: Keyword arguments to pass to the function when called.
signals: Signals to call the given function for when handled.
successful_exit: Whether to always exit with a zero exit code.
Otherwise, exits with the code of the signal.
keyboard_interrupt_on_sigint: Whether to raise a KeyboardInterrupt on
SIGINT. Otherwise, exits with code SIGINT.
Returns:
func: The given function if not None.
decorator: A decorator that will apply the given settings to the
decorated function.
"""

def decorator(func: Callable) -> Callable:
return _register_impl(func, args, kwargs, signals, successful_exit)
return _register_impl(
func,
args,
kwargs or {},
signals,
successful_exit,
keyboard_interrupt_on_sigint
)

return decorator(func) if func else decorator


def unregister(func: Callable) -> None:
"""
Unregisters a previously registered function from being called at exit.
Args:
func: A previously registered function. The call is a no-op if not
previously registered.
"""

if func in _func_to_wrapper:
atexit.unregister(_func_to_wrapper[func])

Expand All @@ -46,26 +86,66 @@ def _register_impl(
kwargs: Dict[str, Any],
signals: Tuple[int, ...],
successful_exit: bool,
keyboard_interrupt_on_sigint: bool,
) -> Callable:
"""
Registers a function to be called at program termination.
This function is the internal implementation of registration, and should
not be called by a user, who should called register() instead.
Idempotent handlers are created for both atexit and signal handling.
The currently handled signal is ignored during the signal handler to allow
for the registered functions to complete when potentially receiving
multiple repeated signals. However, it can be canceled upon receipt of
another signal.
Args:
func: Function to register.
args: Positional arguments to pass to the function when called.
kwargs: Keyword arguments to pass to the function when called.
signals: Signals to call the given function for when handled.
successful_exit: Whether to always exit with a zero exit code.
Otherwise, exits with the code of the signal.
keyboard_interrupt_on_sigint: Whether to raise a KeyboardInterrupt on
SIGINT. Otherwise, exits with code SIGINT.
Returns:
func: The given function.
"""

def exit_handler(*args: Any, **kwargs: Any) -> Any:
if func not in _registered_funcs:
return

_registered_funcs.remove(func)
return func(*args, **kwargs)

def signal_handler(sig: int, frame: FrameType):
def signal_handler(sig: int, frame: Optional[FrameType]) -> None:
signal.signal(sig, signal.SIG_IGN)

exit_handler(*args, **kwargs)
prev_handler = _signal_to_prev_handler[func][sig][-1]
if callable(prev_handler) and prev_handler != signal.default_int_handler:

if _signal_to_prev_handler[func][sig]:
prev_handler = _signal_to_prev_handler[func][sig].pop()
prev_handler(sig, frame)

if keyboard_interrupt_on_sigint and sig == signal.SIGINT:
raise KeyboardInterrupt

sys.exit(0 if successful_exit else sig)

for sig in signals:
_signal_to_prev_handler[func][sig].append(signal.signal(sig, signal_handler))
prev_handler = signal.signal(sig, signal_handler)

if not callable(prev_handler):
continue

if prev_handler == signal.default_int_handler:
continue

_signal_to_prev_handler[func][sig].append(prev_handler)

_registered_funcs.add(func)
_func_to_wrapper[func] = exit_handler
Expand Down
8 changes: 6 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

setuptools.setup(
name='pyterminate',
version='0.0.1',
version='0.0.2',
url='https://github.com/jeremyephron/pyterminate',
author='Jeremy Ephron',
author_email='[email protected]',
description='Exit programs gracefully',
description='Exit programs gracefully.',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
packages=setuptools.find_packages(),
Expand All @@ -15,7 +15,11 @@
'Development Status :: 3 - Alpha',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'License :: OSI Approved :: MIT License',
'Operating System :: MacOS',
'Operating System :: Unix',
Expand Down
Loading

0 comments on commit 32d59cc

Please sign in to comment.