import pluggy
import os
import importlib
import inspect
import sys
import threading
import time
from functools import wraps
from loguru import logger
# Global plugin namespace for changedetection.io
PLUGIN_NAMESPACE = "changedetectionio"
# Conditions plugins used to have their own PluginManager under this name. Their hooks now live on
# the main manager, but implementations marked with the old namespace are still picked up (see
# ChangeDetectionPluginManager.parse_hookimpl_opts) and the old entry-point group is still loaded.
LEGACY_CONDITIONS_NAMESPACE = "changedetectionio_conditions"
hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)
hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)
# A plugin that raises is logged and counted, and the hook carries on with the other plugins.
# After this many failures in a row the plugin is reported as "errored" (it is still called).
PLUGIN_CONSECUTIVE_FAILURES_ERRORED = int(os.getenv('PLUGIN_CONSECUTIVE_FAILURES_ERRORED', 5))
# Upper bound for an async hook implementation, so a hung plugin can't stall a check forever.
PLUGIN_ASYNC_HOOK_TIMEOUT = float(os.getenv('PLUGIN_ASYNC_HOOK_TIMEOUT', 120))
# Hooks whose exceptions must reach the caller (after being logged and counted), for example a
# future fetch hook that raises EmptyReply on purpose. Every other hook swallows plugin exceptions.
HOOKS_THAT_PROPAGATE_EXCEPTIONS = set()
class ChangeDetectionSpec:
"""Hook specifications for extending changedetection.io functionality."""
@hookspec
def ui_edit_stats_extras(watch):
"""Return HTML content to add to the stats tab in the edit view.
Args:
watch: The watch object being edited
Returns:
str: HTML content to be inserted in the stats tab
"""
pass
@hookspec
def register_content_fetcher(self):
"""Return a tuple of (fetcher_name, fetcher_class) for content fetcher plugins.
The fetcher_name should start with 'html_' and the fetcher_class
should inherit from changedetectionio.content_fetchers.base.Fetcher
Returns:
tuple: (str: fetcher_name, class: fetcher_class)
"""
pass
@hookspec
def fetcher_status_icon(fetcher_name):
"""Return status icon HTML attributes for a content fetcher.
Args:
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
str: HTML string containing tags or other status icon elements
Empty string if no custom status icon is needed
"""
pass
@hookspec
def plugin_static_path(self):
"""Return the path to the plugin's static files directory.
Returns:
str: Absolute path to the plugin's static directory, or None if no static files
"""
pass
@hookspec(firstresult=True)
def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url, llm_intent, text):
"""Provide custom implementation of get_itemprop_availability for a specific fetcher.
This hook allows plugins to provide their own product availability detection
when their fetcher is being used. This is called as a fallback when the built-in
method doesn't find good data.
The first implementation to return a non-None result wins and the rest are skipped,
so return None (not an empty dict) when you have nothing. The built-in LLM restock
plugin (litellm) is registered trylast, so your own plugin, for example one that
POSTs to an LLM with plain `requests`, always gets the first go and the built-in
one is never billed when yours already answered. See plugins/llm_restock.py for
the prompt, cache and reply-parsing helpers you can reuse without importing litellm.
Args:
content: The HTML content that was just fetched
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
fetcher_instance: The fetcher instance that generated the content
url: The URL being watched/checked
llm_intent: Optional user-supplied intent string (e.g. "alert when price drops below $300")
text: The same content converted with html_to_text(). Only computed when an
implementation declares this argument, so leave it out if you don't need it.
Declare the arguments you want WITHOUT default values (`text`, not `text=None`),
pluggy silently never passes an argument that has a default.
Returns:
dict or None: Dictionary with availability data:
{
'price': float or None,
'availability': str or None, # e.g., 'in stock', 'out of stock'
'currency': str or None, # e.g., 'USD', 'EUR'
}
Or None if this plugin doesn't handle this fetcher or couldn't extract data
"""
pass
@hookspec
def plugin_settings_tab(self):
"""Return settings tab information for this plugin.
This hook allows plugins to add their own settings tab to the settings page.
Settings will be saved to a separate JSON file in the datastore directory.
Returns:
dict or None: Dictionary with settings tab information:
{
'plugin_id': str, # Unique identifier (e.g., 'zyte_fetcher')
'tab_label': str, # Display name for tab (e.g., 'Zyte Fetcher')
'form_class': Form, # WTForms Form class for the settings
'template_path': str, # Optional: path to Jinja2 template (relative to plugin)
# If not provided, a default form renderer will be used
}
Or None if this plugin doesn't provide settings
"""
pass
@hookspec
def register_processor(self):
"""Register an external processor plugin.
External packages can implement this hook to register custom processors
that will be discovered alongside built-in processors.
Returns:
dict or None: Dictionary with processor information:
{
'processor_name': str, # Machine name (e.g., 'osint_recon')
'processor_module': module, # Module containing processor.py
'processor_class': class, # The perform_site_check class
'metadata': { # Optional metadata
'name': str, # Display name
'description': str, # Description
'processor_weight': int,# Sort weight (lower = higher priority)
'list_badge_text': str, # Badge text for UI
}
}
Return None if this plugin doesn't provide a processor
"""
pass
@hookspec
def update_handler_alter(update_handler, watch, datastore):
"""Modify or wrap the update_handler before it processes a watch.
This hook is called after the update_handler (perform_site_check instance) is created
but before it calls call_browser() and run_changedetection(). Plugins can use this to:
- Wrap the handler to add logging/metrics
- Modify handler configuration
- Add custom preprocessing logic
Args:
update_handler: The perform_site_check instance that will process the watch
watch: The watch dict being processed
datastore: The application datastore
Returns:
object or None: Return a modified/wrapped handler, or None to keep the original.
If multiple plugins return handlers, they are chained in registration order.
"""
pass
@hookspec
def update_finalize(update_handler, watch, datastore, processing_exception):
"""Called after watch processing completes (success or failure).
This hook is called in the finally block after all processing is complete,
allowing plugins to perform cleanup, update metrics, or log final status.
The plugin can access update_handler.last_logging_insert_id if it was stored
during update_handler_alter, and use processing_exception to determine if
the processing succeeded or failed.
Args:
update_handler: The perform_site_check instance (may be None if creation failed)
watch: The watch dict that was processed (may be None if not loaded)
datastore: The application datastore
processing_exception: The exception from the main processing block, or None if successful.
This does NOT include cleanup exceptions - only exceptions from
the actual watch processing (fetch, diff, etc).
Returns:
None: This hook doesn't return a value
"""
pass
@hookspec
def get_html_head_extras():
"""Return HTML to inject into the
of every page via base.html.
Plugins can use this to add '
)
For larger assets, register your own lightweight Flask routes in the plugin
module and point to them with url_for() so the sub-path prefix is handled
automatically::
from flask import url_for, Response
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.flask_app import app as _app
MY_CSS = ".my-module-example { color: red; }"
MY_JS = "console.log('my_module_content loaded');"
@_app.route('/my_module_content/css')
def my_module_content_css():
return Response(MY_CSS, mimetype='text/css',
headers={'Cache-Control': 'max-age=3600'})
@_app.route('/my_module_content/js')
def my_module_content_js():
return Response(MY_JS, mimetype='application/javascript',
headers={'Cache-Control': 'max-age=3600'})
@hookimpl
def get_html_head_extras(self):
css = url_for('my_module_content_css')
js = url_for('my_module_content_js')
return (
f'\\n'
f''
)
Returns:
str or None: Raw HTML string to inject inside , or None
"""
pass
# --- Conditions (JSON Logic rules on the watch edit page) ---
@hookspec
def register_operators():
"""Return a dict of extra JSON Logic operators, {"operator_name": callable}."""
pass
@hookspec
def register_operator_choices():
"""Return a list of (operator_name, label) tuples to offer in the conditions UI."""
pass
@hookspec
def register_field_choices():
"""Return a list of (field_name, label) tuples to offer in the conditions UI."""
pass
@hookspec
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
"""Return a dict of extra facts the conditions are evaluated against.
Each implementation is given 10 seconds, see conditions.execute_ruleset_against_all_plugins().
"""
pass
_plugin_health = {}
_plugin_health_lock = threading.Lock()
def _record_hook_success(plugin_name):
with _plugin_health_lock:
health = _plugin_health.get(plugin_name)
if health:
health['consecutive_failures'] = 0
def _record_hook_failure(plugin_name, hook_name, exception):
with _plugin_health_lock:
health = _plugin_health.setdefault(plugin_name, {'consecutive_failures': 0, 'total_failures': 0})
health['consecutive_failures'] += 1
health['total_failures'] += 1
health['last_hook'] = hook_name
health['last_error'] = f"{type(exception).__name__}: {exception}"
health['last_error_time'] = time.time()
logger.opt(exception=exception).error(f"Plugin '{plugin_name}' raised in hook '{hook_name}': {exception}")
def get_plugin_health(plugin_name=None):
"""Failure counters per plugin, {name: {'consecutive_failures', 'total_failures', 'last_hook',
'last_error', 'last_error_time', 'errored'}}. Plugins that never failed are not listed."""
with _plugin_health_lock:
snapshot = {name: dict(h, errored=h['consecutive_failures'] >= PLUGIN_CONSECUTIVE_FAILURES_ERRORED)
for name, h in _plugin_health.items()}
if plugin_name is not None:
return snapshot.get(plugin_name)
return snapshot
def _isolate_hookimpl(function, plugin_name, hook_name):
"""Wrap one hook implementation so its exceptions are logged and counted instead of breaking
the hook call (and with it the check, page or worker that made it).
pluggy calls hookimpl.function(*args) with the arguments the implementation declared, and it
has already read those names (HookImpl.argnames) from the original function, so the wrapper
only has to pass everything through.
"""
propagate = hook_name in HOOKS_THAT_PROPAGATE_EXCEPTIONS
if inspect.iscoroutinefunction(function):
import asyncio
@wraps(function)
async def isolated_async(*args, **kwargs):
try:
result = await asyncio.wait_for(function(*args, **kwargs), timeout=PLUGIN_ASYNC_HOOK_TIMEOUT)
except Exception as e:
_record_hook_failure(plugin_name, hook_name, e)
if propagate:
raise
return None
_record_hook_success(plugin_name)
return result
return isolated_async
@wraps(function)
def isolated(*args, **kwargs):
try:
result = function(*args, **kwargs)
except Exception as e:
_record_hook_failure(plugin_name, hook_name, e)
if propagate:
raise
return None
_record_hook_success(plugin_name)
return result
return isolated
class ChangeDetectionPluginManager(pluggy.PluginManager):
"""pluggy.PluginManager with failure isolation and the legacy conditions hookimpl marker."""
_warned_legacy_marker = set()
def parse_hookimpl_opts(self, plugin, name):
opts = super().parse_hookimpl_opts(plugin, name)
if opts is None:
method = getattr(plugin, name, None)
if inspect.isroutine(method):
legacy = getattr(method, LEGACY_CONDITIONS_NAMESPACE + "_impl", None)
if isinstance(legacy, dict):
plugin_label = getattr(plugin, '__name__', type(plugin).__name__)
if plugin_label not in self._warned_legacy_marker:
self._warned_legacy_marker.add(plugin_label)
logger.warning(f"Plugin '{plugin_label}' uses the deprecated "
f"HookimplMarker('{LEGACY_CONDITIONS_NAMESPACE}'), "
f"use 'from changedetectionio.plugin_api import hookimpl' instead")
opts = dict(legacy)
return opts
def register(self, plugin, name=None):
plugin_name = super().register(plugin, name)
if plugin_name is None:
return None
for hook_caller in self.get_hookcallers(plugin) or []:
for impl in hook_caller.get_hookimpls():
# Wrappers are generators that pluggy drives itself, leave them alone
if impl.plugin is plugin and not (impl.wrapper or impl.hookwrapper):
impl.function = _isolate_hookimpl(impl.function, plugin_name, hook_caller.name)
return plugin_name
# Set up Plugin Manager
plugin_manager = ChangeDetectionPluginManager(PLUGIN_NAMESPACE)
# Register hookspecs
plugin_manager.add_hookspecs(ChangeDetectionSpec)
# Load plugins from subdirectories
def load_plugins_from_directories():
# The built-in conditions operators (starts_with, length_min, ...), first so its choices lead the lists
from changedetectionio.conditions import default_plugin
if not plugin_manager.is_registered(default_plugin):
plugin_manager.register(default_plugin, 'conditions_default_plugin')
# List of (python_package_prefix, filesystem_path) pairs to scan for plugins.
# NOTE: processors/restock_diff/plugins is intentionally excluded here — those
# plugins are registered via register_builtin_restock_plugins() to avoid the
# circular import: restock_diff/__init__.py → model.Watch → content_fetchers → pluggy_interface.
plugin_dirs = [
(
'changedetectionio.conditions.plugins',
os.path.join(os.path.dirname(__file__), 'conditions', 'plugins'),
),
]
for module_prefix, dir_path in plugin_dirs:
if not os.path.exists(dir_path):
continue
# Get all Python files (excluding __init__.py)
for filename in os.listdir(dir_path):
if filename.endswith(".py") and filename != "__init__.py":
module_name = filename[:-3] # Remove .py extension
module_path = f"{module_prefix}.{module_name}"
try:
module = importlib.import_module(module_path)
# Register the plugin with pluggy
plugin_manager.register(module, module_name)
except (ImportError, AttributeError) as e:
print(f"Error loading plugin {module_name}: {e}")
# Load plugins
load_plugins_from_directories()
# Discover installed plugins from external packages (if any)
plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
# Conditions plugins published before the hook namespaces were merged
plugin_manager.load_setuptools_entrypoints(LEGACY_CONDITIONS_NAMESPACE)
# Function to inject datastore into plugins that need it
def inject_datastore_into_plugins(datastore):
"""Inject the global datastore into plugins that need access to settings.
This should be called after plugins are loaded and datastore is initialized.
Args:
datastore: The global ChangeDetectionStore instance
"""
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Check if plugin has datastore attribute and it's not set
if hasattr(plugin_obj, 'datastore'):
if plugin_obj.datastore is None:
plugin_obj.datastore = datastore
logger.debug(f"Injected datastore into plugin: {plugin_name}")
# Function to register built-in fetchers - called later from content_fetchers/__init__.py
def register_builtin_fetchers():
"""Register built-in content fetchers as internal plugins
This is called from content_fetchers/__init__.py after all fetchers are imported
to avoid circular import issues.
"""
from changedetectionio.content_fetchers import requests, playwright, puppeteer, webdriver_selenium
# Register each built-in fetcher plugin
if hasattr(requests, 'requests_plugin'):
plugin_manager.register(requests.requests_plugin, 'builtin_requests')
if hasattr(playwright, 'playwright_plugin'):
plugin_manager.register(playwright.playwright_plugin, 'builtin_playwright')
if hasattr(puppeteer, 'puppeteer_plugin'):
plugin_manager.register(puppeteer.puppeteer_plugin, 'builtin_puppeteer')
if hasattr(webdriver_selenium, 'webdriver_selenium_plugin'):
plugin_manager.register(webdriver_selenium.webdriver_selenium_plugin, 'builtin_webdriver_selenium')
def register_builtin_restock_plugins():
"""Register built-in restock processor plugins after all imports are complete.
Called from content_fetchers/__init__.py alongside register_builtin_fetchers()
to avoid the circular import that occurs when loading via load_plugins_from_directories()
(restock_diff/__init__.py → model.Watch → content_fetchers → pluggy_interface).
"""
import importlib
module_path = 'changedetectionio.processors.restock_diff.plugins.llm_restock'
try:
module = importlib.import_module(module_path)
if not plugin_manager.is_registered(module):
plugin_manager.register(module, 'llm_restock')
logger.debug("Registered built-in restock plugin: llm_restock")
except Exception as e:
logger.error(f"Failed to register llm_restock plugin: {e}")
# Helper function to collect UI stats extras from all plugins
def collect_ui_edit_stats_extras(watch):
"""Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
extras_content = []
# Get all plugins that implement the ui_edit_stats_extras hook
results = plugin_manager.hook.ui_edit_stats_extras(watch=watch)
# If we have results, add them to our content
if results:
for result in results:
if result: # Skip empty results
extras_content.append(result)
return "\n".join(extras_content) if extras_content else ""
def collect_fetcher_status_icons(fetcher_name):
"""Collect status icon data from all plugins
Args:
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
dict or None: Icon data dictionary from first matching plugin, or None
"""
# Get status icon data from plugins
results = plugin_manager.hook.fetcher_status_icon(fetcher_name=fetcher_name)
# Return first non-None result
if results:
for result in results:
if result and isinstance(result, dict):
return result
return None
def get_itemprop_availability_from_plugin(content, fetcher_name, fetcher_instance, url, llm_intent=None):
"""Get itemprop availability data from plugins as a fallback.
This is called when the built-in get_itemprop_availability doesn't find good data.
Args:
content: The HTML/text content to parse
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
fetcher_instance: The fetcher instance that generated the content
url: The URL being watched (watch.link - includes Jinja2 evaluation)
llm_intent: Optional user-supplied intent string passed through to plugins
Returns:
dict or None: Availability data dictionary from first matching plugin, or None
"""
hook = plugin_manager.hook.get_itemprop_availability_override
# html_to_text() isn't free on a large product page, so only run it when some
# implementation actually asks for `text` (pluggy only passes the args an impl declares).
text = None
if content and any('text' in impl.argnames for impl in hook.get_hookimpls()):
try:
from changedetectionio.html_tools import html_to_text
text = html_to_text(content)
except Exception as e:
logger.warning(f"Could not convert {url} to text for restock plugins: {e}")
# firstresult=True, so this is the first non-None answer
result = hook(
content=content,
fetcher_name=fetcher_name,
fetcher_instance=fetcher_instance,
url=url,
llm_intent=llm_intent,
text=text,
)
# Only accept it if it carries actual data
if result and isinstance(result, dict):
if result.get('price') is not None or result.get('availability'):
return result
return None
def get_active_plugins():
"""Get a list of active plugins with their descriptions.
Returns:
list: List of dictionaries with plugin information:
[
{'name': 'plugin_name', 'description': 'Plugin description'},
...
]
"""
active_plugins = []
# Get all registered plugins
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Skip built-in plugins (they start with 'builtin_')
if plugin_name.startswith('builtin_'):
continue
# Get plugin description if available
description = None
if hasattr(plugin_obj, '__doc__') and plugin_obj.__doc__:
description = plugin_obj.__doc__.strip().split('\n')[0] # First line only
elif hasattr(plugin_obj, 'description'):
description = plugin_obj.description
# Try to get a friendly name from the plugin
friendly_name = plugin_name
if hasattr(plugin_obj, 'name'):
friendly_name = plugin_obj.name
active_plugins.append({
'name': friendly_name,
'description': description or 'No description available'
})
return active_plugins
def get_fetcher_capabilities(watch, datastore):
"""Get capability flags for a watch's fetcher.
Args:
watch: The watch object/dict
datastore: The datastore to resolve 'system' fetcher
Returns:
dict: Dictionary with capability flags:
{
'supports_browser_steps': bool,
'supports_screenshots': bool,
'supports_xpath_element_data': bool
}
"""
# Get the fetcher name from watch
fetcher_name = watch.get('fetch_backend', 'system')
# Resolve 'system' to actual fetcher
if fetcher_name == 'system':
fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
# Get the fetcher class
from changedetectionio import content_fetchers
# Try built-in fetchers first, then plugin-provided fetchers
fetcher_class = None
if hasattr(content_fetchers, fetcher_name):
fetcher_class = getattr(content_fetchers, fetcher_name)
else:
# Query all plugins for registered fetchers
for fetcher_registration in plugin_manager.hook.register_content_fetcher():
if fetcher_registration and fetcher_registration[0] == fetcher_name:
fetcher_class = fetcher_registration[1]
break
# Log + build from the shared FetcherCapabilities model. Return a plain dict so
# callers (e.g. blueprint/ui/edit.py) can still add their own extra capability keys.
caps = content_fetchers._log_fetcher_capabilities(fetcher_class, fetcher_name, uuid=watch.get('uuid'))
return caps.model_dump()
def get_plugin_settings_tabs():
"""Get all plugin settings tabs.
Returns:
list: List of dictionaries with plugin settings tab information:
[
{
'plugin_id': str,
'tab_label': str,
'form_class': Form,
'description': str
},
...
]
"""
tabs = []
results = plugin_manager.hook.plugin_settings_tab()
for result in results:
if result and isinstance(result, dict):
# Validate required fields
if 'plugin_id' in result and 'tab_label' in result and 'form_class' in result:
tabs.append(result)
else:
logger.warning(f"Invalid plugin settings tab spec: {result}")
return tabs
def load_plugin_settings(datastore_path, plugin_id):
"""Load settings for a specific plugin from JSON file.
Args:
datastore_path: Path to the datastore directory
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
Returns:
dict: Plugin settings, or empty dict if file doesn't exist
"""
import json
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
if not os.path.exists(settings_file):
return {}
try:
with open(settings_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load settings for plugin '{plugin_id}': {e}")
return {}
def save_plugin_settings(datastore_path, plugin_id, settings):
"""Save settings for a specific plugin to JSON file.
Args:
datastore_path: Path to the datastore directory
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
settings: Dictionary of settings to save
Returns:
bool: True if save was successful, False otherwise
"""
import json
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
try:
with open(settings_file, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2, ensure_ascii=False)
logger.info(f"Saved settings for plugin '{plugin_id}' to {settings_file}")
return True
except Exception as e:
logger.error(f"Failed to save settings for plugin '{plugin_id}': {e}")
return False
def get_plugin_template_paths():
"""Get list of plugin template directories for Jinja2 loader.
Scans both external pluggy plugins and built-in processor plugins.
Returns:
list: List of absolute paths to plugin template directories
"""
template_paths = []
# Add the base processors/templates directory (as absolute path)
processors_templates_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'processors', 'templates')
if os.path.isdir(processors_templates_dir):
template_paths.append(processors_templates_dir)
logger.debug(f"Added base processors template path: {processors_templates_dir}")
# Scan built-in processor plugins
from changedetectionio.processors import find_processors
processor_list = find_processors()
for processor_module, processor_name in processor_list:
# Each processor is a module, check if it has a templates directory
if hasattr(processor_module, '__file__'):
processor_file = processor_module.__file__
if processor_file:
# Get the processor directory (e.g., processors/image_ssim_diff/)
processor_dir = os.path.dirname(os.path.abspath(processor_file))
templates_dir = os.path.join(processor_dir, 'templates')
if os.path.isdir(templates_dir):
template_paths.append(templates_dir)
logger.debug(f"Added processor template path: {templates_dir}")
# Get all registered external pluggy plugins
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
# Check if plugin has a templates directory
if hasattr(plugin_obj, '__file__'):
plugin_file = plugin_obj.__file__
elif hasattr(plugin_obj, '__module__'):
# Get the module file
module = sys.modules.get(plugin_obj.__module__)
if module and hasattr(module, '__file__'):
plugin_file = module.__file__
else:
continue
else:
continue
if plugin_file:
plugin_dir = os.path.dirname(os.path.abspath(plugin_file))
templates_dir = os.path.join(plugin_dir, 'templates')
if os.path.isdir(templates_dir):
template_paths.append(templates_dir)
logger.debug(f"Added plugin template path: {templates_dir}")
return template_paths
def apply_update_handler_alter(update_handler, watch, datastore):
"""Apply update_handler_alter hooks from all plugins.
Allows plugins to wrap or modify the update_handler before it processes a watch.
Multiple plugins can chain modifications - each plugin receives the result from
the previous plugin.
Args:
update_handler: The perform_site_check instance to potentially modify
watch: The watch dict being processed
datastore: The application datastore
Returns:
object: The (potentially modified/wrapped) update_handler
"""
# Get all plugins that implement the update_handler_alter hook
results = plugin_manager.hook.update_handler_alter(
update_handler=update_handler,
watch=watch,
datastore=datastore
)
# Chain results - each plugin gets the result from the previous one
current_handler = update_handler
if results:
for result in results:
if result is not None:
logger.debug(f"Plugin modified update_handler for watch {watch.get('uuid')}")
current_handler = result
return current_handler
def apply_update_finalize(update_handler, watch, datastore, processing_exception):
"""Apply update_finalize hooks from all plugins.
Called in the finally block after watch processing completes, allowing plugins
to perform cleanup, update metrics, or log final status.
Args:
update_handler: The perform_site_check instance (may be None)
watch: The watch dict that was processed (may be None)
datastore: The application datastore
processing_exception: The exception from processing, or None if successful
Returns:
None
"""
try:
# Call all plugins that implement the update_finalize hook
plugin_manager.hook.update_finalize(
update_handler=update_handler,
watch=watch,
datastore=datastore,
processing_exception=processing_exception
)
except Exception as e:
# Don't let plugin errors crash the worker
logger.error(f"Error in update_finalize hook: {e}")
logger.exception(f"update_finalize hook exception details:")
def collect_html_head_extras():
"""Collect and combine HTML head extras from all plugins.
Called from a Flask template global so it always runs inside a request context.
This means url_for() works correctly in plugin implementations, including when the
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
Returns:
str: Combined HTML string to inject inside , or empty string
"""
results = plugin_manager.hook.get_html_head_extras()
parts = [r for r in results if r]
return "\n".join(parts) if parts else ""