Files
changedetection.io/changedetectionio/tests/test_plugin_api.py

271 lines
10 KiB
Python

#!/usr/bin/env python3
"""Plugin API foundations: failure isolation, async hook results, and the merged conditions hooks."""
import asyncio
import time
import pluggy
import pytest
from changedetectionio import pluggy_interface
from changedetectionio.plugin_api import PLUGIN_API_VERSION, await_me_maybe, gather_hook_results, hookimpl
from changedetectionio.pluggy_interface import (
collect_ui_edit_stats_extras,
get_itemprop_availability_from_plugin,
get_plugin_health,
plugin_manager,
apply_update_handler_alter,
)
@pytest.fixture
def register_plugin():
"""Register plugins for one test and always unregister them afterwards."""
added = []
def register(plugin, name):
plugin_manager.register(plugin, name=name)
added.append(name)
return plugin
yield register
for name in added:
if plugin_manager.get_plugin(name) is not None:
plugin_manager.unregister(name=name)
@pytest.fixture
def watch(live_server):
datastore = live_server.app.config['DATASTORE']
# Paused, these tests only need the watch object, not a check
uuid = datastore.add_watch(url='https://example.com/plugin-api-test', extras={'paused': True})
return datastore.data['watching'][uuid]
def test_plugin_api_exports():
assert PLUGIN_API_VERSION
# The public marker is the same one core uses, so either import path registers hooks
assert hookimpl is pluggy_interface.hookimpl
def test_raising_plugin_does_not_break_the_hook(register_plugin, watch):
class Broken:
@hookimpl
def ui_edit_stats_extras(self, watch):
raise RuntimeError("boom from a broken plugin")
class Working:
@hookimpl
def ui_edit_stats_extras(self, watch):
return '<p id="working-plugin">still here</p>'
register_plugin(Working(), 'test_isolation_working')
register_plugin(Broken(), 'test_isolation_broken')
html = collect_ui_edit_stats_extras(watch)
assert 'working-plugin' in html, "The other plugins' output must survive one plugin raising"
health = get_plugin_health('test_isolation_broken')
assert health['total_failures'] == 1
assert health['consecutive_failures'] == 1
assert health['last_hook'] == 'ui_edit_stats_extras'
assert 'boom from a broken plugin' in health['last_error']
assert health['errored'] is False
assert get_plugin_health('test_isolation_working') is None, "A plugin that never failed isn't listed"
def test_plugin_reported_errored_after_consecutive_failures_and_recovers(register_plugin, watch, monkeypatch):
monkeypatch.setattr(pluggy_interface, 'PLUGIN_CONSECUTIVE_FAILURES_ERRORED', 3)
state = {'fail': True}
class Flaky:
@hookimpl
def ui_edit_stats_extras(self, watch):
if state['fail']:
raise ValueError("flaky")
return 'ok'
register_plugin(Flaky(), 'test_isolation_flaky')
for _ in range(3):
collect_ui_edit_stats_extras(watch)
assert get_plugin_health('test_isolation_flaky')['errored'] is True
state['fail'] = False
collect_ui_edit_stats_extras(watch)
health = get_plugin_health('test_isolation_flaky')
assert health['consecutive_failures'] == 0
assert health['total_failures'] == 3
assert health['errored'] is False
def test_firstresult_hook_falls_through_a_raising_plugin(register_plugin):
class Answers:
@hookimpl
def get_itemprop_availability_override(self, content):
return {'price': 9.99, 'currency': 'USD', 'availability': 'instock'}
class Raises:
@hookimpl(tryfirst=True)
def get_itemprop_availability_override(self, content):
raise RuntimeError("restock plugin exploded")
register_plugin(Answers(), 'test_isolation_restock_answers')
register_plugin(Raises(), 'test_isolation_restock_raises')
result = get_itemprop_availability_from_plugin('<html>x</html>', 'html_requests', None, 'https://example.com')
assert result['price'] == 9.99
assert get_plugin_health('test_isolation_restock_raises')['total_failures'] == 1
def test_update_handler_alter_raising_keeps_original_handler(register_plugin):
class Raises:
@hookimpl
def update_handler_alter(self, update_handler, watch, datastore):
raise RuntimeError("wrapper failed")
register_plugin(Raises(), 'test_isolation_handler_alter')
original = object()
assert apply_update_handler_alter(original, {'uuid': 'x'}, None) is original
def test_async_hook_results_are_awaited_and_isolated(register_plugin, watch, monkeypatch):
monkeypatch.setattr(pluggy_interface, 'PLUGIN_ASYNC_HOOK_TIMEOUT', 0.5)
class AsyncGood:
@hookimpl
async def ui_edit_stats_extras(self, watch):
await asyncio.sleep(0)
return 'async-good'
class AsyncBroken:
@hookimpl
async def ui_edit_stats_extras(self, watch):
raise RuntimeError("async boom")
class AsyncHung:
@hookimpl
async def ui_edit_stats_extras(self, watch):
await asyncio.sleep(30)
return 'never'
register_plugin(AsyncGood(), 'test_async_good')
register_plugin(AsyncBroken(), 'test_async_broken')
register_plugin(AsyncHung(), 'test_async_hung')
raw = plugin_manager.hook.ui_edit_stats_extras(watch=watch)
started = time.time()
results = asyncio.run(gather_hook_results(raw))
assert time.time() - started < 5, "A hung async plugin must be cut off by the timeout"
assert 'async-good' in results
assert 'never' not in results
assert get_plugin_health('test_async_broken')['total_failures'] == 1
assert 'TimeoutError' in get_plugin_health('test_async_hung')['last_error']
def test_await_me_maybe_passes_plain_values_through():
async def coro():
return 'awaited'
assert asyncio.run(await_me_maybe('plain')) == 'plain'
assert asyncio.run(await_me_maybe(coro())) == 'awaited'
# Classes are callable but must not be called, register_* hooks return classes
assert asyncio.run(await_me_maybe(dict)) is dict
def test_hooks_that_propagate_exceptions_still_raise(register_plugin, monkeypatch):
# The decision is taken when the plugin is registered, so patch before registering
monkeypatch.setattr(pluggy_interface, 'HOOKS_THAT_PROPAGATE_EXCEPTIONS', {'get_html_head_extras'})
class Raises:
@hookimpl
def get_html_head_extras(self):
raise ValueError("meant to reach the caller")
register_plugin(Raises(), 'test_propagates')
with pytest.raises(ValueError, match="meant to reach the caller"):
plugin_manager.hook.get_html_head_extras()
assert get_plugin_health('test_propagates')['total_failures'] == 1, "Still logged and counted"
def test_conditions_hooks_live_on_the_main_manager():
from changedetectionio.conditions import get_custom_operations, get_field_choices, get_operator_choices
field_names = [name for name, _ in get_field_choices()]
# Built-in conditions plugin first, then the bundled plugins
assert field_names[:3] == [None, 'extracted_number', 'page_filtered_text']
assert 'levenshtein_ratio' in field_names and 'word_count' in field_names
assert 'starts_with' in [name for name, _ in get_operator_choices()]
assert 'starts_with' in get_custom_operations()
assert '==' in get_custom_operations(), "json_logic builtins are still included"
def test_legacy_conditions_marker_still_works(register_plugin):
from changedetectionio.conditions import get_field_choices
from changedetectionio.conditions.form import ConditionFormRow
# The old import path keeps working for published plugins
from changedetectionio.conditions.pluggy_interface import hookimpl as legacy_import_hookimpl
legacy_marker = pluggy.HookimplMarker("changedetectionio_conditions")
class OldStylePlugin:
@legacy_marker
def register_field_choices(self):
return [('legacy_field', 'Legacy field')]
class OldImportPathPlugin:
@legacy_import_hookimpl
def register_field_choices(self):
return [('legacy_import_field', 'Legacy import field')]
register_plugin(OldStylePlugin(), 'test_legacy_conditions_marker')
register_plugin(OldImportPathPlugin(), 'test_legacy_conditions_import')
field_names = [name for name, _ in get_field_choices()]
assert 'legacy_field' in field_names
assert 'legacy_import_field' in field_names
# Forms collect choices when they're built, so a plugin registered after startup shows up
form_field_names = [name for name, _ in ConditionFormRow().field.choices]
assert 'legacy_field' in form_field_names
def test_conditions_add_data_survives_broken_and_slow_plugins(register_plugin, monkeypatch):
import changedetectionio.conditions as conditions
monkeypatch.setattr(conditions, 'ADD_DATA_TIMEOUT', 0.5)
class Broken:
@hookimpl
def add_data(self, current_watch_uuid, application_datastruct, ephemeral_data):
raise RuntimeError("add_data boom")
class Slow:
@hookimpl
def add_data(self, ephemeral_data):
time.sleep(3)
return {'slow_fact': 1}
class Good:
@hookimpl
def add_data(self, ephemeral_data):
# Declares only the argument it needs, like pluggy allows
return {'good_fact': len(ephemeral_data.get('text', ''))}
register_plugin(Broken(), 'test_add_data_broken')
register_plugin(Slow(), 'test_add_data_slow')
register_plugin(Good(), 'test_add_data_good')
application_datastruct = {'watching': {'w1': {
'conditions': [{'field': 'good_fact', 'operator': '==', 'value': '5'}],
'conditions_match_logic': 'ALL',
}}}
started = time.time()
outcome = conditions.execute_ruleset_against_all_plugins('w1', application_datastruct, {'text': 'hello'})
assert time.time() - started < 2.5, "The slow plugin must be skipped, not waited for"
assert outcome['executed_data']['good_fact'] == 5
assert 'slow_fact' not in outcome['executed_data']
assert outcome['result'] is True
assert get_plugin_health('test_add_data_broken')['total_failures'] == 1