Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing that the .emit call is a no-op when no listeners are registered. #24

Merged
merged 5 commits into from
Sep 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/user_guide/listeners.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Adding event listeners

Event listeners are callback functions/methods that are executed when an event is emitted.
Event listeners are asynchronous callback functions/methods that are triggered when an event is emitted.

Listeners can be used by extension authors to trigger custom logic every time an event occurs.

## Basic usage

Define a listener function:
Define a listener (async) function:

```python
from jupyter_events.logger import EventLogger

def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
print("hello, from my listener")
```

Expand Down
6 changes: 3 additions & 3 deletions jupyter_events/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ async def listener_signature(
raise ListenerError(
"Listeners are required to follow an exact function/method "
"signature. The signature should look like:"
f"\n\n\tdef my_listener{expected_signature}:\n\n"
f"\n\n\tasync def my_listener{expected_signature}:\n\n"
"Check that you are using type annotations for each argument "
"and the return value."
)
Expand Down Expand Up @@ -336,8 +336,8 @@ def emit(self, *, schema_id: str, data: dict, timestamp_override=None):
# If no handlers are routing these events, there's no need to proceed.
if (
not self.handlers
and not self._modified_listeners
and not self._unmodified_listeners
and not self._modified_listeners[schema_id]
and not self._unmodified_listeners[schema_id]
):
return

Expand Down
57 changes: 57 additions & 0 deletions jupyter_events/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import io
import json
import logging

import pytest

from jupyter_events import EventLogger


@pytest.fixture
def jp_event_sink():
"""A stream for capture events."""
return io.StringIO()


@pytest.fixture
def jp_event_handler(jp_event_sink):
"""A logging handler that captures any events emitted by the event handler"""
return logging.StreamHandler(jp_event_sink)


@pytest.fixture
def jp_read_emitted_events(jp_event_handler, jp_event_sink):
"""Reads list of events since last time it was called."""

def _read():
jp_event_handler.flush()
lines = jp_event_sink.getvalue().strip().split("\n")
output = [json.loads(item) for item in lines]
# Clear the sink.
jp_event_sink.truncate(0)
jp_event_sink.seek(0)
return output

return _read


@pytest.fixture
def jp_event_schemas():
"""A list of schema references.

Each item should be one of the following:
- string of serialized JSON/YAML content representing a schema
- a pathlib.Path object pointing to a schema file on disk
- a dictionary with the schema data.
"""
return []


@pytest.fixture
def jp_event_logger(jp_event_handler, jp_event_schemas):
"""A pre-configured event logger for tests."""
logger = EventLogger()
for schema in jp_event_schemas:
logger.register_event_schema(schema)
logger.register_handler(handler=jp_event_handler)
return logger
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ default = ""
[[tool.tbump.field]]
name = "release"
default = ""

[tool.pytest.ini_options]
addopts = "-raXs --durations 10 --color=yes --doctest-modules"
testpaths = [
"tests/"
]
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest_plugins = ["jupyter_events.pytest_plugin"]
24 changes: 15 additions & 9 deletions tests/test_listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@ def schema():


@pytest.fixture
def event_logger(schema):
logger = EventLogger()
logger.register_event_schema(schema)
return logger
def jp_event_schemas(schema):
return [schema]


async def test_listener_function(event_logger, schema):
async def test_listener_function(jp_event_logger, schema):
event_logger = jp_event_logger
global listener_was_called
listener_was_called = False

Expand All @@ -40,7 +39,8 @@ async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
assert len(event_logger._active_listeners) == 0


async def test_remove_listener_function(event_logger, schema):
async def test_remove_listener_function(jp_event_logger, schema):
event_logger = jp_event_logger
global listener_was_called
listener_was_called = False

Expand All @@ -62,7 +62,9 @@ async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
assert len(event_logger._unmodified_listeners[schema.id]) == 0


async def test_bad_listener_function_signature(event_logger, schema):
async def test_bad_listener_function_signature(jp_event_logger, schema):
event_logger = jp_event_logger

async def listener_with_extra_args(
logger: EventLogger, schema_id: str, data: dict, unknown_arg: dict
) -> None:
Expand All @@ -78,7 +80,9 @@ async def listener_with_extra_args(
assert len(event_logger._unmodified_listeners[schema.id]) == 0


async def test_listener_that_raises_exception(event_logger, schema):
async def test_listener_that_raises_exception(jp_event_logger, schema):
event_logger = jp_event_logger

# Get an application logger that will show the exception
app_log = event_logger.log
log_stream = io.StringIO()
Expand All @@ -103,7 +107,9 @@ async def listener_raise_exception(
assert len(event_logger._active_listeners) == 0


async def test_bad_listener_does_not_break_good_listener(event_logger, schema):
async def test_bad_listener_does_not_break_good_listener(jp_event_logger, schema):
event_logger = jp_event_logger

# Get an application logger that will show the exception
app_log = event_logger.log
log_stream = io.StringIO()
Expand Down
81 changes: 81 additions & 0 deletions tests/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import logging
from datetime import datetime, timedelta
from unittest.mock import MagicMock

import jsonschema
import pytest
Expand Down Expand Up @@ -349,3 +350,83 @@ def test_register_duplicate_schemas():
el.register_event_schema(schema0)
with pytest.raises(SchemaRegistryException):
el.register_event_schema(schema1)


async def test_noop_emit():
"""Tests that the emit method returns
immediately if no handlers are listeners
are mapped to the incoming event. This
is important for performance.
"""
el = EventLogger()
# The `emit` method calls `validate_event` if
# it doesn't return immediately. We'll use the
# MagicMock here to see if/when this method is called
# to ensure `emit` is returning when it should.
el.schemas.validate_event = MagicMock(name="validate_event")

schema_id1 = "test/test"
schema1 = {
"$id": schema_id1,
"version": 1,
"type": "object",
"properties": {
"something": {
"type": "string",
"title": "test",
},
},
}
schema_id2 = "test/test2"
schema2 = {
"$id": schema_id2,
"version": 1,
"type": "object",
"properties": {
"something_elss": {
"type": "string",
"title": "test",
},
},
}
el.register_event_schema(schema1)
el.register_event_schema(schema2)

# No handlers or listeners are registered
# So the validate_event method should not
# be called.
el.emit(schema_id=schema_id1, data={"something": "hello"})

el.schemas.validate_event.assert_not_called()

# Register a handler and check that .emit
# validates the method.
handler = logging.StreamHandler()
el.register_handler(handler)

el.emit(schema_id=schema_id1, data={"something": "hello"})

el.schemas.validate_event.assert_called_once()

# Reset
el.remove_handler(handler)
el.schemas.validate_event.reset_mock()
assert el.schemas.validate_event.call_count == 0

# Create a listener and check that emit works

async def listener(logger: EventLogger, schema_id: str, data: dict) -> None:
return None

el.add_listener(schema_id=schema_id1, listener=listener)

el.emit(schema_id=schema_id1, data={"something": "hello"})

el.schemas.validate_event.assert_called_once()
el.schemas.validate_event.reset_mock()
assert el.schemas.validate_event.call_count == 0

# Emit a different event with no listeners or
# handlers and make sure it returns immediately.
el.emit(schema_id=schema_id2, data={"something_else": "hello again"})
el.schemas.validate_event.assert_not_called()
60 changes: 20 additions & 40 deletions tests/test_modifiers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import io
import json
import logging

import pytest

from jupyter_events.logger import EventLogger, ModifierError
Expand All @@ -10,11 +6,6 @@
from .utils import SCHEMA_PATH


@pytest.fixture
def sink():
return io.StringIO()


@pytest.fixture
def schema():
# Read schema from path.
Expand All @@ -23,32 +14,13 @@ def schema():


@pytest.fixture
def handler(sink):
return logging.StreamHandler(sink)
def jp_event_schemas(schema):
return [schema]


@pytest.fixture
def event_logger(handler, schema):
logger = EventLogger()
logger.register_handler(handler)
logger.register_event_schema(schema)
return logger
def test_modifier_function(schema, jp_event_logger, jp_read_emitted_events):
event_logger = jp_event_logger


@pytest.fixture
def read_emitted_event(handler, sink):
def _read():
handler.flush()
output = json.loads(sink.getvalue())
# Clear the sink.
sink.truncate(0)
sink.seek(0)
return output

return _read


def test_modifier_function(schema, event_logger, read_emitted_event):
def redactor(schema_id: str, data: dict) -> dict:
if "username" in data:
data["username"] = "<masked>"
Expand All @@ -57,12 +29,14 @@ def redactor(schema_id: str, data: dict) -> dict:
# Add the modifier
event_logger.add_modifier(modifier=redactor)
event_logger.emit(schema_id=schema.id, data={"username": "jovyan"})
output = read_emitted_event()
output = jp_read_emitted_events()[0]
assert "username" in output
assert output["username"] == "<masked>"


def test_modifier_method(schema, event_logger, read_emitted_event):
def test_modifier_method(schema, jp_event_logger, jp_read_emitted_events):
event_logger = jp_event_logger

class Redactor:
def redact(self, schema_id: str, data: dict) -> dict:
if "username" in data:
Expand All @@ -75,12 +49,14 @@ def redact(self, schema_id: str, data: dict) -> dict:
event_logger.add_modifier(modifier=redactor.redact)

event_logger.emit(schema_id=schema.id, data={"username": "jovyan"})
output = read_emitted_event()
output = jp_read_emitted_events()[0]
assert "username" in output
assert output["username"] == "<masked>"


def test_bad_modifier_functions(event_logger, schema: EventSchema):
def test_bad_modifier_functions(jp_event_logger, schema: EventSchema):
event_logger = jp_event_logger

def modifier_with_extra_args(schema_id: str, data: dict, unknown_arg: dict) -> dict:
return data

Expand All @@ -91,7 +67,9 @@ def modifier_with_extra_args(schema_id: str, data: dict, unknown_arg: dict) -> d
assert len(event_logger._modifiers[schema.id]) == 0


def test_bad_modifier_method(event_logger, schema: EventSchema):
def test_bad_modifier_method(jp_event_logger, schema: EventSchema):
event_logger = jp_event_logger

class Redactor:
def redact(self, schema_id: str, data: dict, extra_args: dict) -> dict:
return data
Expand All @@ -115,7 +93,9 @@ def modifier_with_extra_args(event):
logger.add_modifier(modifier=modifier_with_extra_args)


def test_remove_modifier(schema, event_logger, read_emitted_event):
def test_remove_modifier(schema, jp_event_logger, jp_read_emitted_events):
event_logger = jp_event_logger

def redactor(schema_id: str, data: dict) -> dict:
if "username" in data:
data["username"] = "<masked>"
Expand All @@ -127,15 +107,15 @@ def redactor(schema_id: str, data: dict) -> dict:
assert len(event_logger._modifiers) == 1

event_logger.emit(schema_id=schema.id, data={"username": "jovyan"})
output = read_emitted_event()
output = jp_read_emitted_events()[0]

assert "username" in output
assert output["username"] == "<masked>"

event_logger.remove_modifier(modifier=redactor)

event_logger.emit(schema_id=schema.id, data={"username": "jovyan"})
output = read_emitted_event()
output = jp_read_emitted_events()[0]

assert "username" in output
assert output["username"] == "jovyan"