subaudit 0.1.0
Subscribe and unsubscribe for specific audit events
License: 0BSD
Requires Python: >=3.7,<4.0
Keywords: audit,auditing,events,subscribe,unsubscribe

<!--
  Copyright (c) 2023 Eliah Kagan

  Permission to use, copy, modify, and/or distribute this software for any
  purpose with or without fee is hereby granted.

  THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
  OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  PERFORMANCE OF THIS SOFTWARE.
-->

# subaudit: Subscribe and unsubscribe for specific audit events

[Audit hooks](https://docs.python.org/3/library/audit_events.html) in Python
are called on all events, and they remain in place until the interpreter shuts
down.

This library provides a higher-level interface that allows listeners to be
subscribed to specific audit events, and unsubscribed from them. It provides
[context managers](https://github.com/EliahKagan/subaudit#basic-usage) for
using that interface with a convenient notation that ensures the listener is
unsubscribed. The context managers are reentrant—you can nest `with`-statements
that listen to events. By default, a single audit hook is used for any number
of events and listeners.

The primary use case for this library is in writing test code.

## License

subaudit is licensed under [0BSD](https://spdx.org/licenses/0BSD.html), which
is a [“public-domain
equivalent”](https://en.wikipedia.org/wiki/Public-domain-equivalent_license)
license. See
[**`LICENSE`**](https://github.com/EliahKagan/subaudit/blob/main/LICENSE).

## Compatibility

The subaudit library can be used to observe [audit events generated by the
Python interpreter and standard
library](https://docs.python.org/3/library/audit_events.html), as well as
custom audit events. It requires Python 3.7 or later. It is most useful on
Python 3.8 or later, because [audit events were introduced in Python
3.8](https://peps.python.org/pep-0578/). On Python 3.7, subaudit uses [the
*sysaudit* library](https://pypi.org/project/sysaudit/) to support audit
events, but the Python interpreter and standard library still do not provide
any events, so only custom events can be used on Python 3.7.

To avoid the performance cost of explicit locking in the audit hook, [some
operations are assumed atomic](https://github.com/EliahKagan/subaudit#locking).
I believe these assumptions are correct for CPython, as well as PyPy and some
other implementations, but there may exist Python implementations on which
these assumptions don’t hold.

## Installation  <!-- Maybe remove this section once badges are added. -->

Install [the `subaudit` package (PyPI)](https://pypi.org/project/subaudit/) in
your project’s environment.

## Basic usage

### The `subaudit.listening` context manager

The best way to use subaudit is usually the `listening` context manager.

```python
import subaudit

def listen_open(path, mode, flags):
    ...  # Handle the event.

with subaudit.listening('open', listen_open):
    ...  # Do something that may raise the event.
```

The listener—here, `listen_open`—is called with the event arguments each time
the event is raised. They are passed to the listener as separate positional
arguments (not as an `args` tuple).

In tests, it is convenient to use [`Mock`
objects](https://docs.python.org/3/library/unittest.mock.html#the-mock-class)
as listeners, because they record calls, provide a
[`mock_calls`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.mock_calls)
attribute to see the calls, and provide [various `assert_*`
methods](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called)
to make assertions about the calls:

```python
from unittest.mock import ANY, Mock
import subaudit

with subaudit.listening('open', Mock()) as listener:
    ...  # Do something that may raise the event.

listener.assert_any_call('/path/to/file.txt', 'r', ANY)
```

Note how, when the `listening` context manager is entered, it returns the
`listener` that was passed in, for convenience.

### The `subaudit.extracting` context manager

You may want to extract some information about calls to a list:

```python
from dataclasses import InitVar, dataclass
import subaudit

@dataclass(frozen=True)
class PathAndMode:  # Usually strings. See examples/notebooks/open_event.ipynb.
    path: str
    mode: str
    flags: InitVar = None  # Opt not to record this argument.

with subaudit.extracting('open', PathAndMode) as extracts:
    ...  # Do something that may raise the event.

assert PathAndMode('/path/to/file.txt', 'r') in extracts
```

The extractor—here, `PathAndMode`—can be any callable that accepts the event
args as separate positional arguments. Entering the context manager returns an
initially empty list, which will be populated with *extracts* gleaned from the
event args. Each time the event is raised, the extractor is called and the
object it returns is appended to the list.

### `subaudit.subscribe` and `subaudit.unsubscribe`

Although you should usually use the `listening` or `extracting` context
managers instead, you can subscribe and unsubscribe listeners without a context
manager:

```python
import subaudit

def listen_open(path, mode, flags):
    ...  # Handle the event.

subaudit.subscribe('open', listen_open)
try:
    ...  # Do something that may raise the event.
finally:
    subaudit.unsubscribe('open', listen_open)
```

Attempting to unsubscribe a listener that is not subscribed raises
`ValueError`. Currently, subaudit provides no feature to make this succeed
silently instead. But you can suppress the exception:

```python
with contextlib.suppress(ValueError):
    subaudit.unsubscribe('glob.glob', possibly_subscribed_listener)
```

## Nesting

To unsubscribe a listener from an event, it must be subscribed to the event.
Subject to this restriction, calls to [`subscribe` and
`unsubscribe`](https://github.com/EliahKagan/subaudit#subauditsubscribe-and-subauditunsubscribe)
can happen in any order, and
[`listening`](https://github.com/EliahKagan/subaudit#the-subauditlistening-context-manager)
and
[`extracting`](https://github.com/EliahKagan/subaudit#the-subauditextracting-context-manager)
may be arbitrarily nested.

`listening` and `extracting` support reentrant use with both the same event and
different events. Here’s an example with three `listening` contexts:

```python
from unittest.mock import Mock, call

listen_to = Mock()  # Let us assert calls to child mocks in a specific order.

with subaudit.listening('open', print):  # Print all open events' arguments.
    with subaudit.listening('open', listen_to.open):  # Log opening.
        with subaudit.listening('glob.glob', listen_to.glob):  # Log globbing.
            ...  # Do something that may raise the events.

assert listen_to.mock_calls == ...  # Assert a specific order of calls.
```

(That is written out to make the nesting clear. You could also use a single
`with`-statement with commas.)

Here’s an example with both `listening` and `extracting` contexts:

```python
from unittest.mock import Mock, call

def extract(*args):
    return args

with (
    subaudit.extracting('pathlib.Path.glob', extract) as glob_extracts,
    subaudit.listening('pathlib.Path.glob', Mock()) as glob_listener,
    subaudit.extracting('pathlib.Path.rglob', extract) as rglob_extracts,
    subaudit.listening('pathlib.Path.rglob', Mock()) as rglob_listener,
):
    ...  # Do something that may raise the events.

# Assert something about, or otherwise use, the mocks glob_listener and
# rglob_listener, as well as the lists glob_extracts and rglob_extracts.
...
```

(That example uses [parenthesized context
managers](https://docs.python.org/3/whatsnew/3.10.html#parenthesized-context-managers),
which were introduced in Python 3.10.)

## Specialized usage

### `subaudit.Hook` objects

Each instance of the `subaudit.Hook` class represents a single audit hook that
supports subscribing and unsubscribing listeners for any number of events, with
methods corresponding to the four top-level functions listed above. Separate
`Hook` instances use separate audit hooks. The `Hook` class exists for three
purposes:

- It supplies the behavior of [the top-level `listening`, `extracting`,
  `subscribe`, and `unsubscribe`
  functions](https://github.com/EliahKagan/subaudit#basic-usage), which
  correspond to the same-named methods on a global `Hook` instance.
- It allows multiple audit hooks to be used, for special cases where that might
  be desired.
- It facilitates customization, as detailed below.

The actual audit hook that a `Hook` object encapsulates is not installed until
the first listener is subscribed. This happens on the first call to its
`subscribe` method, or the first time one of its context managers (from calling
its `listening` or `extracting` method) is entered. This is also true of the
global `Hook` instance used by the top-level functions—merely importing
`subaudit` does not install an audit hook.

Whether the top-level functions are bound methods of a `Hook` instance, or
delegate in some other way to those methods on an instance, is currently
considered an implementation detail.

### Deriving from `Hook`

You can derive from `Hook` to provide custom behavior for subscribing and
unsubscribing, by overriding the `subscribe` and `unsubscribe` methods. You can
also override the `listening` and `extracting` methods, though that may be less
useful. Overridden `subscribe` and `unsubscribe` methods are automatically used
by `listening` and `extracting`.

Whether `extracting` uses `listening`, or directly calls `subscribe` and
`unsubscribe`, is currently considered an implementation detail.

### Locking

Consider two possible cases of race conditions:

#### 1. Between audit hook and `subscribe`/`unsubscribe` (audit hook does not lock)

In this scenario, a `Hook` object’s installed audit hook runs at the same time
as a listener is subscribed or unsubscribed.

This is likely to occur often and it cannot be prevented, because audit hooks
are called for all audit events. For the same reason, locking in the audit hook
has performance implications. Instead of having audit hooks take locks,
subaudit relies on each of these operations being atomic:

- *Writing an attribute reference, when it is a simple write to an instance
  dictionary or a slot.* Writing an attribute need not be atomic when, for
  example, `__setattr__` has been overridden.
- *Writing or deleting a ``str`` key in a dictionary whose keys are all of the
  built-in ``str`` type.* Note that the search need not be atomic, but the
  dictionary must always be observed to be in a valid state.

The audit hook is written, and the data structures it uses are selected, to
avoid relying on more than these assumptions.

#### 2. Between calls to `subscribe`/`unsubscribe` (by default, they lock)

In this scenario, two listeners are subscribed at a time, or unsubscribed at a
time, or one listener is subscribed while another (or the same) listener is
unsubscribed.

This is less likely to occur and much easier to avoid. But it is also harder to
make safe without a lock. Subscribing and unsubscribing are unlikely to happen
at a *sustained* high rate, so locking is unlikely to be a performance
bottleneck. So, *by default*, subscribing and unsubscribing are synchronized
with a
[`threading.Lock`](https://docs.python.org/3/library/threading.html#threading.Lock),
to ensure that shared state is not corrupted.

You should not usually change this. But if you want to, you can construct a
`Hook` object by calling `Hook(sub_lock_factory=...)` instead of `Hook`, where
`...` is a type, or other context manager factory, to be used instead of
`threading.Lock`. In particular, to disable locking, pass
[`contextlib.nullcontext`](https://docs.python.org/3/library/contextlib.html#contextlib.nullcontext).

## Functions related to compatibility

As [noted above](https://github.com/EliahKagan/subaudit#compatibility), Python
supports audit hooks [since 3.8](https://peps.python.org/pep-0578/). For Python
3.7, but not Python 3.8 or later, the subaudit library declares
[sysaudit](https://pypi.org/project/sysaudit/) as a dependency.

### `subaudit.addaudithook` and `subaudit.audit`

subaudit exports `addaudithook` and `audit` functions.

- On Python 3.8 and later, they are
  [`sys.addaudithook`](https://docs.python.org/3/library/sys.html#sys.addaudithook)
  and [`sys.audit`](https://docs.python.org/3/library/sys.html#sys.audit).
- On Python 3.7, they are
  [`sysaudit.addaudithook`](https://sysaudit.readthedocs.io/en/latest/#sysaudit.addaudithook)
  and
  [`sysaudit.audit`](https://sysaudit.readthedocs.io/en/latest/#sysaudit.audit).

subaudit uses `subaudit.addaudithook` when it adds its own audit hook (or all
its own hooks, if you use additional
[`Hook`](https://github.com/EliahKagan/subaudit#subaudithook-objects) instances
besides the global one implicitly used by [the top-level
functions](https://github.com/EliahKagan/subaudit#basic-usage)). subaudit does
not itself use `subaudit.audit`, but it is whichever `audit` function
corresponds to `subaudit.addaudithook`.

### `@subaudit.skip_if_unavailable`

The primary use case for subaudit is in writing unit tests, to assert that
particular events have been raised or not raised. Usually these are [“built in”
events](https://docs.python.org/3.8/library/audit_events.html)—those raised by
the Python interpreter or standard library. But the sysaudit library doesn’t
backport those events, which would not really be feasible to do.

For this reason, tests that particular audit events did or didn’t occur—such as
a test that a file has been opened by listening to the `open` event—should
typically be skipped when running a test suite on Python 3.7.

**When using the [unittest](https://docs.python.org/3/library/unittest.html)
framework**, you can apply the `@skip_if_unavailable` decorator to a test class
or test method, so it is skipped prior to Python 3.8 with a message explaining
why. For example:

```python
import unittest
from unittest.mock import ANY, Mock
import subaudit

class TestSomeThings(unittest.TestCase):
    ...

    @subaudit.skip_if_unavailable  # Skip this test if < 3.8, with a message.
    def test_file_is_opened_for_read(self):
        with subaudit.listening('open', Mock()) as listener:
            ...  # Do something that may raise the event.

        listener.assert_any_call('/path/to/file.txt', 'r', ANY)

    ...

@subaudit.skip_if_unavailable  # Skip the whole class if < 3.8, with a message.
class TestSomeMoreThings(unittest.TestCase):
    ...
```

It could be useful also to have a conditional xfail ([expected
failure](https://docs.python.org/3/library/unittest.html#unittest.expectedFailure))
decorator for unittest—and, more so,
[marks](https://docs.pytest.org/en/7.1.x/how-to/mark.html) for
[pytest](https://docs.pytest.org/) providing specialized
[skip](https://docs.pytest.org/en/7.3.x/how-to/skipping.html#skipping-test-functions)/[skipif](https://docs.pytest.org/en/7.3.x/how-to/skipping.html#id1)
and
[xfail](https://docs.pytest.org/en/7.3.x/how-to/skipping.html#xfail-mark-test-functions-as-expected-to-fail)—but
subaudit does not currently provide them. Of course, in pytest, you can still
use the [`@pytest.mark.skip` and
`@pytest.mark.xfail`](https://docs.pytest.org/en/7.3.x/how-to/skipping.html)
decorators, by passing `sys.version_info < (3, 8)` as the condition.

## Overview by level of abstraction

From higher to lower level, from the perspective of the top-level
[`listening`](https://github.com/EliahKagan/subaudit#the-subauditlistening-context-manager)
and
[`extracting`](https://github.com/EliahKagan/subaudit#the-subauditextracting-context-manager)
functions:

- [`subaudit.extracting`](https://github.com/EliahKagan/subaudit#the-subauditextracting-context-manager)
  \- context manager that listens and extracts to a list
- [`subaudit.listening`](https://github.com/EliahKagan/subaudit#the-subauditlistening-context-manager)
  \- context manager to subscribe and unsubscribe a custom listener *(usually
  use this)*
- [`subaudit.subscribe` and
  `subaudit.unsubscribe`](https://github.com/EliahKagan/subaudit#subauditsubscribe-and-subauditunsubscribe)
  \- manually subscribe/unsubscribe a listener
- [`subaudit.Hook`](https://github.com/EliahKagan/subaudit#subaudithook-objects)
  \- abstraction around an audit hook allowing subscribing and unsubscribing
  for specific events, with `extracting`, `listening`, `subscribe`, and
  `unsubscribe` instance methods
- [`subaudit.addaudithook`](https://github.com/EliahKagan/subaudit#subauditaddaudithook-and-subauditaudit)
  \- trivial abstraction representing whether the function from `sys` or
  `sysaudit` is used
- [`sys.addaudithook`](https://docs.python.org/3/library/sys.html#sys.addaudithook)
  or
  [`sysaudit.addaudithook`](https://sysaudit.readthedocs.io/en/latest/#sysaudit.addaudithook)
  \- *not part of subaudit* \- install a [PEP
  578](https://peps.python.org/pep-0578/) audit hook

This list is not exhaustive. For example,
[`@skip_if_unavailable`](https://github.com/EliahKagan/subaudit#subauditskip_if_unavailable)
is not part of that conceptual hierarchy.

## Acknowledgements

I’d like to thank:

- [**Brett Langdon**](https://github.com/brettlangdon), who wrote the
  [sysaudit](https://github.com/brettlangdon/sysaudit) library (which subaudit
  [uses on 3.7](https://github.com/EliahKagan/subaudit#compatibility)).

- [**David Vassallo**](https://github.com/dmvassallo), for reviewing pull
  requests about testing using audit hooks in [a project we have collaborated
  on](https://github.com/dmvassallo/EmbeddingScratchwork), which helped me to
  recognize what kinds of usage were more or less clear and that it could be
  good to have a library like subaudit; and for coauthoring a
  `@skip_if_unavailable` decorator that had been used there, which motivated
  the one here.

## About the name

This library is called “subaudit” because it provides a way to effectively
*sub*scribe to and un*sub*scribe from a *sub*set of audit events rather than
all of them.