From 64191241a329b7ceab791646201f88a59dd3d84f Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Mon, 27 Jul 2026 21:38:20 +0100 Subject: [PATCH 1/3] fix(plugins): discovery must not instantiate an imported base class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspect.getmembers() returns members ALPHABETICALLY, and the name-based detection strategy takes the first class ending in 'Plugin'. Every plugin does 'from plugin_system import PredBatPlugin', so the abstract base sits in the module namespace — and any plugin class sorting after 'PredBatPlugin' gets the base instantiated instead of itself. The plugin then logs as loaded successfully and silently does nothing: no hooks registered, no behaviour. Whether a plugin works depends on the luck of its class name. Hit in the wild with a class named SocKeepPublishPlugin; ColdWeatherPlugin and CurtailmentPlugin sort before the base, so they were unaffected. Fix: require obj.__module__ == plugin_module.__name__, so only classes actually DEFINED in the plugin file are candidates. The two fallback strategies (PREDBAT_PLUGIN marker, initialize_plugin function) are unchanged. Test discovers a temp plugin whose class name sorts after PredBatPlugin and asserts the right class was instantiated. Verified it fails without the fix: ERROR: discovery instantiated PredBatPlugin, expected ZzSortsLastPlugin --- apps/predbat/plugin_system.py | 11 +++++- apps/predbat/tests/test_plugin_startup.py | 45 +++++++++++++++++++++++ apps/predbat/unit_test.py | 3 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/predbat/plugin_system.py b/apps/predbat/plugin_system.py index 64aeb986f..a13c46cdf 100644 --- a/apps/predbat/plugin_system.py +++ b/apps/predbat/plugin_system.py @@ -121,9 +121,16 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): # Plugin discovery fallthrough (try in order of preference): - # 1. MOST PREFERRED: Class name ends with "Plugin" + # 1. MOST PREFERRED: Class name ends with "Plugin" AND is defined in this + # module. inspect.getmembers() returns members ALPHABETICALLY, so without the + # __module__ check a plugin whose class sorts after an imported one silently + # gets the wrong class instantiated: `from plugin_system import PredBatPlugin` + # puts the base class in the namespace, so e.g. SocKeepPublishPlugin (sorting + # after PredBatPlugin) had the abstract base constructed instead — the plugin + # logged as loaded successfully and did nothing. Whether a plugin works should + # not depend on the alphabet. for name, obj in inspect.getmembers(plugin_module, inspect.isclass): - if name.endswith("Plugin"): + if name.endswith("Plugin") and obj.__module__ == plugin_module.__name__: try: self.log(f"Initialising plugin class (name-based): {name}") plugin_instance = obj(self.base) diff --git a/apps/predbat/tests/test_plugin_startup.py b/apps/predbat/tests/test_plugin_startup.py index 2393935b4..8df14e4e3 100644 --- a/apps/predbat/tests/test_plugin_startup.py +++ b/apps/predbat/tests/test_plugin_startup.py @@ -7,8 +7,12 @@ # pylint: disable=consider-using-f-string # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +import os +import shutil +import tempfile from unittest.mock import MagicMock, patch from predbat import PredBat +from plugin_system import PluginSystem def test_plugin_startup_order(my_predbat): @@ -143,3 +147,44 @@ def register_hooks(self, plugin_system): print(f"OK: Plugin successfully registered endpoint /test") return failed + + +def test_plugin_discovery_skips_imported_base_class(my_predbat): + """ + Discovery must instantiate the class DEFINED in the plugin file, not a class + it merely imports. + + inspect.getmembers() returns members alphabetically and the name-based strategy + takes the first class ending in "Plugin". Every plugin does + `from plugin_system import PredBatPlugin`, so any plugin class sorting AFTER + "PredBatPlugin" had the abstract base instantiated instead of itself — the + plugin logged as loaded successfully, registered no hooks, and did nothing. + """ + print("*** Running test: Plugin discovery skips imported base classes") + failed = 0 + + base = MagicMock() + base.log = MagicMock() + + tmpdir = tempfile.mkdtemp() + try: + # Class name deliberately sorts AFTER "PredBatPlugin". + with open(os.path.join(tmpdir, "zz_sorts_last_plugin.py"), "w", encoding="utf-8") as fh: + fh.write("from plugin_system import PredBatPlugin\n\n\nclass ZzSortsLastPlugin(PredBatPlugin):\n pass\n") + + plugin_system = PluginSystem(base) + plugin_system.discover_plugins([tmpdir]) + loaded = list(plugin_system.plugins.values()) + + if len(loaded) != 1: + print("ERROR: expected exactly one discovered plugin, got {}".format(len(loaded))) + failed = 1 + elif type(loaded[0]).__name__ != "ZzSortsLastPlugin": + print("ERROR: discovery instantiated {}, expected ZzSortsLastPlugin".format(type(loaded[0]).__name__)) + failed = 1 + else: + print("OK: discovery instantiated the plugin class, not the imported base") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 71fb3dfb7..1fe7ce963 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -71,7 +71,7 @@ from tests.test_futurerate_agilepredict import test_futurerate_agilepredict from tests.test_car_charging_smart import run_car_charging_smart_tests from tests.test_battery_accuracy import run_battery_accuracy_tests -from tests.test_plugin_startup import test_plugin_startup_order +from tests.test_plugin_startup import test_plugin_startup_order, test_plugin_discovery_skips_imported_base_class from tests.test_active_flag import test_active_flag from tests.test_time_loop_guard import test_time_loop_guard from tests.test_component_health_status import ( @@ -571,6 +571,7 @@ def main(): ("load_free_slot", test_load_free_slot, "Load free slot tests", False), ("octopus_slots_change", test_octopus_slots_change, "Octopus slots change-detection signature tests (in-progress re-clock vs genuine change)", False), ("plugin_startup", test_plugin_startup_order, "Plugin startup order tests", False), + ("plugin_discovery", test_plugin_discovery_skips_imported_base_class, "Plugin discovery skips imported base classes", False), ("active_flag", test_active_flag, "Active flag cleared on exception tests", False), ("time_loop_guard", test_time_loop_guard, "Run-loop guard reports a missing HA interface and sets fatal_error (#5135)", False), ("component_health_status", test_component_health_status, "Component errors fail the recorded run status tests", False), From 04041cf2dce1833e898a8d27d43efdf97224fdc2 Mon Sep 17 00:00:00 2001 From: Home Date: Wed, 26 Aug 2026 11:05:00 +0100 Subject: [PATCH 2/3] fix(plugins): guard the attribute-based fallback the same way Addresses review feedback on the name-based fix. PredBatPlugin sets PREDBAT_PLUGIN = True as its own discovery marker, so it matches strategy 2 in every plugin that does `from plugin_system import PredBatPlugin`. Guarding only strategy 1 left the identical bug reachable in the two cases where strategy 1 declines: the plugin class does not end in "Plugin", or it raised during __init__ and left plugin_instance None. Both end with the abstract base constructed, the plugin logged as loaded, and no hooks registered. Same __module__ check, same reason. Also replaces the redundant hasattr/getattr pair with a single getattr default. The new test needs three properties together, and getting any of them wrong hides the bug: the file must end "_plugin.py" or discovery never reads it; the class must not end in "Plugin" so strategy 1 declines; and the class must sort AFTER "PredBatPlugin", because inspect.getmembers() is alphabetical and strategy 2 breaks on its first match. A first draft named the class AttributeOnlyWorker, which sorts before the base and so passed with and without the fix. Verified failing without the guard ("fallback instantiated PredBatPlugin, expected ZzAttributeWorker") and passing with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LVDP1s2fmBMFmbHNAirnUt --- apps/predbat/plugin_system.py | 13 +++++- apps/predbat/tests/test_plugin_startup.py | 48 +++++++++++++++++++++++ apps/predbat/unit_test.py | 3 +- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/predbat/plugin_system.py b/apps/predbat/plugin_system.py index a13c46cdf..d9d43bf5b 100644 --- a/apps/predbat/plugin_system.py +++ b/apps/predbat/plugin_system.py @@ -138,10 +138,19 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): except Exception as e: self.log(f"Failed to initialise plugin class {name}: {e}") - # 2. FALLBACK: Class has PREDBAT_PLUGIN = True attribute + # 2. FALLBACK: Class has PREDBAT_PLUGIN = True attribute AND is defined in + # this module. The same __module__ guard as strategy 1, and for the same + # reason: PredBatPlugin itself sets PREDBAT_PLUGIN = True as its discovery + # marker, so every plugin that does `from plugin_system import PredBatPlugin` + # has a matching class sitting in its namespace. Without the guard this + # fallback re-introduces exactly the bug strategy 1 just fixed, and it is + # reachable in the two cases where strategy 1 declines: the plugin class does + # not end in "Plugin", or it raised during __init__ and left plugin_instance + # None. Both end with the abstract base constructed, the plugin logged as + # loaded, and nothing registered. if plugin_instance is None: for name, obj in inspect.getmembers(plugin_module, inspect.isclass): - if hasattr(obj, "PREDBAT_PLUGIN") and getattr(obj, "PREDBAT_PLUGIN"): + if getattr(obj, "PREDBAT_PLUGIN", False) and obj.__module__ == plugin_module.__name__: try: self.log(f"Initialising plugin class (attribute-based): {name}") plugin_instance = obj(self.base) diff --git a/apps/predbat/tests/test_plugin_startup.py b/apps/predbat/tests/test_plugin_startup.py index 8df14e4e3..f7233259b 100644 --- a/apps/predbat/tests/test_plugin_startup.py +++ b/apps/predbat/tests/test_plugin_startup.py @@ -188,3 +188,51 @@ def test_plugin_discovery_skips_imported_base_class(my_predbat): shutil.rmtree(tmpdir, ignore_errors=True) return failed + + +def test_plugin_discovery_fallback_skips_imported_base_class(my_predbat): + """ + The SAME guard must hold on the attribute-based fallback (strategy 2). + + PredBatPlugin sets PREDBAT_PLUGIN = True as its own discovery marker, so it + matches strategy 2 in every plugin that imports it. Guarding only the + name-based strategy leaves the identical bug reachable whenever strategy 1 + declines — here the plugin class does not end in "Plugin", so discovery falls + straight through to the fallback and, unguarded, constructs the abstract base: + plugin logged as loaded, no hooks registered, silently does nothing. + """ + print("*** Running test: Plugin discovery fallback skips imported base classes") + failed = 0 + + base = MagicMock() + base.log = MagicMock() + + tmpdir = tempfile.mkdtemp() + try: + # Three properties here are all load-bearing: + # - the FILE ends "_plugin.py", or discover_plugins never reads it; + # - the CLASS does not end in "Plugin", so strategy 1 finds nothing and + # discovery falls through to the PREDBAT_PLUGIN attribute match; + # - the CLASS sorts AFTER "PredBatPlugin", because inspect.getmembers() + # is alphabetical and strategy 2 breaks on its first match. A name + # sorting before the base hides the bug entirely - which is how the + # first draft of this test passed with and without the fix. + with open(os.path.join(tmpdir, "attribute_only_plugin.py"), "w", encoding="utf-8") as fh: + fh.write("from plugin_system import PredBatPlugin\n\n\nclass ZzAttributeWorker(PredBatPlugin):\n pass\n") + + plugin_system = PluginSystem(base) + plugin_system.discover_plugins([tmpdir]) + loaded = list(plugin_system.plugins.values()) + + if len(loaded) != 1: + print("ERROR: expected exactly one discovered plugin, got {}".format(len(loaded))) + failed = 1 + elif type(loaded[0]).__name__ != "ZzAttributeWorker": + print("ERROR: fallback instantiated {}, expected ZzAttributeWorker".format(type(loaded[0]).__name__)) + failed = 1 + else: + print("OK: attribute-based fallback instantiated the plugin class, not the imported base") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 1fe7ce963..8d60e0587 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -71,7 +71,7 @@ from tests.test_futurerate_agilepredict import test_futurerate_agilepredict from tests.test_car_charging_smart import run_car_charging_smart_tests from tests.test_battery_accuracy import run_battery_accuracy_tests -from tests.test_plugin_startup import test_plugin_startup_order, test_plugin_discovery_skips_imported_base_class +from tests.test_plugin_startup import test_plugin_startup_order, test_plugin_discovery_skips_imported_base_class, test_plugin_discovery_fallback_skips_imported_base_class from tests.test_active_flag import test_active_flag from tests.test_time_loop_guard import test_time_loop_guard from tests.test_component_health_status import ( @@ -572,6 +572,7 @@ def main(): ("octopus_slots_change", test_octopus_slots_change, "Octopus slots change-detection signature tests (in-progress re-clock vs genuine change)", False), ("plugin_startup", test_plugin_startup_order, "Plugin startup order tests", False), ("plugin_discovery", test_plugin_discovery_skips_imported_base_class, "Plugin discovery skips imported base classes", False), + ("plugin_discovery_fallback", test_plugin_discovery_fallback_skips_imported_base_class, "Plugin discovery fallback skips imported base classes", False), ("active_flag", test_active_flag, "Active flag cleared on exception tests", False), ("time_loop_guard", test_time_loop_guard, "Run-loop guard reports a missing HA interface and sets fatal_error (#5135)", False), ("component_health_status", test_component_health_status, "Component errors fail the recorded run status tests", False), From a0e8f169234ecf7360fbd5f4ce312ea8b74fce5f Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Wed, 28 Oct 2026 19:05:24 +0000 Subject: [PATCH 3/3] fix(plugins): don't retry a failed class; name skipped re-exports Review feedback on the discovery guard: - Strategy 2 skipped nothing strategy 1 had already tried, so a "Plugin" class whose __init__ raised was constructed twice (it inherits the PREDBAT_PLUGIN marker). Track attempted classes and skip them. - The __module__ guard means a class re-exported into the _plugin.py file from a helper module is not loaded. Keep the guard (any imported class winning is the alphabet-dependent bug), but log which classes were skipped and where they came from, and document the rule in docs/plugins.md. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/plugin_system.py | 17 ++++- apps/predbat/tests/test_plugin_startup.py | 85 +++++++++++++++++++++++ apps/predbat/unit_test.py | 10 ++- docs/plugins.md | 1 + 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/apps/predbat/plugin_system.py b/apps/predbat/plugin_system.py index d9d43bf5b..cb358c54d 100644 --- a/apps/predbat/plugin_system.py +++ b/apps/predbat/plugin_system.py @@ -118,6 +118,7 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): # Look for plugin classes or initialisation functions plugin_instance = None + attempted = set() # Plugin discovery fallthrough (try in order of preference): @@ -131,6 +132,7 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): # not depend on the alphabet. for name, obj in inspect.getmembers(plugin_module, inspect.isclass): if name.endswith("Plugin") and obj.__module__ == plugin_module.__name__: + attempted.add(obj) try: self.log(f"Initialising plugin class (name-based): {name}") plugin_instance = obj(self.base) @@ -148,9 +150,12 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): # not end in "Plugin", or it raised during __init__ and left plugin_instance # None. Both end with the abstract base constructed, the plugin logged as # loaded, and nothing registered. + # Classes strategy 1 already tried are skipped: a "Plugin" class inherits the + # marker, so without this one that raised in __init__ is constructed twice and + # any side effect before the raise happens twice. if plugin_instance is None: for name, obj in inspect.getmembers(plugin_module, inspect.isclass): - if getattr(obj, "PREDBAT_PLUGIN", False) and obj.__module__ == plugin_module.__name__: + if getattr(obj, "PREDBAT_PLUGIN", False) and obj.__module__ == plugin_module.__name__ and obj not in attempted: try: self.log(f"Initialising plugin class (attribute-based): {name}") plugin_instance = obj(self.base) @@ -178,6 +183,16 @@ def load_plugin(self, plugin_dir: str, plugin_name: str): self.log(f"Failed to register hooks for plugin {plugin_name}: {e}") else: self.log(f"No plugin class or initialisation function found in {plugin_name}") + # A plugin class must be DEFINED in the _plugin.py file; one imported from + # another module is skipped. Name any that were, so a re-exported class is + # diagnosable from the log rather than silently not loading. + imported = [ + f"{name} (from {obj.__module__})" + for name, obj in inspect.getmembers(plugin_module, inspect.isclass) + if (name.endswith("Plugin") or getattr(obj, "PREDBAT_PLUGIN", False)) and obj.__module__ not in (plugin_module.__name__, PredBatPlugin.__module__) + ] + if imported: + self.log(f"Plugin classes in {plugin_name} that are imported, not defined there, are not loaded: {', '.join(imported)}") def get_plugin(self, plugin_name: str): """ diff --git a/apps/predbat/tests/test_plugin_startup.py b/apps/predbat/tests/test_plugin_startup.py index f7233259b..2c2f0f3d4 100644 --- a/apps/predbat/tests/test_plugin_startup.py +++ b/apps/predbat/tests/test_plugin_startup.py @@ -9,6 +9,7 @@ # pylint: disable=attribute-defined-outside-init import os import shutil +import sys import tempfile from unittest.mock import MagicMock, patch from predbat import PredBat @@ -236,3 +237,87 @@ def test_plugin_discovery_fallback_skips_imported_base_class(my_predbat): shutil.rmtree(tmpdir, ignore_errors=True) return failed + + +def test_plugin_discovery_does_not_retry_a_failed_class(my_predbat): + """ + A plugin class that raises in __init__ must be constructed ONCE, not twice. + + The class ends in "Plugin" and inherits PREDBAT_PLUGIN from the base, so it + matches both strategy 1 and strategy 2. Strategy 1 tries it and it raises; + unless strategy 2 skips classes strategy 1 already attempted, it constructs + the same class again, and any side effect before the raising line (a hook + registered, state written) happens twice. + """ + print("*** Running test: Plugin discovery does not retry a class that failed") + failed = 0 + + base = MagicMock() + base.log = MagicMock() + + tmpdir = tempfile.mkdtemp() + try: + with open(os.path.join(tmpdir, "zz_raises_plugin.py"), "w", encoding="utf-8") as fh: + fh.write("from plugin_system import PredBatPlugin\n\n\nclass ZzRaisesPlugin(PredBatPlugin):\n def __init__(self, base):\n raise ValueError('bad config')\n") + + plugin_system = PluginSystem(base) + plugin_system.discover_plugins([tmpdir]) + attempts = [c for c in base.log.call_args_list if "Initialising plugin class" in str(c) and "ZzRaisesPlugin" in str(c)] + + if len(attempts) != 1: + print("ERROR: ZzRaisesPlugin was constructed {} times, expected 1".format(len(attempts))) + failed = 1 + elif plugin_system.plugins: + print("ERROR: expected no plugin loaded, got {}".format(list(plugin_system.plugins))) + failed = 1 + else: + print("OK: the failing plugin class was constructed once and not retried") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + return failed + + +def test_plugin_discovery_names_a_reexported_class(my_predbat): + """ + A plugin class imported into the _plugin.py file, rather than defined there, is + not loaded, and the log must say which class was skipped and where it came from. + + Discovery only takes classes defined in the plugin file, so a layout that keeps + the class in a helper module and re-exports it does not load. Without naming + the skipped class, the only log line is "No plugin class ... found", which + points nowhere. + """ + print("*** Running test: Plugin discovery names a re-exported plugin class") + failed = 0 + + base = MagicMock() + base.log = MagicMock() + + tmpdir = tempfile.mkdtemp() + sys.path.insert(0, tmpdir) + try: + with open(os.path.join(tmpdir, "zz_reexport_core.py"), "w", encoding="utf-8") as fh: + fh.write("from plugin_system import PredBatPlugin\n\n\nclass ZzReexportPlugin(PredBatPlugin):\n pass\n") + with open(os.path.join(tmpdir, "zz_reexport_plugin.py"), "w", encoding="utf-8") as fh: + fh.write("from plugin_system import PredBatPlugin\nfrom zz_reexport_core import ZzReexportPlugin\n") + + plugin_system = PluginSystem(base) + plugin_system.discover_plugins([tmpdir]) + messages = [str(c.args[0]) for c in base.log.call_args_list if c.args] + expected = "Plugin classes in zz_reexport_plugin that are imported, not defined there, are not loaded: ZzReexportPlugin (from zz_reexport_core)" + + if plugin_system.plugins: + print("ERROR: expected no plugin loaded, got {}".format(list(plugin_system.plugins))) + failed = 1 + elif expected not in messages: + print("ERROR: expected log line not found: {}".format(expected)) + failed = 1 + else: + print("OK: the skipped re-exported class is named in the log") + finally: + sys.path.remove(tmpdir) + sys.modules.pop("zz_reexport_core", None) + shutil.rmtree(tmpdir, ignore_errors=True) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 8d60e0587..8192fca05 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -71,7 +71,13 @@ from tests.test_futurerate_agilepredict import test_futurerate_agilepredict from tests.test_car_charging_smart import run_car_charging_smart_tests from tests.test_battery_accuracy import run_battery_accuracy_tests -from tests.test_plugin_startup import test_plugin_startup_order, test_plugin_discovery_skips_imported_base_class, test_plugin_discovery_fallback_skips_imported_base_class +from tests.test_plugin_startup import ( + test_plugin_startup_order, + test_plugin_discovery_skips_imported_base_class, + test_plugin_discovery_fallback_skips_imported_base_class, + test_plugin_discovery_does_not_retry_a_failed_class, + test_plugin_discovery_names_a_reexported_class, +) from tests.test_active_flag import test_active_flag from tests.test_time_loop_guard import test_time_loop_guard from tests.test_component_health_status import ( @@ -573,6 +579,8 @@ def main(): ("plugin_startup", test_plugin_startup_order, "Plugin startup order tests", False), ("plugin_discovery", test_plugin_discovery_skips_imported_base_class, "Plugin discovery skips imported base classes", False), ("plugin_discovery_fallback", test_plugin_discovery_fallback_skips_imported_base_class, "Plugin discovery fallback skips imported base classes", False), + ("plugin_discovery_no_retry", test_plugin_discovery_does_not_retry_a_failed_class, "Plugin discovery does not retry a class that failed to initialise", False), + ("plugin_discovery_reexport", test_plugin_discovery_names_a_reexported_class, "Plugin discovery names a re-exported plugin class it skips", False), ("active_flag", test_active_flag, "Active flag cleared on exception tests", False), ("time_loop_guard", test_time_loop_guard, "Run-loop guard reports a missing HA interface and sets fatal_error (#5135)", False), ("component_health_status", test_component_health_status, "Component errors fail the recorded run status tests", False), diff --git a/docs/plugins.md b/docs/plugins.md index 8a0002141..c9459b818 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -24,6 +24,7 @@ - Plugin files must end with `_plugin.py` for auto-discovery - Plugins can be simple classes, inherit from `PredBatPlugin`, or use initialization functions +- A plugin class must be defined in the `_plugin.py` file itself. Classes imported from another module, including `PredBatPlugin`, are never instantiated. To keep the implementation in a helper module, define a thin subclass in the `_plugin.py` file or use `initialize_plugin()` - Web endpoints can be registered by plugins for custom interfaces (e.g., metrics endpoints) - Full error handling and logging throughout the plugin lifecycle - No breaking changes to existing functionality