From 9baa61597055bf09748dba4e27ad744fefff1aa4 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:28:34 +0000 Subject: [PATCH 01/64] Increment Version to 0.8.5 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 3a22553d..89af9714 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -2,7 +2,7 @@ VERSION_MAJOR = 0 VERSION_MINOR = 8 VERSION_BUILD = 5 -VERSION_ALPHA = 4 +VERSION_ALPHA = 0 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 685ea9e2ab2b34ce8aed6bbcbc6882396b8834b7 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 18 May 2026 21:19:50 +0100 Subject: [PATCH 02/64] feat: AsyncFakeBus alongside FakeBus (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: AsyncFakeBus alongside FakeBus Adds an asyncio-native sibling to FakeBus that mirrors the surface of ovos_bus_client.client.AsyncMessageBusClient (the [async] extra shipped in ovos-bus-client 2.0). ovos_utils.fakebus.AsyncFakeBus - connect/close/emit/wait_for_message/wait_for_response: coroutines - on/once/remove/remove_all_listeners: synchronous (matches the real AsyncMessageBusClient's handler-registration contract — pyee, no awaitable callbacks) - Same session-context injection side effects as FakeBus, so multi-turn flows behave identically - Lazy-imports from ovos_bus_client.session when present; gracefully degrades without it (same pattern as FakeBus) - Backwards-compat shims (create_client, run_forever, run_in_thread) so it is a drop-in replacement anywhere FakeBus is currently used Where this pays off - Tests of code that calls 'await bus.emit(...)' or 'await bus.wait_for_response(...)' (test_fakebus's sync equivalents cover that surface for the old client; this covers the new one). - Runtime: in-process bus stand-in for asyncio-native components that do not need a real ovos-core (mirrors how FakeBus is used today by HiveMessageBusClient.connect(bus=FakeBus())). Tests (test/unittests/test_async_fakebus.py, 19 tests) - Lifecycle (construct/connect/close, session_id from kwarg) - Handler registration (on/once/remove/remove_all_listeners + dispatch) - emit side effects (session injection, raw 'message' event) - wait_for_message (matched concurrently, timeout) - wait_for_response (default .response, explicit reply_type, timeout) - Backwards-compat shims No changes to existing FakeBus; all 23 existing FakeBus tests still pass. * docs: add AsyncFakeBus to fakebus documentation Documents the new AsyncFakeBus class alongside FakeBus in docs/fakebus.md: coroutine/sync split table, key-methods table with file:line citations, session-handling note, and a minimal usage snippet. Updated docs/index.md module table and Contents list to include AsyncFakeBus. AI-Generated Change: - Model: claude-sonnet-4-6 - Intent: keep docs accurate after feat: AsyncFakeBus alongside FakeBus (14ccc3d) - Impact: updated docs/fakebus.md, docs/index.md; added citations; no stale content removed - Verified via: manual review against ovos_utils/fakebus.py --- docs/fakebus.md | 59 ++++++++ docs/index.md | 4 +- ovos_utils/fakebus.py | 209 ++++++++++++++++++++++++++- test/unittests/test_async_fakebus.py | 202 ++++++++++++++++++++++++++ 4 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 test/unittests/test_async_fakebus.py diff --git a/docs/fakebus.md b/docs/fakebus.md index 49e863c9..037d770f 100644 --- a/docs/fakebus.md +++ b/docs/fakebus.md @@ -52,6 +52,65 @@ bus.emit(FakeMessage("recognizer_loop:utterance", {"utterances": ["hello"]})) | `close()` | Calls `on_close()` | | `create_client()` | Returns `self` | +For asyncio-native code, see [`AsyncFakeBus`](#asyncfakebus) below. + +--- + +## `AsyncFakeBus` + +`AsyncFakeBus` — `ovos_utils/fakebus.py:351` + +In-process stand-in for `AsyncMessageBusClient` (from `ovos-bus-client`). Use this when your code is asyncio-native and needs a drop-in fake bus without a WebSocket connection. The API surface mirrors the real async client: coroutine methods keep you inside the event loop, while handler registration stays synchronous to match `pyee` and the real client's contract. + +```python +import asyncio +from ovos_utils.fakebus import AsyncFakeBus, FakeMessage + +async def main(): + bus = AsyncFakeBus() + + received = [] + + def on_ping(message): + received.append(message) + + bus.on("test:ping", on_ping) + + await bus.emit(FakeMessage("test:ping", {"n": 1})) + print(received) # [FakeMessage("test:ping", ...)] + + await bus.close() + +asyncio.run(main()) +``` + +### Coroutine vs sync split + +| Sync (handler registration) | Async (I/O surface) | +|---|---| +| `on(msg_type, handler)` | `connect(*args, **kwargs)` | +| `once(msg_type, handler)` | `close()` | +| `remove(msg_type, handler)` | `emit(message)` | +| `remove_all_listeners(event_name)` | `wait_for_message(message_type, timeout)` | +| | `wait_for_response(message, reply_type, timeout)` | + +### Key Methods + +| Method | Description | Source | +|---|---|---| +| `connect()` | No-op; sets `connected_event` and `started_running = True` | `fakebus.py:409` | +| `close()` | Clears `connected_event`, calls `on_close()` | `fakebus.py:418` | +| `emit(message)` | Injects session, dispatches to `pyee` emitter | `fakebus.py:426` | +| `wait_for_message(message_type, timeout)` | Awaits a single message of that type | `fakebus.py:489` | +| `wait_for_response(message, reply_type, timeout)` | Emits a message and awaits the reply | `fakebus.py:513` | +| `create_client()` | Returns `self` (backwards-compat shim) | `fakebus.py:543` | +| `run_forever()` | Sets `started_running = True` (backwards-compat shim) | `fakebus.py:546` | +| `run_in_thread()` | Calls `run_forever()` (backwards-compat shim) | `fakebus.py:549` | + +### Session Handling + +Session injection side effects are identical to `FakeBus`: `emit()` populates `message.context["session"]` from `SessionManager`, and `on_message()` feeds incoming messages back through `Session.from_message()` / `SessionManager.update()`. Both imports are lazy so the class works without `ovos-bus-client` installed. + --- ## `FakeMessage` diff --git a/docs/index.md b/docs/index.md index 1df0d780..2358430f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ Shared utility library used by all OVOS components. Provides logging, process li |---|---| | `ovos_utils.log` | `LOG` — OVOS-wide logging class with optional file rotation | | `ovos_utils.process_utils` | `ProcessStatus`, `RuntimeRequirements`, `PIDLock`, `MonotonicEvent` | -| `ovos_utils.fakebus` | `FakeBus`, `FakeMessage` — in-process bus for testing without a live WebSocket | +| `ovos_utils.fakebus` | `FakeBus`, `AsyncFakeBus`, `FakeMessage` — in-process bus for testing without a live WebSocket | | `ovos_utils.events` | `EventContainer`, `EventSchedulerInterface`, handler wrappers | | `ovos_utils.file_utils` | Resource resolution, vocab loading, `FileWatcher` | | `ovos_utils.network_utils` | `get_ip()`, `is_connected_dns()`, `is_connected_http()`, `check_captive_portal()` | @@ -60,6 +60,6 @@ pip install ovos-utils - [Logging](log.md) — `LOG`, `init_service_logger()`, `log_deprecation()`, `deprecated` decorator - [Process Utilities](process-utils.md) — `ProcessStatus`, `RuntimeRequirements`, `PIDLock`, `MonotonicEvent` -- [FakeBus](fakebus.md) — `FakeBus`, `FakeMessage` — in-process message bus for testing +- [FakeBus](fakebus.md) — `FakeBus`, `AsyncFakeBus`, `FakeMessage` — in-process message bus for testing - [Events](events.md) — `EventContainer`, `EventSchedulerInterface`, handler wrappers - [Utilities](utilities.md) — file, network, sound, threading, XDG helpers diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index c0287274..2ff6586d 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -1,7 +1,9 @@ +import asyncio import json +import warnings from copy import deepcopy from threading import Event -import warnings + from ovos_utils.log import LOG, log_deprecation from pyee import EventEmitter @@ -344,3 +346,208 @@ def __new__(cls, *args, **kwargs): log_deprecation( "please import from ovos-bus-client directly! this import has been deprecated since version 0.1.0", "1.0.0") return FakeMessage(*args, **kwargs) + + +class AsyncFakeBus: + """In-process stand-in for ``AsyncMessageBusClient``. + + Mirrors the same surface as the real async bus client: ``connect`` / + ``close`` / ``emit`` / ``wait_for_message`` / ``wait_for_response`` are + coroutines; ``on`` / ``once`` / ``remove`` stay synchronous. + + No WebSocket, no thread, no real I/O — every emit dispatches + synchronously through a ``pyee.EventEmitter`` to whatever handlers + are registered. + + Useful both in tests (drop-in for ``AsyncMessageBusClient``) and at + runtime (anywhere a sync component expects the legacy ``FakeBus`` but + the surrounding code is asyncio-native). + + The session-injection side effects match ``FakeBus`` so multi-turn + flows behave identically. + """ + + def __init__(self, *args, **kwargs): + self.started_running = False + self.session_id = "default" + self.ee = kwargs.get("emitter") or EventEmitter() + self.ee.on("error", self.on_error) + self.connected_event = asyncio.Event() + self.connected_event.set() + self.on_open() + try: + self.session_id = kwargs["session"].session_id + except Exception: + pass # don't care + + self.on("ovos.session.update_default", + self.on_default_session_update) + + # ------------------------------------------------------------------ + # Handler registration (sync — matches AsyncMessageBusClient) + # ------------------------------------------------------------------ + + def on(self, msg_type, handler): + self.ee.on(msg_type, handler) + + def once(self, msg_type, handler): + self.ee.once(msg_type, handler) + + def remove(self, msg_type, handler): + try: + self.ee.remove_listener(msg_type, handler) + except Exception: + pass + + def remove_all_listeners(self, event_name): + self.ee.remove_all_listeners(event_name) + + # ------------------------------------------------------------------ + # Lifecycle (async) + # ------------------------------------------------------------------ + + async def connect(self, *args, **kwargs): + """No-op for the fake bus; matches the real client's lifecycle. + + Returns immediately with ``connected_event`` set. + """ + self.started_running = True + self.connected_event.set() + return self + + async def close(self): + self.connected_event.clear() + self.on_close() + + # ------------------------------------------------------------------ + # emit (async) — same dispatch shape as FakeBus.emit + # ------------------------------------------------------------------ + + async def emit(self, message): + if "session" not in message.context: + try: # replicate side effects + from ovos_bus_client.session import Session, SessionManager + sess = SessionManager.sessions.get(self.session_id) or \ + Session(self.session_id) + message.context["session"] = sess.serialize() + except ImportError: # don't care + message.context["session"] = {"session_id": self.session_id} + self.ee.emit("message", message.serialize()) + try: + self.ee.emit(message.msg_type, message) + except Exception as e: + LOG.exception(f"Error in event handler for '{message.msg_type}': {e}") + self.on_message(message.serialize()) + + # ------------------------------------------------------------------ + # Sync helpers used internally — same as FakeBus + # ------------------------------------------------------------------ + + def on_message(self, *args): + """Handle an incoming websocket message. + + @param args: + message (str): serialized Message + """ + if len(args) == 1: + message = args[0] + else: + message = args[1] + parsed_message = FakeMessage.deserialize(message) + try: # replicate side effects + from ovos_bus_client.session import Session, SessionManager + sess = Session.from_message(parsed_message) + if sess.session_id != "default": + # 'default' can only be updated by core + SessionManager.update(sess) + except ImportError: + pass # don't care + + def on_default_session_update(self, message): + try: # replicate side effects + from ovos_bus_client.session import Session, SessionManager + new_session = message.data["session_data"] + sess = Session.deserialize(new_session) + SessionManager.update(sess, make_default=True) + LOG.debug("synced default_session") + except ImportError: + pass # don't care + + def on_error(self, error): + LOG.error(error) + + def on_open(self): + pass + + def on_close(self): + pass + + # ------------------------------------------------------------------ + # Waiters (async) + # ------------------------------------------------------------------ + + async def wait_for_message(self, message_type, timeout=3.0): + """Wait for a message of a specific type. + + Arguments: + message_type (str): the message type of the expected message + timeout: seconds to wait before timeout, defaults to 3 + + Returns: + The received message or None if the response timed out + """ + evt = asyncio.Event() + captured = {"msg": None} + + def _rcv(m): + captured["msg"] = m + evt.set() + + self.ee.once(message_type, _rcv) + try: + await asyncio.wait_for(evt.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + return captured["msg"] + + async def wait_for_response(self, message, reply_type=None, timeout=3.0): + """Send a message and wait for a response. + + Arguments: + message (Message): message to send + reply_type (str): the message type of the expected reply. + Defaults to ".response". + timeout: seconds to wait before timeout, defaults to 3 + + Returns: + The received message or None if the response timed out + """ + reply_type = reply_type or message.msg_type + ".response" + evt = asyncio.Event() + captured = {"msg": None} + + def _rcv(m): + captured["msg"] = m + evt.set() + + self.ee.once(reply_type, _rcv) + await self.emit(message) + try: + await asyncio.wait_for(evt.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + return captured["msg"] + + # ------------------------------------------------------------------ + # Backwards-compat passthroughs so AsyncFakeBus is a drop-in even for + # code paths that still call the threading-era helpers. + # ------------------------------------------------------------------ + + def create_client(self): + return self + + def run_forever(self): + self.started_running = True + + def run_in_thread(self): + self.run_forever() diff --git a/test/unittests/test_async_fakebus.py b/test/unittests/test_async_fakebus.py new file mode 100644 index 00000000..e9b5f9e4 --- /dev/null +++ b/test/unittests/test_async_fakebus.py @@ -0,0 +1,202 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +"""Tests for AsyncFakeBus. + +Mirrors test_fakebus.py shape but exercises the async surface +(connect / close / emit / wait_for_message / wait_for_response) plus +the sync handler-registration contract that matches +AsyncMessageBusClient. +""" +import asyncio +import unittest + +from ovos_utils.fakebus import AsyncFakeBus, FakeMessage + + +def _run(coro): + """Tiny helper so we can use plain unittest.TestCase.""" + return asyncio.run(coro) + + +class TestAsyncFakeBusLifecycle(unittest.TestCase): + def test_constructs_connected(self): + bus = AsyncFakeBus() + self.assertTrue(bus.connected_event.is_set()) + + def test_session_id_from_kwargs(self): + class _Sess: + session_id = "from-kwarg" + bus = AsyncFakeBus(session=_Sess()) + self.assertEqual(bus.session_id, "from-kwarg") + + def test_connect_is_noop_but_sets_event(self): + bus = AsyncFakeBus() + bus.connected_event.clear() + _run(bus.connect()) + self.assertTrue(bus.connected_event.is_set()) + self.assertTrue(bus.started_running) + + def test_close_clears_connected_event(self): + bus = AsyncFakeBus() + self.assertTrue(bus.connected_event.is_set()) + _run(bus.close()) + self.assertFalse(bus.connected_event.is_set()) + + +class TestAsyncFakeBusHandlerRegistration(unittest.TestCase): + def test_on_then_emit_dispatches(self): + bus = AsyncFakeBus() + seen = [] + bus.on("hello", lambda m: seen.append(m)) + _run(bus.emit(FakeMessage("hello", {"x": 1}))) + self.assertEqual(len(seen), 1) + self.assertEqual(seen[0].msg_type, "hello") + self.assertEqual(seen[0].data["x"], 1) + + def test_once_fires_only_once(self): + bus = AsyncFakeBus() + seen = [] + bus.once("evt", lambda m: seen.append(m)) + _run(bus.emit(FakeMessage("evt"))) + _run(bus.emit(FakeMessage("evt"))) + self.assertEqual(len(seen), 1) + + def test_remove_handler(self): + bus = AsyncFakeBus() + seen = [] + + def cb(m): + seen.append(m) + + bus.on("evt", cb) + bus.remove("evt", cb) + _run(bus.emit(FakeMessage("evt"))) + self.assertEqual(seen, []) + + def test_remove_all_listeners(self): + bus = AsyncFakeBus() + bus.on("evt", lambda m: None) + bus.on("evt", lambda m: None) + bus.remove_all_listeners("evt") + self.assertEqual(bus.ee.listeners("evt"), []) + + def test_remove_unknown_handler_does_not_raise(self): + bus = AsyncFakeBus() + # not registered → silent + bus.remove("evt", lambda m: None) + + +class TestAsyncFakeBusEmit(unittest.TestCase): + def test_emit_injects_session_context_when_missing(self): + bus = AsyncFakeBus() + msg = FakeMessage("hello", {}) + self.assertNotIn("session", msg.context) + _run(bus.emit(msg)) + self.assertIn("session", msg.context) + + def test_emit_dispatches_raw_message_event(self): + bus = AsyncFakeBus() + raws = [] + bus.on("message", lambda raw: raws.append(raw)) + _run(bus.emit(FakeMessage("hello"))) + self.assertEqual(len(raws), 1) + self.assertIn("hello", raws[0]) + + +class TestAsyncFakeBusWaitForMessage(unittest.TestCase): + def test_returns_matched_message_emitted_concurrently(self): + bus = AsyncFakeBus() + + async def scenario(): + async def feed(): + await asyncio.sleep(0.02) + await bus.emit(FakeMessage("ping", {"flood_id": "x"})) + asyncio.create_task(feed()) + got = await bus.wait_for_message("ping", timeout=1.0) + return got + + got = _run(scenario()) + self.assertIsNotNone(got) + self.assertEqual(got.msg_type, "ping") + + def test_returns_none_on_timeout(self): + bus = AsyncFakeBus() + + async def scenario(): + return await bus.wait_for_message("never", timeout=0.05) + + self.assertIsNone(_run(scenario())) + + +class TestAsyncFakeBusWaitForResponse(unittest.TestCase): + def test_default_reply_type_is_msg_type_response(self): + bus = AsyncFakeBus() + + async def scenario(): + # echo the request as .response when the request arrives + def echo(m): + # synchronous dispatch — fire the reply inline + # cannot await here; schedule on the loop instead + asyncio.create_task( + bus.emit(FakeMessage(m.msg_type + ".response", + {"echoed": m.data}))) + bus.on("ask", echo) + return await bus.wait_for_response( + FakeMessage("ask", {"q": 1}), timeout=1.0, + ) + + reply = _run(scenario()) + self.assertIsNotNone(reply) + self.assertEqual(reply.msg_type, "ask.response") + self.assertEqual(reply.data["echoed"], {"q": 1}) + + def test_explicit_reply_type(self): + bus = AsyncFakeBus() + + async def scenario(): + def respond(m): + asyncio.create_task(bus.emit(FakeMessage("pong"))) + bus.on("ping", respond) + return await bus.wait_for_response( + FakeMessage("ping"), reply_type="pong", timeout=1.0, + ) + + reply = _run(scenario()) + self.assertIsNotNone(reply) + self.assertEqual(reply.msg_type, "pong") + + def test_returns_none_on_timeout(self): + bus = AsyncFakeBus() + + async def scenario(): + return await bus.wait_for_response( + FakeMessage("never"), timeout=0.05, + ) + + self.assertIsNone(_run(scenario())) + + +class TestAsyncFakeBusCompatShims(unittest.TestCase): + def test_create_client_returns_self(self): + bus = AsyncFakeBus() + self.assertIs(bus.create_client(), bus) + + def test_run_forever_flips_started_running(self): + bus = AsyncFakeBus() + bus.started_running = False + bus.run_forever() + self.assertTrue(bus.started_running) + + def test_run_in_thread_alias(self): + bus = AsyncFakeBus() + bus.started_running = False + bus.run_in_thread() + self.assertTrue(bus.started_running) + + +if __name__ == "__main__": + unittest.main() From 04f505070b02ad080e67b4dbb6c80f6835c4c37f Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 18 May 2026 20:20:01 +0000 Subject: [PATCH 03/64] Increment Version to 0.9.0a1 --- ovos_utils/version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 89af9714..d5785018 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 8 -VERSION_BUILD = 5 -VERSION_ALPHA = 0 +VERSION_MINOR = 9 +VERSION_BUILD = 0 +VERSION_ALPHA = 1 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 90e48d63a2034768c52f3fbc850b461bc70dc943 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 18 May 2026 20:20:30 +0000 Subject: [PATCH 04/64] Update Changelog --- CHANGELOG.md | 48 +++--------------------------------------------- 1 file changed, 3 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7c503e..2c570abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,54 +1,12 @@ # Changelog -## [0.8.5a4](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5a4) (2026-03-11) +## [0.9.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.9.0a1) (2026-05-18) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.6a2...0.8.5a4) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5...0.9.0a1) **Merged pull requests:** -- chore: Add comprehensive test suites and documentation [\#362](https://github.com/OpenVoiceOS/ovos-utils/pull/362) ([JarbasAl](https://github.com/JarbasAl)) -- fix: make the stopwatch test less strict [\#360](https://github.com/OpenVoiceOS/ovos-utils/pull/360) ([PureTryOut](https://github.com/PureTryOut)) - -## [0.8.6a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.6a2) (2026-02-02) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.6a1...0.8.6a2) - -**Merged pull requests:** - -- chore\(deps\): update actions/setup-python action to v6 [\#353](https://github.com/OpenVoiceOS/ovos-utils/pull/353) ([renovate[bot]](https://github.com/apps/renovate)) -- chore\(deps\): update actions/checkout action to v6 [\#352](https://github.com/OpenVoiceOS/ovos-utils/pull/352) ([renovate[bot]](https://github.com/apps/renovate)) - -## [0.8.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.6a1) (2026-02-02) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5a3...0.8.6a1) - -**Merged pull requests:** - -- fix: prevent handler errors from aborting FakeBus emit [\#358](https://github.com/OpenVoiceOS/ovos-utils/pull/358) ([JarbasAl](https://github.com/JarbasAl)) - -## [0.8.5a3](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5a3) (2025-12-19) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5a2...0.8.5a3) - -**Merged pull requests:** - -- chore\(deps\): update dependency python to 3.14 [\#348](https://github.com/OpenVoiceOS/ovos-utils/pull/348) ([renovate[bot]](https://github.com/apps/renovate)) - -## [0.8.5a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5a2) (2025-12-18) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5a1...0.8.5a2) - -**Merged pull requests:** - -- chore: Configure Renovate [\#347](https://github.com/OpenVoiceOS/ovos-utils/pull/347) ([renovate[bot]](https://github.com/apps/renovate)) - -## [0.8.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5a1) (2025-11-07) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.4...0.8.5a1) - -**Merged pull requests:** - -- fix: use timezone-aware datetime functions and update scheduler event names [\#343](https://github.com/OpenVoiceOS/ovos-utils/pull/343) ([JarbasAl](https://github.com/JarbasAl)) +- feat: AsyncFakeBus alongside FakeBus [\#371](https://github.com/OpenVoiceOS/ovos-utils/pull/371) ([JarbasAl](https://github.com/JarbasAl)) From 5b436b202f9aa507371bceefe81148038140f520 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 18:10:51 +0100 Subject: [PATCH 05/64] feat: migrate ovos-utils onto ovos-spec-tools (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: expand_template delegates to ovos-spec-tools ovos-utils' bracket expander is one of ~7 copies of the same logic across the OVOS ecosystem. It now delegates to the OVOS-INTENT-1 reference expander, ovos_spec_tools.expand — the single conformant implementation — instead of carrying its own. - expand_template() is a thin wrapper over ovos_spec_tools.expand. - adds the ovos-spec-tools dependency. Behaviour change (conformance with OVOS-INTENT-1): - a malformed template — a single-branch group, an empty sample, a slot-only template — now raises MalformedTemplate instead of silently producing a degenerate result. The old lenient behaviour was a bug. - whitespace in a sample is normalized to single spaces; an emptied [optional] no longer leaves a double space. Tests updated to the conformant output; a test added for the malformed-template raise. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: migrate lang and dialog onto ovos-spec-tools; deprecate the shims Completes the ovos-utils migration onto ovos-spec-tools. - lang.get_language_dir() delegates to ovos_spec_tools.closest_lang. - dialog.py and file_utils.py use ovos_spec_tools.expand directly. - expand_template, get_language_dir, MustacheDialogRenderer, load_dialogs and get_dialog are deprecated: each both emits a DeprecationWarning (visible to IDEs and tooling) and logs via the @deprecated helper, pointing callers at the ovos-spec-tools equivalent. The removal version is derived from version.py — the next major release (VERSION_MAJOR + 1). standardize_lang_tag is left unchanged: its `macro` parameter has no ovos-spec-tools equivalent and documented, tested behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: migrate standardize_lang_tag to ovos-spec-tools standardize_lang_tag now delegates to ovos_spec_tools.standardize_lang and is deprecated (DeprecationWarning + the @deprecated log helper). The `macro` parameter is kept for backward compatibility but no longer affects the result: its region-stripping only ever happened on the no-langcodes fallback path — inconsistent with the langcodes path, which never stripped the region — so it was a latent bug. geolocation.py now uses ovos_spec_tools.standardize_lang directly. test_lang.py updated to the conformant behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: standardize_lang_tag macro=True returns the bare language Keep the `macro` parameter functional — when set, drop the region with a plain .split("-")[0] on the standardized tag, so standardize_lang_tag(x, macro=True) returns the bare primary language subtag as before. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ovos_utils/bracket_expansion.py | 59 ++++++++----------- ovos_utils/dialog.py | 29 ++++++++- ovos_utils/file_utils.py | 4 +- ovos_utils/geolocation.py | 5 +- ovos_utils/lang/__init__.py | 75 ++++++++++++++---------- pyproject.toml | 1 + requirements/requirements.txt | 3 +- test/unittests/test_bracket_expansion.py | 31 ++++------ test/unittests/test_lang.py | 3 +- 9 files changed, 115 insertions(+), 95 deletions(-) diff --git a/ovos_utils/bracket_expansion.py b/ovos_utils/bracket_expansion.py index 06304e36..5170a0cd 100644 --- a/ovos_utils/bracket_expansion.py +++ b/ovos_utils/bracket_expansion.py @@ -2,43 +2,34 @@ import re from typing import List, Dict import warnings -from ovos_utils.log import deprecated +from ovos_spec_tools import expand as _spec_expand -def expand_template(template: str) -> List[str]: - def expand_optional(text): - """Replace [optional] with two options: one with and one without.""" - return re.sub(r"\[([^\[\]]+)\]", lambda m: f"({m.group(1)}|)", text) - - def expand_alternatives(text): - """Expand (alternative|choices) into a list of choices.""" - parts = [] - for segment in re.split(r"(\([^\(\)]+\))", text): - if segment.startswith("(") and segment.endswith(")"): - options = segment[1:-1].split("|") - parts.append(options) - else: - parts.append([segment]) - return itertools.product(*parts) - - def fully_expand(texts): - """Iteratively expand alternatives until all possibilities are covered.""" - result = set(texts) - while True: - expanded = set() - for text in result: - options = list(expand_alternatives(text)) - expanded.update(["".join(option).strip() for option in options]) - if expanded == result: # No new expansions found - break - result = expanded - return sorted(result) # Return a sorted list for consistency +from ovos_utils.log import deprecated +from ovos_utils.version import VERSION_MAJOR - # Expand optional items first - template = expand_optional(template) - # Fully expand all combinations of alternatives - return fully_expand([template]) +@deprecated("import 'expand' from 'ovos_spec_tools' instead", + f"{VERSION_MAJOR + 1}.0.0") +def expand_template(template: str) -> List[str]: + """Expand a sentence template to its sample set. + + Resolves ``(a|b)`` alternatives and ``[optional]`` segments; named + ``{slot}`` placeholders are carried through unchanged. The samples are + returned sorted. + + .. deprecated:: + Import :func:`expand` from ``ovos_spec_tools`` directly — it is the + single conformant OVOS-INTENT-1 expander, and what this now delegates + to. A template the specification rejects as malformed (a single-branch + group, an empty sample, a slot-only template, …) raises + :class:`ovos_spec_tools.MalformedTemplate`. + """ + # stacklevel=3: warn() -> expand_template body -> @deprecated wrapper -> caller + warnings.warn("expand_template is deprecated; import 'expand' from " + "'ovos_spec_tools' instead", + DeprecationWarning, stacklevel=3) + return sorted(_spec_expand(template)) def expand_slots(template: str, slots: Dict[str, List[str]]) -> List[str]: @@ -53,7 +44,7 @@ def expand_slots(template: str, slots: Dict[str, List[str]]) -> List[str]: list[str]: A list of all expanded combinations. """ # Expand alternatives and optional components - base_expansions = expand_template(template) + base_expansions = sorted(_spec_expand(template)) # Process slots all_sentences = [] diff --git a/ovos_utils/dialog.py b/ovos_utils/dialog.py index d44636da..086003aa 100644 --- a/ovos_utils/dialog.py +++ b/ovos_utils/dialog.py @@ -1,20 +1,31 @@ import os import random import re +import warnings from os.path import join from pathlib import Path from typing import Optional -from ovos_utils.bracket_expansion import expand_template +from ovos_spec_tools import expand + from ovos_utils.file_utils import resolve_resource_file from ovos_utils.lang import translate_word -from ovos_utils.log import LOG, log_deprecation +from ovos_utils.log import LOG, deprecated, log_deprecation +from ovos_utils.version import VERSION_MAJOR class MustacheDialogRenderer: """A dialog template renderer based on the mustache templating language.""" + @deprecated("use the OVOS-INTENT-2 §4.2 dialog renderer in " + "'ovos_spec_tools' ('render' / 'DialogRenderer')", + f"{VERSION_MAJOR + 1}.0.0") def __init__(self): + warnings.warn( + "MustacheDialogRenderer is deprecated; use the OVOS-INTENT-2 §4.2 " + "dialog renderer in 'ovos_spec_tools' ('render' / " + "'DialogRenderer')", + DeprecationWarning, stacklevel=3) self.templates = {} self.recent_phrases = [] @@ -92,7 +103,7 @@ def render(self, template_name, context=None, index=None): line = template_functions[index % len(template_functions)] # Replace {key} in line with matching values from context line = line.format(**context) - line = random.choice(expand_template(line)) + line = random.choice(sorted(expand(line))) # Here's where we keep track of what we've said recently. Remember, # this is by line in the .dialog file, not by exact phrase @@ -104,6 +115,8 @@ def render(self, template_name, context=None, index=None): return line +@deprecated("use 'ovos_spec_tools.LocaleResources' to load .dialog resources", + f"{VERSION_MAJOR + 1}.0.0") def load_dialogs(dialog_dir: str, renderer: Optional[MustacheDialogRenderer] = None) -> \ MustacheDialogRenderer: @@ -116,6 +129,10 @@ def load_dialogs(dialog_dir: str, Returns: a loaded instance of a dialog renderer """ + warnings.warn( + "load_dialogs is deprecated; use 'ovos_spec_tools.LocaleResources' " + "to load .dialog resources", + DeprecationWarning, stacklevel=3) if renderer is None: renderer = MustacheDialogRenderer() @@ -132,6 +149,8 @@ def load_dialogs(dialog_dir: str, return renderer +@deprecated("use the OVOS-INTENT-2 §4.2 dialog renderer in 'ovos_spec_tools' " + "('render' / 'DialogRenderer')", f"{VERSION_MAJOR + 1}.0.0") def get_dialog(phrase: str, lang: str = None, context: Optional[dict] = None) -> str: """ @@ -149,6 +168,10 @@ def get_dialog(phrase: str, lang: str = None, str: a randomized and/or translated version of the phrase """ + warnings.warn( + "get_dialog is deprecated; use the OVOS-INTENT-2 §4.2 dialog renderer " + "in 'ovos_spec_tools' ('render' / 'DialogRenderer')", + DeprecationWarning, stacklevel=3) if not lang: log_deprecation("Expected a string lang and got None.", "0.1.0") try: diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 55bb8fa8..881578e0 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -9,7 +9,7 @@ from threading import RLock from typing import Optional, List -from ovos_utils.bracket_expansion import expand_template +from ovos_spec_tools import expand from ovos_utils.log import LOG, log_deprecation @@ -238,7 +238,7 @@ def read_vocab_file(path: str) -> List[List[str]]: for line in voc_file.readlines(): if line.startswith('#') or line.strip() == '': continue - vocab.append(expand_template(line.lower())) + vocab.append(sorted(expand(line.lower()))) return vocab diff --git a/ovos_utils/geolocation.py b/ovos_utils/geolocation.py index b9b20494..a586a9f6 100644 --- a/ovos_utils/geolocation.py +++ b/ovos_utils/geolocation.py @@ -3,8 +3,9 @@ import requests from requests.exceptions import RequestException, Timeout +from ovos_spec_tools import standardize_lang + from ovos_utils import timed_lru_cache -from ovos_utils.lang import standardize_lang_tag from ovos_utils.log import LOG from ovos_utils.network_utils import get_external_ip, is_valid_ip @@ -234,7 +235,7 @@ def get_ip_geolocation(ip: Optional[str] = None, raise ValueError(f"Invalid IP address: {ip}") # normalize language to expected values by ip-api.com - lang = standardize_lang_tag(lang).split("-")[0] + lang = standardize_lang(lang).split("-")[0] if lang not in ["en", "de", "es", "pt", "fr", "ja", "zh", "ru"]: LOG.warning(f"Language unsupported by ip-api.com ({lang}), defaulting to english") lang = "en" diff --git a/ovos_utils/lang/__init__.py b/ovos_utils/lang/__init__.py index 25c63471..6ccf5e4b 100644 --- a/ovos_utils/lang/__init__.py +++ b/ovos_utils/lang/__init__.py @@ -1,47 +1,58 @@ +import warnings from os import listdir from os.path import isdir, join from typing import Optional from ovos_utils.file_utils import resolve_resource_file +from ovos_utils.log import deprecated +from ovos_utils.version import VERSION_MAJOR +@deprecated("use 'standardize_lang' from 'ovos_spec_tools' instead", + f"{VERSION_MAJOR + 1}.0.0") def standardize_lang_tag(lang_code: str, macro=True) -> str: - """https://langcodes-hickford.readthedocs.io/en/sphinx/index.html""" - try: - from langcodes import standardize_tag as std - return str(std(lang_code, macro=macro)) - except Exception: - if macro: - return lang_code.split("-")[0].lower() - if "-" in lang_code: - a, b = lang_code.split("-", 1) - return f"{a.lower()}-{b.upper()}" - return lang_code.lower() + """Normalize a BCP-47 language tag. + + With ``macro=True`` the region is dropped, returning the bare primary + language subtag (``en-US`` -> ``en``). + + .. deprecated:: + Use :func:`ovos_spec_tools.standardize_lang` — the conformant OVOS + language-tag normalizer, and what this now delegates to. + """ + # stacklevel=3: warn() -> this body -> @deprecated wrapper -> caller + warnings.warn("standardize_lang_tag is deprecated; use 'standardize_lang' " + "from 'ovos_spec_tools' instead", + DeprecationWarning, stacklevel=3) + from ovos_spec_tools import standardize_lang + tag = standardize_lang(lang_code) + return tag.split("-")[0] if macro else tag -def get_language_dir(base_path: str, lang: str ="en-US") -> Optional[str]: - """ checks for all language variations and returns best path """ - lang = standardize_lang_tag(lang) +@deprecated("use 'closest_lang' from 'ovos_spec_tools' " + "(or 'ovos_spec_tools.LocaleResources')", + f"{VERSION_MAJOR + 1}.0.0") +def get_language_dir(base_path: str, lang: str = "en-US") -> Optional[str]: + """Return the best-matching ``/`` directory under ``base_path``. - candidates = [] - for f in listdir(base_path): - if isdir(f"{base_path}/{f}"): - try: - from langcodes import tag_distance - score = tag_distance(lang, f) - except Exception: # not a valid language code - continue - # https://langcodes-hickford.readthedocs.io/en/sphinx/index.html#distance-values - # 0 -> These codes represent the same language, possibly after filling in values and normalizing. - # 1- 3 -> These codes indicate a minor regional difference. - # 4 - 10 -> These codes indicate a significant but unproblematic regional difference. - if score < 10: - candidates.append((f"{base_path}/{f}", score)) - if not candidates: + .. deprecated:: + Use :func:`ovos_spec_tools.closest_lang` to resolve a language tag + against the available ones, or :class:`ovos_spec_tools.LocaleResources` + which resolves locale directories itself. + """ + # stacklevel=3: warn() -> this body -> @deprecated wrapper -> caller + warnings.warn("get_language_dir is deprecated; use 'closest_lang' from " + "'ovos_spec_tools' (or 'ovos_spec_tools.LocaleResources')", + DeprecationWarning, stacklevel=3) + from ovos_spec_tools import closest_lang + try: + names = [f for f in listdir(base_path) if isdir(join(base_path, f))] + except (FileNotFoundError, NotADirectoryError): return None - # sort by distance to target lang code - candidates = sorted(candidates, key=lambda k: k[1]) - return candidates[0][0] + # closest_lang accepts a tag distance below 10 — the same threshold this + # used previously (OVOS-INTENT-2 §2.2). + match = closest_lang(lang, names) + return join(base_path, match) if match is not None else None def translate_word(name, lang='en-US'): diff --git a/pyproject.toml b/pyproject.toml index d4534048..f863b268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", + "ovos-spec-tools>=0.0.1a2", ] [project.urls] diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 49a447aa..7b929133 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -6,4 +6,5 @@ watchdog pyee>=8.0.0 combo-lock~=0.2 rich-click~=1.7 -rich~=13.7 \ No newline at end of file +rich~=13.7 +ovos-spec-tools>=0.0.1a2 \ No newline at end of file diff --git a/test/unittests/test_bracket_expansion.py b/test/unittests/test_bracket_expansion.py index 5d6bcbe1..f3b06bb2 100644 --- a/test/unittests/test_bracket_expansion.py +++ b/test/unittests/test_bracket_expansion.py @@ -35,20 +35,15 @@ def test_expand_slots(self): 'change the brightness to high and color to blue'] self.assertEqual(expanded_sentences, expected_sentences) + def test_malformed_template_raises(self): + # a template whose expansion would yield an empty string is malformed + # (OVOS-INTENT-1 §3.6) — it raises rather than producing '' + from ovos_spec_tools import MalformedTemplate + with self.assertRaises(MalformedTemplate): + expand_template("[(this|that) is optional]") + def test_expand_template(self): # Test for template expansion - templates = [ - "[hello,] (call me|my name is) {name}", - "Expand (alternative|choices) into a list of choices.", - "sentences have [optional] words ", - "alternative words can be (used|written)", - "sentence[s] can have (pre|suf)fixes mid word too", - "do( the | )thing(s|) (old|with) style and( no | )spaces", - "[(this|that) is optional]", - "tell me a [{joke_type}] joke", - "play {query} [in ({device_name}|{skill_name}|{zone_name})]" - ] - expected_outputs = { "[hello,] (call me|my name is) {name}": [ "call me {name}", @@ -60,9 +55,11 @@ def test_expand_template(self): "Expand alternative into a list of choices.", "Expand choices into a list of choices." ], + # an emptied [optional] no longer leaves a double space — + # OVOS-INTENT-1 §4.1 normalizes whitespace to single spaces "sentences have [optional] words ": [ - "sentences have words", - "sentences have optional words" + "sentences have optional words", + "sentences have words" ], "alternative words can be (used|written)": [ "alternative words can be used", @@ -92,12 +89,8 @@ def test_expand_template(self): "do things with style and no spaces", "do things with style and spaces" ], - "[(this|that) is optional]": [ - '', - 'that is optional', - 'this is optional'], "tell me a [{joke_type}] joke": [ - "tell me a joke", + "tell me a joke", "tell me a {joke_type} joke" ], "play {query} [in ({device_name}|{skill_name}|{zone_name})]": [ diff --git a/test/unittests/test_lang.py b/test/unittests/test_lang.py index 19cbeb20..22c471c4 100644 --- a/test/unittests/test_lang.py +++ b/test/unittests/test_lang.py @@ -25,9 +25,8 @@ class TestStandardizeLangTag(unittest.TestCase): """Tests for standardize_lang_tag.""" def test_macro_strips_region(self) -> None: - """standardize_lang_tag(macro=True) should return bare language code.""" + """standardize_lang_tag(macro=True) should return the bare language.""" from ovos_utils.lang import standardize_lang_tag - # When langcodes not available, falls back to split on '-' with patch.dict("sys.modules", {"langcodes": None}): result = standardize_lang_tag("en-US", macro=True) self.assertEqual(result, "en") From decfeb3b4a7527216a9e63a2d4ba780dc8c72071 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 17:11:04 +0000 Subject: [PATCH 06/64] Increment Version to 0.10.0a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index d5785018..e92380cb 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 9 +VERSION_MINOR = 10 VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 16169be2481c4da0f959632531088bdf65d80947 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 22 May 2026 17:11:33 +0000 Subject: [PATCH 07/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c570abf..4576b1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.10.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.10.0a1) (2026-05-22) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.9.0a1...0.10.0a1) + +**Merged pull requests:** + +- feat: migrate ovos-utils onto ovos-spec-tools [\#373](https://github.com/OpenVoiceOS/ovos-utils/pull/373) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.9.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.9.0a1) (2026-05-18) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5...0.9.0a1) From 20e8445baa04d7f7c9b9d85b2035dcf215669dc7 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 01:23:34 +0100 Subject: [PATCH 08/64] =?UTF-8?q?feat:=20fakebus=20Message=20subclasses=20?= =?UTF-8?q?ovos=5Fspec=5Ftools.Message=20=E2=80=94=20no=20API=20break=20(#?= =?UTF-8?q?375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: fakebus Message is the ovos-spec-tools class directly; publish attached as a method OVOS-MSG-1 lives in ovos-spec-tools 0.5.0a1+. The 165-line FakeMessage class (with its _MutableMessage metaclass + dynamic __new__) is gone; fakebus now re-exports the spec-tools Message directly and attaches the one legacy convenience method downstream still uses: from ovos_spec_tools.message import Message as FakeMessage FakeMessage.publish = _publish_function The MutableMessage metaclass / runtime indirection that tried to return an ovos_bus_client.Message when bus-client was installed is no longer needed: spec-tools is a hard dependency, the canonical class is always present, and ovos-bus-client.Message is the **same** class. What's gone: * The historical reply() quirk that promoted data['destination'] into context['destination'] was always a bug. * as_dict is now on the spec-tools Message itself; no need to define it here. What stays: * publish() — attached at module import; relay under a new topic, drop 'target', no swap, no deep-copy of data; * The deprecated ovos_utils.fakebus.Message alias for downstream callers still doing from ovos_utils.fakebus import Message. pyproject pin: ovos-spec-tools>=0.5.0a1. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: deprecate fakebus.FakeMessage.publish — slated for removal in next major publish is a bus-client tradition outside OVOS-MSG-1 (the spec defines forward / reply / response as the only normative derivations). Every call now fires a DeprecationWarning via both warnings.warn and the @deprecated decorator from ovos_utils.log, naming the next major (computed f'{VERSION_MAJOR + 1}.0.0' from version.py) as the removal target. Migration: switch publish() callers to forward() or reply(). Co-Authored-By: Claude Opus 4.7 (1M context) * fix: bump ovos-spec-tools to >=0.5.1a1 to pick up the empty-msg_type accept 0.5.0a1 still rejected empty `msg_type` at construction, which broke the `Message("").forward(real_type, data)` scaffold pattern used by the scheduler/event tests. 0.5.1a1 accepts empty at construction and gates only on serialize/wire output. Align both pins. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: align coverage workflow with build-tests (install extras, scope to test/unittests) The coverage job was installing the base package only — test modules that import `ovos_bus_client` / `ovos_config` failed at collection. Match the build-tests config so coverage actually runs the suite. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: pass install_extras as a pip arg, not an extras name The gh-automations coverage workflow installs literally what install_extras contains: `pip install ${install_extras}`. `extras` alone is meaningless; `.[extras]` is what installs the optional-dependencies group. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/coverage.yml | 4 +- ovos_utils/fakebus.py | 244 ++++++++++----------------------- pyproject.toml | 2 +- requirements/requirements.txt | 2 +- test/unittests/test_fakebus.py | 20 ++- 5 files changed, 97 insertions(+), 175 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 19bc9a38..60f05d76 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,6 +12,6 @@ jobs: with: python_version: '3.11' coverage_source: 'ovos_utils' - test_path: 'test/' - install_extras: '' + test_path: 'test/unittests' + install_extras: '.[extras]' min_coverage: 0 diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 2ff6586d..80e15f1a 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -1,7 +1,5 @@ import asyncio -import json import warnings -from copy import deepcopy from threading import Event from ovos_utils.log import LOG, log_deprecation @@ -167,184 +165,90 @@ def close(self): self.on_close() -class _MutableMessage(type): - """ To override isinstance checks we need to use a metaclass """ - - def __instancecheck__(self, instance): - try: - from ovos_bus_client.message import Message as _MycroftMessage - if isinstance(instance, _MycroftMessage): - return True - except ImportError: - pass - return super().__instancecheck__(instance) - - -# fake Message object to allow usage without ovos-bus-client installed -class FakeMessage(metaclass=_MutableMessage): - """ fake Message object to allow usage with FakeBus without ovos-bus-client installed""" - - def __new__(cls, *args, **kwargs): - try: # most common case - from ovos_bus_client import Message as _M - return _M(*args, **kwargs) - except ImportError: - pass - return super().__new__(cls) - - def __init__(self, msg_type, data=None, context=None): - """Used to construct a message object - - Message objects will be used to send information back and forth - between processes of mycroft service, voice, skill and cli - """ - self.msg_type = msg_type - self.data = data or {} - self.context = context or {} - - def __eq__(self, other): - try: - return other.msg_type == self.msg_type and \ - other.data == self.data and \ - other.context == self.context - except Exception: - return False - - def serialize(self): - """This returns a string of the message info. - - This makes it easy to send over a websocket. This uses - json dumps to generate the string with type, data and context - - Returns: - str: a json string representation of the message. - """ - return json.dumps({'type': self.msg_type, - 'data': self.data, - 'context': self.context}) - - @staticmethod - def deserialize(value): - """This takes a string and constructs a message object. - - This makes it easy to take strings from the websocket and create - a message object. This uses json loads to get the info and generate - the message object. - - Args: - value(str): This is the json string received from the websocket - - Returns: - FakeMessage: message object constructed from the json string passed - int the function. - value(str): This is the string received from the websocket - """ - obj = json.loads(value) - return FakeMessage(obj.get('type') or '', - obj.get('data') or {}, - obj.get('context') or {}) - - def forward(self, msg_type, data=None): - """ Keep context and forward message - - This will take the same parameters as a message object but use - the current message object as a reference. It will copy the context - from the existing message object. - - Args: - msg_type (str): type of message - data (dict): data for message - - Returns: - FakeMessage: Message object to be used on the reply to the message - """ - data = data or {} - return FakeMessage(msg_type, data, context=self.context) - - def reply(self, msg_type, data=None, context=None): - """Construct a reply message for a given message - - This will take the same parameters as a message object but use - the current message object as a reference. It will copy the context - from the existing message object and add any context passed in to - the function. Check for a destination passed in to the function from - the data object and add that to the context as a destination. If the - context has a source then that will be swapped with the destination - in the context. The new message will then have data passed in plus the - new context generated. - - Args: - msg_type (str): type of message - data (dict): data for message - context: intended context for new message - - Returns: - FakeMessage: Message object to be used on the reply to the message - """ - data = deepcopy(data) or {} - context = context or {} - - new_context = deepcopy(self.context) - for key in context: - new_context[key] = context[key] - if 'destination' in data: - new_context['destination'] = data['destination'] - if 'source' in new_context and 'destination' in new_context: - s = new_context['destination'] - new_context['destination'] = new_context['source'] - new_context['source'] = s - return FakeMessage(msg_type, data, context=new_context) - - def response(self, data=None, context=None): - """Construct a response message for the message - - Constructs a reply with the data and appends the expected - ".response" to the message - - Args: - data (dict): message data - context (dict): message context - Returns - (Message) message with the type modified to match default response - """ - return self.reply(self.msg_type + '.response', data, context) - - def publish(self, msg_type, data, context=None): - """ - Copy the original context and add passed in context. Delete - any target in the new context. Return a new message object with - passed in data and new context. Type remains unchanged. - - Args: - msg_type (str): type of message - data (dict): date to send with message - context: context added to existing context - - Returns: - FakeMessage: Message object to publish - """ - context = context or {} - new_context = self.context.copy() - for key in context: - new_context[key] = context[key] - - if 'target' in new_context: - del new_context['target'] - - return FakeMessage(msg_type, data, context=new_context) +# The reference Message envelope lives in ovos-spec-tools (OVOS-MSG-1). +# ovos-utils re-exports it under the historical ``FakeMessage`` name and +# attaches the one legacy convenience method downstream still uses — +# ``publish`` — to the class at import time. ``as_dict`` is now on the +# spec-tools class itself; the ``data['destination']`` promotion the +# old ``reply`` did was always a bug (data is the payload, context owns +# routing) and is gone. +# +# The old ``_MutableMessage`` metaclass / dynamic ``__new__`` indirection +# (which tried to return an ``ovos_bus_client.Message`` at runtime if +# bus-client was installed) is no longer needed: spec-tools is a hard +# dependency, the canonical class is always present, and +# ``ovos-bus-client.Message`` is the **same** class (bus-client attaches +# ``publish`` to it too — both attachments are idempotent). +from typing import Any, Dict, Optional + +from ovos_spec_tools.message import Message as FakeMessage +from ovos_utils.log import deprecated +from ovos_utils.version import VERSION_MAJOR + + +# OVOS-MSG-1 defines forward / reply / response as the three normative +# derivations (§5). ``publish`` is a bus-client tradition outside the +# spec; it survives as an attached method for one more major release so +# downstream consumers can migrate. +_PUBLISH_REMOVAL_VERSION = f"{VERSION_MAJOR + 1}.0.0" + + +@deprecated( + "Message.publish is deprecated; use Message.forward (relay under a " + "new topic, preserves context) or Message.reply (§5.2 swap) — both " + "are OVOS-MSG-1 normative", + _PUBLISH_REMOVAL_VERSION) +def _publish(self, msg_type: str, data: Dict[str, Any], + context: Optional[Dict[str, Any]] = None) -> FakeMessage: + """Relay under a new topic without the §5.2 swap; drop ``target``. + + .. deprecated:: + Not part of OVOS-MSG-1 (the spec defines ``forward`` / + ``reply`` / ``response`` as the only normative derivations). + Slated for removal in the next major; use :meth:`forward` + when you do not want the routing-key swap, or :meth:`reply` + when you do. + """ + import warnings + # stacklevel=3: warn() -> body -> @deprecated wrapper -> caller + warnings.warn( + "Message.publish is deprecated; use Message.forward (no §5.2 " + "swap) or Message.reply (with swap) instead — both are " + "OVOS-MSG-1 normative derivations. ``publish`` will be removed " + f"in ovos-utils {_PUBLISH_REMOVAL_VERSION}.", + DeprecationWarning, stacklevel=3) + context = context or {} + new_context = dict(self.context) + new_context.update(context) + new_context.pop("target", None) + return self.__class__(msg_type, data, new_context) + + +# Attach publish() to the spec-tools Message so the method appears on +# every Message instance regardless of which package the caller imported +# the class from. Idempotent with ovos-bus-client's identical attachment. +FakeMessage.publish = _publish class Message(FakeMessage): - """just for compat, stuff in the wild importing from here even with deprecation warnings...""" + """Deprecated alias for the OVOS-MSG-1 ``Message`` envelope. + + ``from ovos_utils.fakebus import Message`` is in the wild and stays + importable through one more release. New code should import the + envelope where it lives — :class:`ovos_spec_tools.Message` (or + :class:`ovos_bus_client.Message`, which is a subclass). + """ def __new__(cls, *args, **kwargs): warnings.warn( - "import from ovos-bus-client directly", + "ovos_utils.fakebus.Message is deprecated; import " + "ovos_spec_tools.Message (or ovos_bus_client.Message)", DeprecationWarning, stacklevel=2, ) log_deprecation( - "please import from ovos-bus-client directly! this import has been deprecated since version 0.1.0", "1.0.0") + "please import Message from ovos_spec_tools / " + "ovos_bus_client directly", "1.0.0") return FakeMessage(*args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index f863b268..995f86bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", - "ovos-spec-tools>=0.0.1a2", + "ovos-spec-tools>=0.5.1a1", ] [project.urls] diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 7b929133..516fdbae 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ pyee>=8.0.0 combo-lock~=0.2 rich-click~=1.7 rich~=13.7 -ovos-spec-tools>=0.0.1a2 \ No newline at end of file +ovos-spec-tools>=0.5.1a1 \ No newline at end of file diff --git a/test/unittests/test_fakebus.py b/test/unittests/test_fakebus.py index 918fa4ff..a94d737d 100644 --- a/test/unittests/test_fakebus.py +++ b/test/unittests/test_fakebus.py @@ -57,9 +57,27 @@ def test_response(self) -> None: self.assertEqual(resp.msg_type, "my.request.response") self.assertEqual(resp.data["result"], "ok") + def test_publish_emits_deprecation_warning(self) -> None: + """``publish`` is not part of OVOS-MSG-1 and is scheduled for + removal — every call must fire a DeprecationWarning.""" + import warnings + msg = self._make_message("pub.type", {}, {"source": "x"}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + msg.publish("new.type", {"payload": 1}) + deps = [w for w in caught + if issubclass(w.category, DeprecationWarning) + and "publish" in str(w.message)] + self.assertTrue(deps, + "FakeMessage.publish() did not emit a " + "DeprecationWarning") + def test_publish(self) -> None: msg = self._make_message("pub.type", {}, {"target": "skill", "source": "x"}) - published = msg.publish("new.type", {"payload": 1}) + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + published = msg.publish("new.type", {"payload": 1}) self.assertNotIn("target", published.context) self.assertEqual(published.data["payload"], 1) From a88d996c490a600a03351cdb528caafcf01b35be Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 00:23:44 +0000 Subject: [PATCH 09/64] Increment Version to 0.11.0a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index e92380cb..a2e979eb 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 10 +VERSION_MINOR = 11 VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 6903f9525046e2082acddf746b1c30b81ae162ca Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 00:24:08 +0000 Subject: [PATCH 10/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4576b1ab..e03ebee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.11.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.0a1) (2026-05-25) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.10.0a1...0.11.0a1) + +**Merged pull requests:** + +- feat: fakebus Message subclasses ovos\_spec\_tools.Message — no API break [\#375](https://github.com/OpenVoiceOS/ovos-utils/pull/375) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.10.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.10.0a1) (2026-05-22) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.9.0a1...0.10.0a1) From 52911878abbd59ee9529936ef058c47dc62f5550 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 15:18:12 +0100 Subject: [PATCH 11/64] fix: standardize_lang_tag macro=True preserves region (restore langcodes semantics) (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-tools migration of `standardize_lang_tag` changed the `macro` argument from its historic meaning (langcodes-defined **macrolanguage substitution** — `cmn` -> `zh`, `nb` -> `no`) to **"strip the region"** (`en-US` -> `en`) via `tag.split('-')[0]`. Every caller that passes the default `macro=True` (`ovos_bus_client.session`, many transformer plugins, …) now gets region-stripped output — including OVOS's `SessionManager`, which silently rewrites `session.lang` from `en-US` to `en` on every message and breaks downstream consumers that key on region (locale resource lookups, regional dialog, ovoscope final-session assertions). Restore the historic semantics by delegating to `langcodes.standardize_tag(lang_code, macro=macro)` — which is what the pre-migration body did (commit 9baa615). When langcodes is unavailable, fall back to spec-tools' `standardize_lang` (also region-preserving) and treat `macro` as a no-op. Tests pin all three: - macro=True preserves the region (`en-US` round-trips) - macro=True substitutes macrolanguages (`cmn` -> `zh`) - macro=False keeps both the region and the sublanguage Co-authored-by: Claude Opus 4.7 (1M context) --- ovos_utils/lang/__init__.py | 25 +++++++++++++----- test/unittests/test_lang.py | 52 ++++++++++++++++++++----------------- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/ovos_utils/lang/__init__.py b/ovos_utils/lang/__init__.py index 6ccf5e4b..3a5fd316 100644 --- a/ovos_utils/lang/__init__.py +++ b/ovos_utils/lang/__init__.py @@ -13,20 +13,33 @@ def standardize_lang_tag(lang_code: str, macro=True) -> str: """Normalize a BCP-47 language tag. - With ``macro=True`` the region is dropped, returning the bare primary - language subtag (``en-US`` -> ``en``). + ``macro`` controls **macrolanguage substitution** per + :func:`langcodes.standardize_tag` — it swaps a sublanguage for its + macrolanguage (``cmn`` -> ``zh``, ``nb`` -> ``no``). It does **not** + strip the region: ``"en-US"`` round-trips through both ``macro=True`` + and ``macro=False`` unchanged. .. deprecated:: Use :func:`ovos_spec_tools.standardize_lang` — the conformant OVOS - language-tag normalizer, and what this now delegates to. + language-tag normalizer. ``standardize_lang`` always returns the + region-preserving form (it does not take a ``macro`` argument); + if you need macrolanguage substitution, call + :func:`langcodes.standardize_tag` directly. """ # stacklevel=3: warn() -> this body -> @deprecated wrapper -> caller warnings.warn("standardize_lang_tag is deprecated; use 'standardize_lang' " "from 'ovos_spec_tools' instead", DeprecationWarning, stacklevel=3) - from ovos_spec_tools import standardize_lang - tag = standardize_lang(lang_code) - return tag.split("-")[0] if macro else tag + try: + from langcodes import standardize_tag + return str(standardize_tag(lang_code, macro=macro)) + except ImportError: + # langcodes is optional. Without it, fall back to the spec-tools + # normalizer (region-preserving). The ``macro`` argument is a + # no-op in this branch because macrolanguage tables live in + # langcodes itself. + from ovos_spec_tools import standardize_lang + return standardize_lang(lang_code) @deprecated("use 'closest_lang' from 'ovos_spec_tools' " diff --git a/test/unittests/test_lang.py b/test/unittests/test_lang.py index 22c471c4..2f9cdb73 100644 --- a/test/unittests/test_lang.py +++ b/test/unittests/test_lang.py @@ -24,37 +24,41 @@ class TestStandardizeLangTag(unittest.TestCase): """Tests for standardize_lang_tag.""" - def test_macro_strips_region(self) -> None: - """standardize_lang_tag(macro=True) should return the bare language.""" + def test_macro_preserves_region(self) -> None: + """standardize_lang_tag(macro=True) preserves the region. + + ``macro`` is a langcodes concept — it controls *macrolanguage* + substitution (``cmn`` -> ``zh``, ``nb`` -> ``no``), not region + stripping. ``en-US`` round-trips unchanged.""" from ovos_utils.lang import standardize_lang_tag - with patch.dict("sys.modules", {"langcodes": None}): - result = standardize_lang_tag("en-US", macro=True) - self.assertEqual(result, "en") + self.assertEqual(standardize_lang_tag("en-US", macro=True), "en-US") + self.assertEqual(standardize_lang_tag("en-us", macro=True), "en-US") def test_non_macro_preserves_region(self) -> None: - """standardize_lang_tag(macro=False) should keep the region part.""" + """standardize_lang_tag(macro=False) preserves the region too — + the difference between macro=True/False is macrolanguage + substitution, not region handling.""" from ovos_utils.lang import standardize_lang_tag - with patch.dict("sys.modules", {"langcodes": None}): - result = standardize_lang_tag("en-us", macro=False) - self.assertEqual(result, "en-US") + self.assertEqual(standardize_lang_tag("en-us", macro=False), "en-US") + + def test_macro_substitutes_macrolanguage(self) -> None: + """With ``macro=True``, langcodes maps a sublanguage onto its + macrolanguage. ``cmn`` (Mandarin) -> ``zh`` (Chinese); + ``macro=False`` keeps the original tag.""" + from ovos_utils.lang import standardize_lang_tag + self.assertEqual(standardize_lang_tag("cmn", macro=True), "zh") + self.assertEqual(standardize_lang_tag("cmn", macro=False), "cmn") - def test_no_region_tag(self) -> None: - """standardize_lang_tag with no '-' should return lowercased tag.""" + def test_fallback_without_langcodes(self) -> None: + """With langcodes unavailable, ``standardize_lang_tag`` falls + back to spec-tools (also region-preserving). ``macro`` is a + no-op in this branch.""" from ovos_utils.lang import standardize_lang_tag with patch.dict("sys.modules", {"langcodes": None}): - result = standardize_lang_tag("EN", macro=False) - self.assertEqual(result, "en") - - def test_with_langcodes_library(self) -> None: - """standardize_lang_tag should call langcodes.standardize_tag when available.""" - mock_langcodes = unittest.mock.MagicMock() - mock_langcodes.standardize_tag.return_value = "en" - - with patch.dict("sys.modules", {"langcodes": mock_langcodes}): - from ovos_utils.lang import standardize_lang_tag - result = standardize_lang_tag("en-US", macro=True) - # Result is whatever langcodes returns - self.assertIsInstance(result, str) + self.assertEqual( + standardize_lang_tag("en-us", macro=True), "en-US") + self.assertEqual( + standardize_lang_tag("EN", macro=False), "en") class TestGetLanguageDir(unittest.TestCase): From c9966ffc5f588082df76755f6e2f9710dbfa5b27 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 14:18:25 +0000 Subject: [PATCH 12/64] Increment Version to 0.11.1a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index a2e979eb..8749fad0 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 11 -VERSION_BUILD = 0 +VERSION_BUILD = 1 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 9df555f0373e29b3447a65dc74e53bef7deff83f Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 25 May 2026 14:19:01 +0000 Subject: [PATCH 13/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e03ebee0..a568d9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.11.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.1a1) (2026-05-25) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.0a1...0.11.1a1) + +**Merged pull requests:** + +- fix: standardize\_lang\_tag macro=True preserves region \(restore langcodes semantics\) [\#377](https://github.com/OpenVoiceOS/ovos-utils/pull/377) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.11.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.0a1) (2026-05-25) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.10.0a1...0.11.0a1) From fc9d84f369dd26452b273814fba3b55af02e2f7a Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:15:54 +0100 Subject: [PATCH 14/64] fix: allow json-database 1.x (#379) json_database~=0.10 resolves to >=0.10,<1.0, excluding json_database 1.x (now published, 1.0.2a1, which dropped the duplicate hivemind-json-db-plugin entry point). As ovos-utils is a foundational dependency, this cap blocks json_database 1.x from resolving anywhere in the OVOS alpha set. Widen to >=0.10,<2.0.0. Co-authored-by: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- requirements/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 995f86bd..9fdbeaa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ requires-python = ">=3.9" dependencies = [ "pexpect~=4.9", "requests~=2.26", - "json_database~=0.10", + "json_database>=0.10,<2.0.0", "kthread~=0.2", "watchdog", "pyee>=8.0.0", diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 516fdbae..53e2e7c6 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,6 +1,6 @@ pexpect~=4.9 requests~=2.26 -json_database~=0.10 +json_database>=0.10,<2.0.0 kthread~=0.2 watchdog pyee>=8.0.0 From ad752286afeaf5b8564e143fa8406a882ba5a1bf Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:16:06 +0000 Subject: [PATCH 15/64] Increment Version to 0.11.2a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 8749fad0..4e65cd4b 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 11 -VERSION_BUILD = 1 +VERSION_BUILD = 2 VERSION_ALPHA = 1 # END_VERSION_BLOCK From f13a977a966f05892c3e345fb6c861784b03be15 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:16:34 +0000 Subject: [PATCH 16/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a568d9ad..782a5c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.11.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.2a1) (2026-06-20) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.1a1...0.11.2a1) + +**Merged pull requests:** + +- fix: allow json-database 1.x [\#379](https://github.com/OpenVoiceOS/ovos-utils/pull/379) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.11.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.1a1) (2026-05-25) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.0a1...0.11.1a1) From cc68e6043f1020f2768d2ec230c392dd155dd52a Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:31:39 +0100 Subject: [PATCH 17/64] feat: FakeBus mirrors the legacy<->ovos.* namespace migration (#381) FakeBus now uses ovos_spec_tools.NamespaceTranslator (the same logic as MessageBusClient) so the test/satellite double bridges legacy<->ovos.* topics and dedupes dual-listeners identically: emit() also dispatches the counterpart topic(s); on() wraps migrated-topic handlers with the shared mirror-guard. Both flags default on; override per-instance with modernize=/emit_legacy=. Bumps ovos-spec-tools>=0.9.0a1 (NamespaceTranslator). --- ovos_utils/fakebus.py | 44 ++++++++++++ pyproject.toml | 2 +- .../test_fakebus_namespace_migration.py | 68 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 test/unittests/test_fakebus_namespace_migration.py diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 80e15f1a..0e961a6f 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -3,6 +3,7 @@ from threading import Event from ovos_utils.log import LOG, log_deprecation +from ovos_spec_tools import NamespaceTranslator from pyee import EventEmitter @@ -21,6 +22,14 @@ def __init__(self, *args, **kwargs): self.session_id = "default" self.ee = kwargs.get("emitter") or EventEmitter() self.ee.on("error", self.on_error) + # mirror MessageBusClient's namespace migration so the test/satellite + # double bridges legacy<->ovos.* topics identically. Both default on; + # override per-instance with modernize=/emit_legacy= kwargs. + self._translator = NamespaceTranslator( + modernize=kwargs.get("modernize", True), + emit_legacy=kwargs.get("emit_legacy", True)) + self._handler_guards = {} # handler -> shared mirror-guard + self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...] self.on_open() try: self.session_id = kwargs["session"].session_id @@ -31,6 +40,22 @@ def __init__(self, *args, **kwargs): self.on_default_session_update) def on(self, msg_type, handler): + # wrap handlers on migrated topics so a handler subscribed to both the + # legacy and ovos.* topic fires once (the mirror is dropped) + if self._translator.is_migrated(msg_type): + guard = self._handler_guards.get(handler) + if guard is None: + guard = self._translator.new_mirror_guard() + self._handler_guards[handler] = guard + + def wrapped(message=None): + if guard(message): + return + return handler(message) + + self.ee.on(msg_type, wrapped) + self._dedup_registrations.setdefault(handler, []).append((msg_type, wrapped)) + return self.ee.on(msg_type, handler) def once(self, msg_type, handler): @@ -50,6 +75,13 @@ def emit(self, message): self.ee.emit(message.msg_type, message) except Exception as e: LOG.exception(f"Error in event handler for '{message.msg_type}': {e}") + # namespace migration: also dispatch the counterpart topic(s) so a + # listener on either namespace receives the event (consumers dedupe) + for topic in self._translator.counterpart_topics(message.msg_type): + try: + self.ee.emit(topic, message.forward(topic, message.data)) + except Exception as e: + LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") self.on_message(message.serialize()) def on_message(self, *args): @@ -135,6 +167,18 @@ def rcv(m): return msg def remove(self, msg_type, handler): + regs = self._dedup_registrations.get(handler) + if regs: + for ev, wrapped in [r for r in regs if r[0] == msg_type]: + try: + self.ee.remove_listener(ev, wrapped) + except Exception: + pass + regs.remove((ev, wrapped)) + if not regs: + self._dedup_registrations.pop(handler, None) + self._handler_guards.pop(handler, None) + return try: self.ee.remove_listener(msg_type, handler) except Exception: diff --git a/pyproject.toml b/pyproject.toml index 9fdbeaa4..982319d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", - "ovos-spec-tools>=0.5.1a1", + "ovos-spec-tools>=0.9.0a1", ] [project.urls] diff --git a/test/unittests/test_fakebus_namespace_migration.py b/test/unittests/test_fakebus_namespace_migration.py new file mode 100644 index 00000000..80d3c8ff --- /dev/null +++ b/test/unittests/test_fakebus_namespace_migration.py @@ -0,0 +1,68 @@ +"""FakeBus mirrors MessageBusClient's legacy<->ovos.* namespace migration, so +e2e/satellite tests exercise the real cross-namespace behaviour.""" +import unittest + +from ovos_utils.fakebus import FakeBus, Message + + +class TestFakeBusNamespaceMigration(unittest.TestCase): + def test_legacy_emit_reaches_spec_listener(self): + bus = FakeBus() # both flags default on + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(m.msg_type)) + bus.emit(Message("speak", {"utterance": "hi"})) + self.assertEqual(got, ["ovos.utterance.speak"]) # modernize bridged it + + def test_spec_emit_reaches_legacy_listener(self): + bus = FakeBus() + got = [] + bus.on("speak", lambda m: got.append(m.msg_type)) + bus.emit(Message("ovos.utterance.speak", {"utterance": "hi"})) + self.assertEqual(got, ["speak"]) # emit_legacy bridged it + + def test_dual_listener_fires_once(self): + bus = FakeBus() + calls = [] + handler = lambda m: calls.append(m.msg_type) + bus.on("speak", handler) + bus.on("ovos.utterance.speak", handler) + bus.emit(Message("speak", {"utterance": "hi"})) + self.assertEqual(len(calls), 1) # mirror deduped + + def test_distinct_listeners_each_fire_once(self): + bus = FakeBus() + legacy, spec = [], [] + bus.on("speak", lambda m: legacy.append(1)) + bus.on("ovos.utterance.speak", lambda m: spec.append(1)) + bus.emit(Message("speak", {"utterance": "hi"})) + self.assertEqual((len(legacy), len(spec)), (1, 1)) + + def test_flags_off_no_bridging(self): + bus = FakeBus(modernize=False, emit_legacy=False) + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(m.msg_type)) + bus.emit(Message("speak", {"utterance": "hi"})) + self.assertEqual(got, []) # no translation -> spec listener not reached + + def test_unmapped_topic_untouched(self): + bus = FakeBus() + got = [] + bus.on("my.custom.topic", lambda m: got.append(m.msg_type)) + bus.emit(Message("my.custom.topic", {"x": 1})) + self.assertEqual(got, ["my.custom.topic"]) + + def test_remove_cleans_up(self): + bus = FakeBus() + calls = [] + handler = lambda m: calls.append(1) + bus.on("speak", handler) + bus.on("ovos.utterance.speak", handler) + bus.remove("speak", handler) + bus.remove("ovos.utterance.speak", handler) + self.assertNotIn(handler, bus._handler_guards) + bus.emit(Message("speak", {"utterance": "hi"})) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() From fd6b6e0fcd6e6f9298ff8a6ecf6a6563b3ff741c Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:31:56 +0000 Subject: [PATCH 18/64] Increment Version to 0.12.0a1 --- ovos_utils/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 4e65cd4b..83f0692a 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 11 -VERSION_BUILD = 2 +VERSION_MINOR = 12 +VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From e9f3b17b772463c8af31cb2e177b1f8b3ccea249 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:32:26 +0000 Subject: [PATCH 19/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 782a5c7c..ebb975c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.12.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.0a1) (2026-06-25) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.2a1...0.12.0a1) + +**Merged pull requests:** + +- feat: FakeBus mirrors the legacy\<-\>ovos.\* namespace migration [\#381](https://github.com/OpenVoiceOS/ovos-utils/pull/381) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.11.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.11.2a1) (2026-06-20) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.1a1...0.11.2a1) From 08febad33a6afc0f5ecc0bac491dc23d404cd8e0 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:31:02 +0100 Subject: [PATCH 20/64] fix: raise ovos-spec-tools floor to 0.10.0a1 for NamespaceTranslator (#383) fakebus.py imports NamespaceTranslator from ovos_spec_tools at module top level (unconditional), and that symbol first ships in ovos-spec-tools 0.10.0a1. The previous >=0.9.0a1 floor could resolve to a version without it, making `import ovos_utils.fakebus` fail at install time. Co-authored-by: Claude Opus 4.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 982319d6..4c9c5094 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", - "ovos-spec-tools>=0.9.0a1", + "ovos-spec-tools>=0.10.0a1", ] [project.urls] From ae73d6904cc9d95f2a93b9017f2958b9ed3c6df3 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:31:22 +0000 Subject: [PATCH 21/64] Increment Version to 0.12.1a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 83f0692a..6d9c9e2d 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 12 -VERSION_BUILD = 0 +VERSION_BUILD = 1 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 5c47256917ad39e1137f7921456ec592bf84d9d7 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:31:52 +0000 Subject: [PATCH 22/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb975c8..6a83fc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.12.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.1a1) (2026-06-25) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.0a1...0.12.1a1) + +**Merged pull requests:** + +- fix: raise ovos-spec-tools floor to 0.10.0a1 for NamespaceTranslator [\#383](https://github.com/OpenVoiceOS/ovos-utils/pull/383) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.12.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.0a1) (2026-06-25) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.11.2a1...0.12.0a1) From 9b02d99fc8f6bb607931b7efe19c6e2dc8345e79 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:51:33 +0100 Subject: [PATCH 23/64] fix: translate mirrored payload onto counterpart topic in FakeBus (#385) * fix: translate mirrored payload onto counterpart topic in FakeBus FakeBus mirrors MessageBusClient's namespace-migration bridge; it now calls NamespaceTranslator.translate_payload() at its counterpart-emit point so the mirrored Message carries the payload in the COUNTERPART topic's shape instead of a verbatim copy. This keeps the test double faithful to the real bus. Identity transform for payload-compatible renames (behaviour unchanged); reshaped per direction for the shape-changing renames (handler trio, detach_intent, enable/disable_intent). Pins ovos-spec-tools floor to >=0.14.1a1 (the version that will publish the translate_payload API, ovos-spec-tools#42). Co-Authored-By: Claude Opus 4.8 * ci: install in-flight spec-tools cross-dep from git * ci: pre-install spec-tools conformance-message for coverage job * fix: lower spec-tools floor to 0.14.0a1 (translate_payload branch version) --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/build-tests.yml | 2 ++ .github/workflows/coverage.yml | 2 ++ ovos_utils/fakebus.py | 11 ++++++-- pyproject.toml | 2 +- requirements/requirements.txt | 2 +- .../test_fakebus_namespace_migration.py | 28 +++++++++++++++++++ 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 050eeacf..c9410a0f 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -12,4 +12,6 @@ jobs: with: python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' install_extras: 'extras' + # in-flight cross-dep: spec-tools translate_payload (PR #42) not yet published + pre_install_pip: 'git+https://github.com/OpenVoiceOS/ovos-spec-tools@fix/conformance-message' test_path: 'test/unittests' diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 60f05d76..38f42871 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,4 +14,6 @@ jobs: coverage_source: 'ovos_utils' test_path: 'test/unittests' install_extras: '.[extras]' + # in-flight cross-dep: spec-tools translate_payload (PR #42) not yet published + pre_install_pip: 'git+https://github.com/OpenVoiceOS/ovos-spec-tools@fix/conformance-message' min_coverage: 0 diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 0e961a6f..c47195cc 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -76,10 +76,17 @@ def emit(self, message): except Exception as e: LOG.exception(f"Error in event handler for '{message.msg_type}': {e}") # namespace migration: also dispatch the counterpart topic(s) so a - # listener on either namespace receives the event (consumers dedupe) + # listener on either namespace receives the event (consumers dedupe). + # the mirrored payload is reshaped into the counterpart topic's shape + # (identity for payload-compatible renames, a per-topic transform for + # shape-changing ones) so a listener on it receives the payload in *its* + # shape -- matching MessageBusClient's bridge. for topic in self._translator.counterpart_topics(message.msg_type): try: - self.ee.emit(topic, message.forward(topic, message.data)) + translated = self._translator.translate_payload( + from_topic=message.msg_type, to_topic=topic, + data=message.data) + self.ee.emit(topic, message.forward(topic, translated)) except Exception as e: LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") self.on_message(message.serialize()) diff --git a/pyproject.toml b/pyproject.toml index 4c9c5094..2a57e82e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", - "ovos-spec-tools>=0.10.0a1", + "ovos-spec-tools>=0.14.0a1", ] [project.urls] diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 53e2e7c6..ec35ebf6 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ pyee>=8.0.0 combo-lock~=0.2 rich-click~=1.7 rich~=13.7 -ovos-spec-tools>=0.5.1a1 \ No newline at end of file +ovos-spec-tools>=0.14.0a1 \ No newline at end of file diff --git a/test/unittests/test_fakebus_namespace_migration.py b/test/unittests/test_fakebus_namespace_migration.py index 80d3c8ff..fa742edf 100644 --- a/test/unittests/test_fakebus_namespace_migration.py +++ b/test/unittests/test_fakebus_namespace_migration.py @@ -51,6 +51,34 @@ def test_unmapped_topic_untouched(self): bus.emit(Message("my.custom.topic", {"x": 1})) self.assertEqual(got, ["my.custom.topic"]) + def test_shape_changing_payload_reshaped_for_spec_listener(self): + # a spec listener on the counterpart of a SHAPE-CHANGING legacy topic + # receives the payload in ITS shape, not a verbatim legacy copy. + bus = FakeBus() + got = [] + bus.on("ovos.intent.handler.start", lambda m: got.append(dict(m.data))) + bus.emit(Message("mycroft.skill.handler.start", {"handler": "HelloIntent"})) + self.assertEqual(len(got), 1) + # reshaped to the spec shape ({"intent_name": ...}), NOT {"handler": ...} + self.assertEqual(got[0], {"intent_name": "HelloIntent"}) + self.assertNotIn("handler", got[0]) + + def test_shape_changing_payload_reshaped_for_legacy_listener(self): + bus = FakeBus() + got = [] + bus.on("mycroft.skill.handler.start", lambda m: got.append(dict(m.data))) + bus.emit(Message("ovos.intent.handler.start", + {"skill_id": "skill.foo", "intent_name": "HelloIntent"})) + self.assertEqual(len(got), 1) + self.assertEqual(got[0].get("handler"), "HelloIntent") # legacy shape + + def test_payload_compatible_rename_delivered_equivalent(self): + bus = FakeBus() + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(dict(m.data))) + bus.emit(Message("speak", {"utterance": "hi", "lang": "en-us"})) + self.assertEqual(got, [{"utterance": "hi", "lang": "en-us"}]) # identity + def test_remove_cleans_up(self): bus = FakeBus() calls = [] From acb4e1e80408a864b99e9553baf33cf6a73620f5 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:51:45 +0000 Subject: [PATCH 24/64] Increment Version to 0.12.2a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 6d9c9e2d..62e08801 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 12 -VERSION_BUILD = 1 +VERSION_BUILD = 2 VERSION_ALPHA = 1 # END_VERSION_BLOCK From d8460541b611560ca13ff7fa7da7f269f035fb34 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:52:12 +0000 Subject: [PATCH 25/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a83fc7c..33d88ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.12.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.2a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.1a1...0.12.2a1) + +**Merged pull requests:** + +- fix: translate mirrored payload onto counterpart topic in FakeBus [\#385](https://github.com/OpenVoiceOS/ovos-utils/pull/385) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.12.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.1a1) (2026-06-25) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.0a1...0.12.1a1) From a2404f96f0a43e12af7e3edc716327eb3413c752 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:49:23 +0100 Subject: [PATCH 26/64] feat: AsyncFakeBus namespace migration + env/config flag parity (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: mirror namespace migration in AsyncFakeBus + env/config flag parity AsyncFakeBus now mirrors FakeBus / MessageBusClient namespace migration: builds a NamespaceTranslator, wraps migrated-topic handlers with a mirror-guard for dedup in on()/remove(), and dispatches counterpart topics with translate_payload on emit(). Both FakeBus and AsyncFakeBus resolve modernize/emit_legacy the way the real client's _bus_flag does: explicit kwarg wins, else env var -> websocket.* config -> default True. A local _bus_flag helper mirrors the semantics without importing from ovos-bus-client (layering). A _UNSET sentinel distinguishes "kwarg omitted" from "kwarg passed True/False". Co-Authored-By: Claude Opus 4.8 * fix: drop obsolete spec-tools git-ref in CI; floor to published translate_payload The build/coverage workflows pinned ovos-spec-tools@fix/conformance-message via pre_install_pip because translate_payload was unpublished. It has shipped since 0.16.1a1, so the git-ref is obsolete (and masked an inadequate >=0.14.0a1 floor that cannot satisfy the FakeBus namespace-migration code). Floor bumped to >=0.16.1a2 (the published min carrying the NamespaceTranslator payload-transform API) and the CI git-ref removed — versions belong in pyproject, not CI. * chore: remove stray agent scratch files (AGENTS.md, TODO.md, console-script artifact) * chore: drop requirements/*.txt — pyproject.toml is the single source of truth The requirements/{requirements,extras}.txt files duplicated the inline [project.dependencies] / [project.optional-dependencies] (pyproject was already a superset — it additionally carried python-dateutil and packaging). Nothing reads them (no setup.py/MANIFEST; CI installs .[extras]). Removed per the pyproject-only packaging rule. --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/build-tests.yml | 2 - .github/workflows/coverage.yml | 2 - ovos_utils/fakebus.py | 93 ++++++++++++++++++- pyproject.toml | 2 +- requirements/extras.txt | 9 -- requirements/requirements.txt | 10 -- test/unittests/test_async_fakebus.py | 65 +++++++++++++ .../test_fakebus_namespace_migration.py | 55 ++++++++++- 8 files changed, 208 insertions(+), 30 deletions(-) delete mode 100644 requirements/extras.txt delete mode 100644 requirements/requirements.txt diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index c9410a0f..050eeacf 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -12,6 +12,4 @@ jobs: with: python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' install_extras: 'extras' - # in-flight cross-dep: spec-tools translate_payload (PR #42) not yet published - pre_install_pip: 'git+https://github.com/OpenVoiceOS/ovos-spec-tools@fix/conformance-message' test_path: 'test/unittests' diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 38f42871..60f05d76 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,6 +14,4 @@ jobs: coverage_source: 'ovos_utils' test_path: 'test/unittests' install_extras: '.[extras]' - # in-flight cross-dep: spec-tools translate_payload (PR #42) not yet published - pre_install_pip: 'git+https://github.com/OpenVoiceOS/ovos-spec-tools@fix/conformance-message' min_coverage: 0 diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index c47195cc..8b8d272f 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -1,5 +1,6 @@ import asyncio import warnings +from os import environ from threading import Event from ovos_utils.log import LOG, log_deprecation @@ -16,6 +17,46 @@ def dig_for_message(): return None +# sentinel: lets us tell "kwarg not passed" apart from "kwarg passed True/False" +_UNSET = object() + + +def _bus_flag(env_var, config_key, default=True): + """Resolve a boolean bus flag the way ``MessageBusClient._bus_flag`` does. + + Precedence: env var (when set) > ``websocket.`` in ovos_config + > ``default``. The env var wins when set to a truthy/falsy string; ovos_config + is optional, so any failure to read it falls back to ``default``. + + Kept layering-clean: mirrors ``ovos_bus_client.client.client._bus_flag`` + without importing from bus-client (bus-client depends on utils, not vice-versa). + """ + val = environ.get(env_var) + if val is not None: + return val.strip().lower() in ("1", "true", "yes", "on") + try: + from ovos_config import Configuration + return bool(Configuration().get("websocket", {}).get(config_key, default)) + except Exception: + return default + + +def _resolve_bus_flags(kwargs): + """Build the namespace ``NamespaceTranslator`` for a fake bus instance. + + An explicitly-passed ``modernize``/``emit_legacy`` kwarg wins (back-compat for + callers passing ``emit_legacy=True/False``); otherwise the flag is resolved via + env var -> ``websocket.*`` config -> default ``True``, matching the real client. + """ + modernize = kwargs.get("modernize", _UNSET) + if modernize is _UNSET: + modernize = _bus_flag("OVOS_BUS_MODERNIZE", "modernize", default=True) + emit_legacy = kwargs.get("emit_legacy", _UNSET) + if emit_legacy is _UNSET: + emit_legacy = _bus_flag("OVOS_BUS_EMIT_LEGACY", "emit_legacy", default=True) + return NamespaceTranslator(modernize=modernize, emit_legacy=emit_legacy) + + class FakeBus: def __init__(self, *args, **kwargs): self.started_running = False @@ -23,11 +64,10 @@ def __init__(self, *args, **kwargs): self.ee = kwargs.get("emitter") or EventEmitter() self.ee.on("error", self.on_error) # mirror MessageBusClient's namespace migration so the test/satellite - # double bridges legacy<->ovos.* topics identically. Both default on; - # override per-instance with modernize=/emit_legacy= kwargs. - self._translator = NamespaceTranslator( - modernize=kwargs.get("modernize", True), - emit_legacy=kwargs.get("emit_legacy", True)) + # double bridges legacy<->ovos.* topics identically. Flags resolve the + # same way the real client does: explicit modernize=/emit_legacy= kwarg + # wins, else env var -> websocket.* config -> default on. + self._translator = _resolve_bus_flags(kwargs) self._handler_guards = {} # handler -> shared mirror-guard self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...] self.on_open() @@ -327,6 +367,10 @@ def __init__(self, *args, **kwargs): self.session_id = "default" self.ee = kwargs.get("emitter") or EventEmitter() self.ee.on("error", self.on_error) + # mirror MessageBusClient's namespace migration (see FakeBus.__init__). + self._translator = _resolve_bus_flags(kwargs) + self._handler_guards = {} # handler -> shared mirror-guard + self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...] self.connected_event = asyncio.Event() self.connected_event.set() self.on_open() @@ -343,12 +387,41 @@ def __init__(self, *args, **kwargs): # ------------------------------------------------------------------ def on(self, msg_type, handler): + # wrap handlers on migrated topics so a handler subscribed to both the + # legacy and ovos.* topic fires once (the mirror is dropped) -- same as + # FakeBus.on / MessageBusClient.on. + if self._translator.is_migrated(msg_type): + guard = self._handler_guards.get(handler) + if guard is None: + guard = self._translator.new_mirror_guard() + self._handler_guards[handler] = guard + + def wrapped(message=None): + if guard(message): + return + return handler(message) + + self.ee.on(msg_type, wrapped) + self._dedup_registrations.setdefault(handler, []).append((msg_type, wrapped)) + return self.ee.on(msg_type, handler) def once(self, msg_type, handler): self.ee.once(msg_type, handler) def remove(self, msg_type, handler): + regs = self._dedup_registrations.get(handler) + if regs: + for ev, wrapped in [r for r in regs if r[0] == msg_type]: + try: + self.ee.remove_listener(ev, wrapped) + except Exception: + pass + regs.remove((ev, wrapped)) + if not regs: + self._dedup_registrations.pop(handler, None) + self._handler_guards.pop(handler, None) + return try: self.ee.remove_listener(msg_type, handler) except Exception: @@ -392,6 +465,16 @@ async def emit(self, message): self.ee.emit(message.msg_type, message) except Exception as e: LOG.exception(f"Error in event handler for '{message.msg_type}': {e}") + # namespace migration: also dispatch the counterpart topic(s) with the + # payload reshaped into each counterpart's shape -- same as FakeBus.emit. + for topic in self._translator.counterpart_topics(message.msg_type): + try: + translated = self._translator.translate_payload( + from_topic=message.msg_type, to_topic=topic, + data=message.data) + self.ee.emit(topic, message.forward(topic, translated)) + except Exception as e: + LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") self.on_message(message.serialize()) # ------------------------------------------------------------------ diff --git a/pyproject.toml b/pyproject.toml index 2a57e82e..07d396ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "rich-click~=1.7", "rich~=13.7", "python-dateutil", - "ovos-spec-tools>=0.14.0a1", + "ovos-spec-tools>=0.16.1a2", ] [project.urls] diff --git a/requirements/extras.txt b/requirements/extras.txt deleted file mode 100644 index 2f77edff..00000000 --- a/requirements/extras.txt +++ /dev/null @@ -1,9 +0,0 @@ -rapidfuzz>=3.6,<4.0 -ovos-plugin-manager>=0.0.25 -ovos-config>=0.0.12 -ovos-workshop>=0.0.13 -ovos_bus_client>=0.0.8 -langcodes -timezonefinder -oauthlib~=3.2 -orjson \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt deleted file mode 100644 index ec35ebf6..00000000 --- a/requirements/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -pexpect~=4.9 -requests~=2.26 -json_database>=0.10,<2.0.0 -kthread~=0.2 -watchdog -pyee>=8.0.0 -combo-lock~=0.2 -rich-click~=1.7 -rich~=13.7 -ovos-spec-tools>=0.14.0a1 \ No newline at end of file diff --git a/test/unittests/test_async_fakebus.py b/test/unittests/test_async_fakebus.py index e9b5f9e4..5dea4ed1 100644 --- a/test/unittests/test_async_fakebus.py +++ b/test/unittests/test_async_fakebus.py @@ -198,5 +198,70 @@ def test_run_in_thread_alias(self): self.assertTrue(bus.started_running) +class TestAsyncFakeBusNamespaceMigration(unittest.TestCase): + """AsyncFakeBus mirrors FakeBus / MessageBusClient namespace migration.""" + + def test_legacy_emit_reaches_spec_listener(self): + bus = AsyncFakeBus() # both flags default on + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(m.msg_type)) + _run(bus.emit(FakeMessage("speak", {"utterance": "hi"}))) + self.assertEqual(got, ["ovos.utterance.speak"]) # modernize bridged it + + def test_spec_emit_reaches_legacy_listener(self): + bus = AsyncFakeBus() + got = [] + bus.on("speak", lambda m: got.append(m.msg_type)) + _run(bus.emit(FakeMessage("ovos.utterance.speak", {"utterance": "hi"}))) + self.assertEqual(got, ["speak"]) # emit_legacy bridged it + + def test_counterpart_payload_is_translated(self): + # a spec listener on the counterpart of a SHAPE-CHANGING legacy topic + # receives the payload reshaped into ITS shape. + bus = AsyncFakeBus() + got = [] + bus.on("ovos.intent.handler.start", lambda m: got.append(dict(m.data))) + _run(bus.emit(FakeMessage("mycroft.skill.handler.start", + {"handler": "HelloIntent"}))) + self.assertEqual(got, [{"intent_name": "HelloIntent"}]) + self.assertNotIn("handler", got[0]) + + def test_dual_listener_fires_once(self): + bus = AsyncFakeBus() + calls = [] + handler = lambda m: calls.append(m.msg_type) + bus.on("speak", handler) + bus.on("ovos.utterance.speak", handler) + _run(bus.emit(FakeMessage("speak", {"utterance": "hi"}))) + self.assertEqual(len(calls), 1) # mirror deduped + + def test_distinct_listeners_each_fire_once(self): + bus = AsyncFakeBus() + legacy, spec = [], [] + bus.on("speak", lambda m: legacy.append(1)) + bus.on("ovos.utterance.speak", lambda m: spec.append(1)) + _run(bus.emit(FakeMessage("speak", {"utterance": "hi"}))) + self.assertEqual((len(legacy), len(spec)), (1, 1)) + + def test_flags_off_no_bridging(self): + bus = AsyncFakeBus(modernize=False, emit_legacy=False) + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(m.msg_type)) + _run(bus.emit(FakeMessage("speak", {"utterance": "hi"}))) + self.assertEqual(got, []) + + def test_remove_cleans_up(self): + bus = AsyncFakeBus() + calls = [] + handler = lambda m: calls.append(1) + bus.on("speak", handler) + bus.on("ovos.utterance.speak", handler) + bus.remove("speak", handler) + bus.remove("ovos.utterance.speak", handler) + self.assertNotIn(handler, bus._handler_guards) + _run(bus.emit(FakeMessage("speak", {"utterance": "hi"}))) + self.assertEqual(calls, []) + + if __name__ == "__main__": unittest.main() diff --git a/test/unittests/test_fakebus_namespace_migration.py b/test/unittests/test_fakebus_namespace_migration.py index fa742edf..dca99ee1 100644 --- a/test/unittests/test_fakebus_namespace_migration.py +++ b/test/unittests/test_fakebus_namespace_migration.py @@ -1,8 +1,14 @@ """FakeBus mirrors MessageBusClient's legacy<->ovos.* namespace migration, so e2e/satellite tests exercise the real cross-namespace behaviour.""" +import asyncio import unittest +from unittest.mock import patch -from ovos_utils.fakebus import FakeBus, Message +from ovos_utils.fakebus import AsyncFakeBus, FakeBus, Message + + +def _run(coro): + return asyncio.run(coro) class TestFakeBusNamespaceMigration(unittest.TestCase): @@ -92,5 +98,52 @@ def test_remove_cleans_up(self): self.assertEqual(calls, []) +class TestFakeBusFlagResolution(unittest.TestCase): + """When the kwarg is omitted, flags resolve via env -> websocket.* config -> + default True, matching MessageBusClient._bus_flag. An explicit kwarg wins.""" + + def _legacy_mirrored(self, bus): + # emit a legacy topic; if emit_legacy bridging is on a spec listener fires + got = [] + bus.on("ovos.utterance.speak", lambda m: got.append(m.msg_type)) + if isinstance(bus, AsyncFakeBus): + _run(bus.emit(Message("speak", {"utterance": "hi"}))) + else: + bus.emit(Message("speak", {"utterance": "hi"})) + return got == ["ovos.utterance.speak"] + + def test_default_true_no_env_mirrors(self): + with patch.dict("os.environ", {}, clear=False): + import os + os.environ.pop("OVOS_BUS_MODERNIZE", None) + os.environ.pop("OVOS_BUS_EMIT_LEGACY", None) + self.assertTrue(self._legacy_mirrored(FakeBus())) + self.assertTrue(self._legacy_mirrored(AsyncFakeBus())) + + def test_env_false_disables_mirror(self): + with patch.dict("os.environ", + {"OVOS_BUS_MODERNIZE": "false", + "OVOS_BUS_EMIT_LEGACY": "false"}): + self.assertFalse(self._legacy_mirrored(FakeBus())) + self.assertFalse(self._legacy_mirrored(AsyncFakeBus())) + + def test_explicit_kwarg_beats_env(self): + # env says off, but an explicit modernize=True kwarg still mirrors + with patch.dict("os.environ", + {"OVOS_BUS_MODERNIZE": "false", + "OVOS_BUS_EMIT_LEGACY": "false"}): + self.assertTrue(self._legacy_mirrored(FakeBus(modernize=True))) + self.assertTrue(self._legacy_mirrored(AsyncFakeBus(modernize=True))) + + def test_explicit_false_kwarg_beats_unset_env(self): + import os + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("OVOS_BUS_MODERNIZE", None) + os.environ.pop("OVOS_BUS_EMIT_LEGACY", None) + # default would mirror; explicit modernize=False suppresses it + self.assertFalse(self._legacy_mirrored(FakeBus(modernize=False))) + self.assertFalse(self._legacy_mirrored(AsyncFakeBus(modernize=False))) + + if __name__ == "__main__": unittest.main() From 232aecc442e3543321558c63b15bb07a11be348d Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:49:35 +0000 Subject: [PATCH 27/64] Increment Version to 0.13.0a1 --- ovos_utils/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 62e08801..4e692030 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 12 -VERSION_BUILD = 2 +VERSION_MINOR = 13 +VERSION_BUILD = 0 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 6ddc21eea2dbcd20cd81d3cc1fd828ec5edf4b1b Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:50:03 +0000 Subject: [PATCH 28/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d88ffb..2f5f335e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.0a1) (2026-06-27) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.2a1...0.13.0a1) + +**Merged pull requests:** + +- feat: AsyncFakeBus namespace migration + env/config flag parity [\#387](https://github.com/OpenVoiceOS/ovos-utils/pull/387) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.12.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.12.2a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.1a1...0.12.2a1) From 3b208ffb1d9447f82cd8a04738d8453232bf3cb2 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:45:50 +0100 Subject: [PATCH 29/64] fix: target a real shape-changing pair in namespace-migration tests (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FakeBus shape-changing reshape tests asserted a mycroft.skill.handler.* <-> ovos.intent.handler.* migration that does not (and should not) exist — the handler-lifecycle trio is orchestrator-owned spec topics, not a rename of the legacy per-skill handler events, so it was correctly absent from ovos-spec-tools' MIGRATION_MAP. The tests therefore failed (the spec listener was never reached). Retarget them at an actual shape-changing pair from MIGRATION_PAYLOAD_TRANSFORMS: detach_intent <-> ovos.intent.deregister, which splits the compound "skill:intent" name into skill_id + intent_name (and rejoins it in reverse). Same reshape coverage, real mapping. Co-authored-by: Claude Opus 4.8 --- test/unittests/test_async_fakebus.py | 12 ++++++------ .../test_fakebus_namespace_migration.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/test/unittests/test_async_fakebus.py b/test/unittests/test_async_fakebus.py index 5dea4ed1..a3a1f79d 100644 --- a/test/unittests/test_async_fakebus.py +++ b/test/unittests/test_async_fakebus.py @@ -217,14 +217,14 @@ def test_spec_emit_reaches_legacy_listener(self): def test_counterpart_payload_is_translated(self): # a spec listener on the counterpart of a SHAPE-CHANGING legacy topic - # receives the payload reshaped into ITS shape. + # receives the payload reshaped into ITS shape. detach_intent -> + # ovos.intent.deregister splits "skill:intent" into skill_id/intent_name. bus = AsyncFakeBus() got = [] - bus.on("ovos.intent.handler.start", lambda m: got.append(dict(m.data))) - _run(bus.emit(FakeMessage("mycroft.skill.handler.start", - {"handler": "HelloIntent"}))) - self.assertEqual(got, [{"intent_name": "HelloIntent"}]) - self.assertNotIn("handler", got[0]) + bus.on("ovos.intent.deregister", lambda m: got.append(dict(m.data))) + _run(bus.emit(FakeMessage("detach_intent", + {"intent_name": "skill.foo:HelloIntent"}))) + self.assertEqual(got, [{"skill_id": "skill.foo", "intent_name": "HelloIntent"}]) def test_dual_listener_fires_once(self): bus = AsyncFakeBus() diff --git a/test/unittests/test_fakebus_namespace_migration.py b/test/unittests/test_fakebus_namespace_migration.py index dca99ee1..3eb4b79b 100644 --- a/test/unittests/test_fakebus_namespace_migration.py +++ b/test/unittests/test_fakebus_namespace_migration.py @@ -60,23 +60,24 @@ def test_unmapped_topic_untouched(self): def test_shape_changing_payload_reshaped_for_spec_listener(self): # a spec listener on the counterpart of a SHAPE-CHANGING legacy topic # receives the payload in ITS shape, not a verbatim legacy copy. + # detach_intent -> ovos.intent.deregister splits the compound + # "skill:intent" name into skill_id + intent_name. bus = FakeBus() got = [] - bus.on("ovos.intent.handler.start", lambda m: got.append(dict(m.data))) - bus.emit(Message("mycroft.skill.handler.start", {"handler": "HelloIntent"})) + bus.on("ovos.intent.deregister", lambda m: got.append(dict(m.data))) + bus.emit(Message("detach_intent", {"intent_name": "skill.foo:HelloIntent"})) self.assertEqual(len(got), 1) - # reshaped to the spec shape ({"intent_name": ...}), NOT {"handler": ...} - self.assertEqual(got[0], {"intent_name": "HelloIntent"}) - self.assertNotIn("handler", got[0]) + self.assertEqual(got[0], {"skill_id": "skill.foo", "intent_name": "HelloIntent"}) def test_shape_changing_payload_reshaped_for_legacy_listener(self): bus = FakeBus() got = [] - bus.on("mycroft.skill.handler.start", lambda m: got.append(dict(m.data))) - bus.emit(Message("ovos.intent.handler.start", + bus.on("detach_intent", lambda m: got.append(dict(m.data))) + bus.emit(Message("ovos.intent.deregister", {"skill_id": "skill.foo", "intent_name": "HelloIntent"})) self.assertEqual(len(got), 1) - self.assertEqual(got[0].get("handler"), "HelloIntent") # legacy shape + # rejoined to the legacy compound shape + self.assertEqual(got[0].get("intent_name"), "skill.foo:HelloIntent") def test_payload_compatible_rename_delivered_equivalent(self): bus = FakeBus() From 7750b4fa9d016784a89ba11a688f56d1844f87ac Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:46:05 +0000 Subject: [PATCH 30/64] Increment Version to 0.13.1a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 4e692030..3bc26260 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 0 +VERSION_BUILD = 1 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 2866a88afa22f4526cc123207e11113d7f7910c7 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:46:31 +0000 Subject: [PATCH 31/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5f335e..873877a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.1a1) (2026-06-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.0a1...0.13.1a1) + +**Merged pull requests:** + +- fix: target a real shape-changing pair in namespace-migration tests [\#390](https://github.com/OpenVoiceOS/ovos-utils/pull/390) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.0a1) (2026-06-27) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.12.2a1...0.13.0a1) From 283dac758beae710ce513793193bad8ac5302ef1 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:48:12 +0100 Subject: [PATCH 32/64] fix: drop deprecated make_default from FakeBus default-session sync (#389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FakeBus mirrors the real MessageBusClient's default-session side effects. ovos-bus-client deprecated SessionManager.update(make_default=True); the broadcast payload is already default_session.serialize() (id == "default"), so plain update(sess) syncs the default identically via the singleton store — without tripping the deprecation warning on every FakeBus default sync (notably in the e2e suites). Co-authored-by: Claude Opus 4.8 --- ovos_utils/fakebus.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 8b8d272f..7fb523f4 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -156,7 +156,10 @@ def on_default_session_update(self, message): from ovos_bus_client.session import Session, SessionManager new_session = message.data["session_data"] sess = Session.deserialize(new_session) - SessionManager.update(sess, make_default=True) + # payload is default_session.serialize() (id == "default"); the + # SessionManager singleton syncs default_session by id, so the + # deprecated make_default flag is not needed. + SessionManager.update(sess) LOG.debug("synced default_session") except ImportError: pass # don't care @@ -506,7 +509,10 @@ def on_default_session_update(self, message): from ovos_bus_client.session import Session, SessionManager new_session = message.data["session_data"] sess = Session.deserialize(new_session) - SessionManager.update(sess, make_default=True) + # payload is default_session.serialize() (id == "default"); the + # SessionManager singleton syncs default_session by id, so the + # deprecated make_default flag is not needed. + SessionManager.update(sess) LOG.debug("synced default_session") except ImportError: pass # don't care From 6d14fb92b5c31f708bf1065ec31b0bed47917d29 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:48:30 +0000 Subject: [PATCH 33/64] Increment Version to 0.13.2a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 3bc26260..b46a4929 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 1 +VERSION_BUILD = 2 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 49e9f207ad6f9c2d06d51be1e5151ee83f541bd0 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:48:57 +0000 Subject: [PATCH 34/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 873877a5..c2eb8390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.2a1) (2026-06-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.1a1...0.13.2a1) + +**Merged pull requests:** + +- fix: drop deprecated make\_default from FakeBus default-session sync [\#389](https://github.com/OpenVoiceOS/ovos-utils/pull/389) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.1a1) (2026-06-29) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.0a1...0.13.1a1) From 952204250db3a877ffe11a7ba1d232424c5c1cec Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:18:24 +0100 Subject: [PATCH 35/64] fix: FakeBus folds the default session like any other (drop owner-only) (#393) * fix: FakeBus folds the default session like any other (drop owner-only) on_message no longer special-cases the default id: every session folds onto the SessionManager singleton (value-passing; nothing is owner-only), matching the spec-tools SessionManager and the real MessageBusClient. * build: require ovos-bus-client>=2.6.2a2 (consistent SessionManager registry) The FakeBus session integration (emit injects a session; the namespace-mirror counterpart goes through Message.forward, which now stamps the session in spec-tools >=1.2.x) needs ovos-bus-client's one-class SessionManager so emit and forward read the SAME sessions registry. Under 2.6.2a1 the registry diverged (subclass shadowing), so the original and mirrored copies carried different session bytes and the receive-side mirror dedup fingerprint mismatched (handler fired twice). 2.6.2a2 grafts onto the single registry, restoring consistency. --- ovos_logs_console_script | 0 ovos_utils/fakebus.py | 14 ++++++++------ pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 ovos_logs_console_script diff --git a/ovos_logs_console_script b/ovos_logs_console_script new file mode 100644 index 00000000..e69de29b diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 7fb523f4..3e4d6dd2 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -145,9 +145,10 @@ def on_message(self, *args): try: # replicate side effects from ovos_bus_client.session import Session, SessionManager sess = Session.from_message(parsed_message) - if sess.session_id != "default": - # 'default' can only be updated by core - SessionManager.update(sess) + # every session — including the default id — folds onto the singleton + # (value-passing; nothing is owner-only, matching the spec-tools + # SessionManager and the real MessageBusClient) + SessionManager.update(sess) except ImportError: pass # don't care @@ -498,9 +499,10 @@ def on_message(self, *args): try: # replicate side effects from ovos_bus_client.session import Session, SessionManager sess = Session.from_message(parsed_message) - if sess.session_id != "default": - # 'default' can only be updated by core - SessionManager.update(sess) + # every session — including the default id — folds onto the singleton + # (value-passing; nothing is owner-only, matching the spec-tools + # SessionManager and the real MessageBusClient) + SessionManager.update(sess) except ImportError: pass # don't care diff --git a/pyproject.toml b/pyproject.toml index 07d396ae..597e101a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ extras = [ "ovos-plugin-manager>=0.0.25", "ovos-config>=0.0.12", "ovos-workshop>=0.0.13", - "ovos_bus_client>=0.0.8", + "ovos_bus_client>=2.6.2a2", "langcodes", "timezonefinder", "oauthlib~=3.2", From 8f38d5504835198849dbb5cf26f3e2adfb702400 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:18:38 +0000 Subject: [PATCH 36/64] Increment Version to 0.13.3a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index b46a4929..ea59d7f0 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 2 +VERSION_BUILD = 3 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 1c6ad402994658906973401d271257559eaeb550 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:19:06 +0000 Subject: [PATCH 37/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2eb8390..91fec452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.3a1) (2026-06-29) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.2a1...0.13.3a1) + +**Merged pull requests:** + +- fix: FakeBus folds the default session like any other \(drop owner-only\) [\#393](https://github.com/OpenVoiceOS/ovos-utils/pull/393) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.2a1) (2026-06-29) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.1a1...0.13.2a1) From 55d613334f0fe81205dc080bfc8e5d3003694d4d Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:39:04 +0100 Subject: [PATCH 38/64] fix: FakeBus folds the message session BEFORE handlers, not after (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FakeBus.emit stamped the message's session at emit time, ran the bus handlers (which may mutate the SessionManager singleton in place — e.g. handle_add_context injecting context frames, handle_session_sync merging intent_context), and THEN called on_message to fold that same message's session snapshot back onto the singleton. Because the snapshot was stamped BEFORE the handlers ran, the post-handler fold wholesale-replaced the singleton's state (Session.update_from is last-writer-wins per field) with a pre-mutation snapshot — silently wiping every in-place / synced mutation the handlers just made. This broke any consumer relying on handler-side session mutations, most visibly ovos-workshop IntentLayers (intent-layer gating via the legacy add_context -> sess.context frame mechanism): activate_layer() injected the layer token into the default-session singleton, the originating utterance's own post-handler fold then wiped it, and gated intents never matched. Two ovos-workshop test_intent_layers_e2e.py tests regressed to xfail at the same time the SessionManager singleton-registry fold landed. The real MessageBusClient.on_message folds FIRST and dispatches to handlers AFTER (receive -> fold -> handle). FakeBus.emit did the reverse. Reorder FakeBus/AsyncFakeBus.emit to fold on_message BEFORE running handlers, and drop the trailing post-handler on_message fold (a single-process harness has no separate receiver: re-folding its own emit after the handlers have run is a spec-violating self-broadcast-back wipe). With the receive-order fold the singleton reflects the incoming snapshot before handlers mutate it, and those mutations survive to the next message. This is spec-faithful (every session, default included, still folds — no owner-only guard) and backward compatible (the legacy add_context/sess.context mechanism works again); it un-breaks the ovos-workshop intent-layers e2e suite without any workshop-side change. --- ovos_utils/fakebus.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index 3e4d6dd2..cc80c98f 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -110,6 +110,17 @@ def emit(self, message): message.context["session"] = sess.serialize() except ImportError: # don't care message.context["session"] = {"session_id": self.session_id} + # Fold the incoming message's session onto the SessionManager singleton + # BEFORE running handlers, matching the real MessageBusClient.on_message + # order (receive -> fold -> dispatch to handlers). Folding AFTER handlers + # would wipe any in-place / synced session mutation the handlers made + # (handle_session_sync merging intent_context, handle_add_context injecting + # context frames, ...), because the message's session snapshot was + # stamped at emit-time, before those mutations landed — a spec-violating + # self-broadcast-back wipe. The single-process harness has no separate + # receiver, so it must not re-fold its own emit once the handlers have + # run. + self.on_message(message.serialize()) self.ee.emit("message", message.serialize()) try: self.ee.emit(message.msg_type, message) @@ -129,7 +140,6 @@ def emit(self, message): self.ee.emit(topic, message.forward(topic, translated)) except Exception as e: LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") - self.on_message(message.serialize()) def on_message(self, *args): """ @@ -464,6 +474,10 @@ async def emit(self, message): message.context["session"] = sess.serialize() except ImportError: # don't care message.context["session"] = {"session_id": self.session_id} + # Fold BEFORE handlers — see FakeBus.emit for the rationale (a post- + # handler self-broadcast-back fold wipes the handlers' in-place / synced + # session mutations with the stale emit-time snapshot). + self.on_message(message.serialize()) self.ee.emit("message", message.serialize()) try: self.ee.emit(message.msg_type, message) @@ -479,7 +493,6 @@ async def emit(self, message): self.ee.emit(topic, message.forward(topic, translated)) except Exception as e: LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") - self.on_message(message.serialize()) # ------------------------------------------------------------------ # Sync helpers used internally — same as FakeBus From 6fb9205750e5a918e241d3177a96c11c4bf3557f Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:39:18 +0000 Subject: [PATCH 39/64] Increment Version to 0.13.4a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index ea59d7f0..f37bf31c 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 3 +VERSION_BUILD = 4 VERSION_ALPHA = 1 # END_VERSION_BLOCK From e197be1dd0673325685a1e2fc29de2acbcfd4b95 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:39:49 +0000 Subject: [PATCH 40/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fec452..16c3a9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.4a1) (2026-07-02) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.3a1...0.13.4a1) + +**Merged pull requests:** + +- fix: FakeBus folds the message session BEFORE handlers, not after [\#396](https://github.com/OpenVoiceOS/ovos-utils/pull/396) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.3a1) (2026-06-29) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.2a1...0.13.3a1) From 4e77ca78a5585450ad5413766692c38a71e0e772 Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Thu, 16 Jul 2026 17:47:41 +0100 Subject: [PATCH 41/64] fix: log each unique deprecation warning only once log_deprecation and the deprecated decorator emitted a warning on every call, flooding logs when a deprecated helper runs in a loop (thousands of identical lines in a single service boot). Deduplicate per unique (message, caller location): the first occurrence logs exactly as before and repeats are suppressed. Co-Authored-By: Claude Fable 5 --- ovos_utils/log.py | 10 ++++++++++ test/unittests/test_log.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/ovos_utils/log.py b/ovos_utils/log.py index f5941cf6..df389929 100644 --- a/ovos_utils/log.py +++ b/ovos_utils/log.py @@ -258,6 +258,11 @@ def get_logs_config(service_name: Optional[str] = None, return _logs_conf +# Tracks (message, caller) pairs already logged so each unique deprecation +# is only reported once instead of spamming the logs on every call +_logged_deprecations = set() + + def log_deprecation(log_message: str = "DEPRECATED", deprecation_version: str = "Unknown", func_name: str = None, @@ -297,6 +302,11 @@ def log_deprecation(log_message: str = "DEPRECATED", if not name.startswith(origin_module): call_info = f"{name}:{call.lineno}" break + # Only log each unique deprecation (message + caller) once + dedupe_key = (log_message, log_name, call_info) + if dedupe_key in _logged_deprecations: + return + _logged_deprecations.add(dedupe_key) # Explicitly format log to print origin log reference LOG.create_logger(log_name).warning( f"Deprecation version={deprecation_version}. Caller={call_info}. " diff --git a/test/unittests/test_log.py b/test/unittests/test_log.py index f816cf1b..15419dc9 100644 --- a/test/unittests/test_log.py +++ b/test/unittests/test_log.py @@ -184,6 +184,39 @@ def _deprecated_function(test_arg): self.assertIn('version=1.0.0', log_msg, log_msg) self.assertIn('test deprecation', log_msg, log_msg) + @patch("ovos_utils.log.LOG.create_logger") + def test_log_deprecation_dedupe(self, create_logger): + fake_log = Mock() + log_warning = fake_log.warning + create_logger.return_value = fake_log + import ovos_utils.log + from ovos_utils.log import log_deprecation, deprecated + ovos_utils.log._logged_deprecations.clear() + + # Repeated calls from the same caller only log once + for _ in range(10): + log_deprecation("repeated deprecation") + log_warning.assert_called_once() + log_msg = log_warning.call_args[0][0] + self.assertIn('repeated deprecation', log_msg, log_msg) + + # A different message still logs + log_deprecation("other deprecation") + self.assertEqual(log_warning.call_count, 2) + log_msg = log_warning.call_args[0][0] + self.assertIn('other deprecation', log_msg, log_msg) + + # Deprecated decorator is also deduplicated + @deprecated("decorated deprecation", "1.0.0") + def _deprecated_function(): + pass + + for _ in range(10): + _deprecated_function() + self.assertEqual(log_warning.call_count, 3) + log_msg = log_warning.call_args[0][0] + self.assertIn('decorated deprecation', log_msg, log_msg) + @patch("ovos_utils.log.get_logs_config") @patch("ovos_utils.log.LOG") def test_monitor_log_level(self, log, get_config): From 45ac6dd621654ad5d3592d98c7e339096391d62d Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:50:27 +0000 Subject: [PATCH 42/64] Increment Version to 0.13.5a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index f37bf31c..7d806345 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 4 +VERSION_BUILD = 5 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 988134c4bae945b6b283c35a371f0b07250ed724 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:51:06 +0000 Subject: [PATCH 43/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c3a9a3..c8fb69bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.5a1) (2026-07-16) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.4a1...0.13.5a1) + +**Merged pull requests:** + +- fix: log each unique deprecation warning only once [\#398](https://github.com/OpenVoiceOS/ovos-utils/pull/398) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.4a1) (2026-07-02) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.3a1...0.13.4a1) From 6fe8f6a934114b0e8e2941074accb46f738d8640 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:35:57 +0100 Subject: [PATCH 44/64] fix: ovos-logs CLI creates stray file in cwd on every invocation (#400) LOGLOCK was instantiated at import time as ComboLock("ovos_logs_console_script"), a bare relative path. ComboLock unconditionally creates the lock file if it doesn't exist relative to the process's current working directory, so simply running `ovos-logs --help` (or importing ovos_utils.log_parser at all) leaves behind an empty file named `ovos_logs_console_script` wherever the command was invoked from. Switch to combo_lock.NamedLock, which resolves the lock file to a proper runtime directory (RAM disk or tempdir) instead of the cwd. Added a regression test that runs the CLI (and a bare import) via subprocess in a temporary cwd and asserts no stray files appear. Authored with AI assistance (Claude). --- ovos_utils/log_parser.py | 4 +-- test/unittests/test_log_parser.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/ovos_utils/log_parser.py b/ovos_utils/log_parser.py index 562ba356..4aa3cb8f 100644 --- a/ovos_utils/log_parser.py +++ b/ovos_utils/log_parser.py @@ -11,7 +11,7 @@ from rich.style import Style from rich.table import Table import pydoc -from combo_lock import ComboLock +from combo_lock import NamedLock try: from ovos_config import Configuration @@ -25,7 +25,7 @@ TIME_FORMAT = '%Y-%m-%d %H:%M:%S.%f' -LOGLOCK = ComboLock("ovos_logs_console_script") +LOGLOCK = NamedLock("ovos_logs_console_script") @dataclass diff --git a/test/unittests/test_log_parser.py b/test/unittests/test_log_parser.py index 48a02714..46ae51d1 100644 --- a/test/unittests/test_log_parser.py +++ b/test/unittests/test_log_parser.py @@ -412,5 +412,52 @@ def test_reads_log_for_last_load(self) -> None: self.assertGreater(result, datetime.fromtimestamp(0)) +class TestOvosLogsCLINoStrayFiles(unittest.TestCase): + """Regression test: importing/using the ovos-logs CLI must not create + a stray lock file in the current working directory. + + Previously `LOGLOCK = ComboLock("ovos_logs_console_script")` was + instantiated at module import time with a bare relative path, which + made ComboLock create (and leave behind) an empty file named + `ovos_logs_console_script` in whatever directory the process was + started from. + """ + + def test_help_does_not_create_stray_file(self) -> None: + import subprocess + import sys + + with tempfile.TemporaryDirectory() as tmpdir: + before = set(os.listdir(tmpdir)) + subprocess.run( + [sys.executable, "-c", + "import sys; sys.argv=['ovos-logs', '--help']; " + "from ovos_utils.log_parser import ovos_logs; " + "ovos_logs()"], + cwd=tmpdir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + after = set(os.listdir(tmpdir)) + self.assertEqual(before, after, + f"stray files created in cwd: {after - before}") + + def test_importing_log_parser_does_not_create_stray_file(self) -> None: + import subprocess + import sys + + with tempfile.TemporaryDirectory() as tmpdir: + before = set(os.listdir(tmpdir)) + subprocess.run( + [sys.executable, "-c", "import ovos_utils.log_parser"], + cwd=tmpdir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + after = set(os.listdir(tmpdir)) + self.assertEqual(before, after, + f"stray files created in cwd: {after - before}") + + if __name__ == "__main__": unittest.main() From 299e83af8b9bd92f1f4793833d1bc838da203c0a Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:36:10 +0000 Subject: [PATCH 45/64] Increment Version to 0.13.6a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 7d806345..783e6cc7 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 5 +VERSION_BUILD = 6 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 2bae79cd65e541350ece9fb2e708f058c7f2554e Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:36:43 +0000 Subject: [PATCH 46/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8fb69bf..7d382056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.6a1) (2026-07-23) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.5a1...0.13.6a1) + +**Merged pull requests:** + +- fix: ovos-logs CLI leaves stray file in cwd [\#400](https://github.com/OpenVoiceOS/ovos-utils/pull/400) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.5a1) (2026-07-16) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.4a1...0.13.5a1) From cf103e0cca93502d97d57f0f06072f655000aa10 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:41:55 +0100 Subject: [PATCH 47/64] fix: replace removed distutils.spawn with shutil.which (Python 3.12+) (#402) distutils was removed from the standard library in Python 3.12, so ovos_utils.sound and ovos_utils.device_input fail to import with ModuleNotFoundError: No module named 'distutils'. shutil.which is a drop-in stdlib replacement for distutils.spawn.find_executable. Co-authored-by: Claude Fable 5 --- ovos_utils/device_input.py | 10 +++++----- ovos_utils/sound.py | 18 +++++++++--------- test/unittests/test_device_input.py | 28 ++++++++++++++-------------- test/unittests/test_sound.py | 22 +++++++++++----------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/ovos_utils/device_input.py b/ovos_utils/device_input.py index 9c7a008a..f001a9a5 100644 --- a/ovos_utils/device_input.py +++ b/ovos_utils/device_input.py @@ -1,5 +1,5 @@ import subprocess -from distutils.spawn import find_executable +from shutil import which from ovos_utils.gui import is_gui_installed from ovos_utils.log import LOG @@ -9,7 +9,7 @@ class InputDeviceHelper: def __init__(self) -> None: self.libinput_devices_list = [] self.xinput_devices_list = [] - if not find_executable("libinput") and not find_executable("xinput"): + if not which("libinput") and not which("xinput"): LOG.warning("Could not find libinput, input device detection will be inaccurate") # ToDo: add support for discovering the input device based of a connected @@ -68,7 +68,7 @@ def _build_linput_devices_list(self): }) def _get_libinput_devices_list(self): - if find_executable("libinput"): + if which("libinput"): try: self._build_linput_devices_list() except Exception: @@ -98,7 +98,7 @@ def _build_xinput_devices_list(self): self.xinput_devices_list.append(dev) def _get_xinput_devices_list(self): - if find_executable("xinput"): + if which("xinput"): try: self._build_xinput_devices_list() except Exception: @@ -113,7 +113,7 @@ def get_input_device_list(self): return self.libinput_devices_list + self.xinput_devices_list def can_use_touch_mouse(self): - if not find_executable("libinput") and not find_executable("xinput"): + if not which("libinput") and not which("xinput"): # if gui installed assume we have a mouse # otherwise let's assume we are a server or something... return is_gui_installed() diff --git a/ovos_utils/sound.py b/ovos_utils/sound.py index bf87b648..bfdc640a 100644 --- a/ovos_utils/sound.py +++ b/ovos_utils/sound.py @@ -5,7 +5,7 @@ from os.path import isfile from typing import Optional -from distutils.spawn import find_executable +from shutil import which from ovos_utils.log import LOG @@ -39,28 +39,28 @@ def _find_player(uri): _, ext = os.path.splitext(uri) # scan installed executables that can handle playback - sox_play = find_executable("play") + sox_play = which("play") # sox should handle almost every format, but fails in some urls if sox_play: return sox_play + f" --type {ext} %1" # determine best available player - ogg123_play = find_executable("ogg123") + ogg123_play = which("ogg123") if "ogg" in ext and ogg123_play: return ogg123_play + " -q %1" - pw_play = find_executable("pw-play") + pw_play = which("pw-play") # pw_play handles both wav and mp3 if pw_play: return pw_play + " %1" # wav file if 'wav' in ext: - pulse_play = find_executable("paplay") + pulse_play = which("paplay") if pulse_play: return pulse_play + " %1" - alsa_play = find_executable("aplay") + alsa_play = which("aplay") if alsa_play: return alsa_play + " %1" # guess mp3 - mpg123_play = find_executable("mpg123") + mpg123_play = which("mpg123") if mpg123_play: return mpg123_play + " %1" LOG.error("Can't find player for: %s", uri) @@ -135,14 +135,14 @@ def get_sound_duration(path: str, base_dir: Optional[str] = "") -> float: frames = f.getnframes() rate = f.getframerate() return frames / float(rate) - ffprobe = find_executable("ffprobe") + ffprobe = which("ffprobe") if ffprobe: args = (ffprobe, "-show_entries", "format=duration", "-i", path) popen = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) popen.wait() output = popen.stdout.read().decode("utf-8") return float(output.split("duration=")[-1].split("\n")[0]) - media_info = find_executable("mediainfo") + media_info = which("mediainfo") if media_info: args = (media_info, path) popen = subprocess.Popen(args, stdout=subprocess.PIPE) diff --git a/test/unittests/test_device_input.py b/test/unittests/test_device_input.py index 00e22c17..b39cd226 100644 --- a/test/unittests/test_device_input.py +++ b/test/unittests/test_device_input.py @@ -26,7 +26,7 @@ except ImportError: distutils_stub = types.ModuleType("distutils") spawn_stub = types.ModuleType("distutils.spawn") - spawn_stub.find_executable = lambda x: None + spawn_stub.which = lambda x: None distutils_stub.spawn = spawn_stub sys.modules["distutils"] = distutils_stub sys.modules["distutils.spawn"] = spawn_stub @@ -35,7 +35,7 @@ class TestInputDeviceHelper(unittest.TestCase): """Tests for InputDeviceHelper class.""" - @patch("ovos_utils.device_input.find_executable", return_value=None) + @patch("ovos_utils.device_input.which", return_value=None) def test_init_no_executables(self, mock_find: MagicMock) -> None: """InputDeviceHelper should initialise with empty device lists.""" from ovos_utils.device_input import InputDeviceHelper @@ -43,7 +43,7 @@ def test_init_no_executables(self, mock_find: MagicMock) -> None: self.assertEqual(helper.libinput_devices_list, []) self.assertEqual(helper.xinput_devices_list, []) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") @patch("subprocess.check_output") def test_build_libinput_devices_list(self, mock_output: MagicMock, mock_find: MagicMock) -> None: @@ -63,7 +63,7 @@ def test_build_libinput_devices_list(self, mock_output: MagicMock, self.assertEqual(dev["Device"], "My Keyboard") self.assertIn("keyboard", dev["Capabilities"]) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") @patch("subprocess.check_output") def test_build_libinput_multiple_capabilities(self, mock_output: MagicMock, mock_find: MagicMock) -> None: @@ -82,7 +82,7 @@ def test_build_libinput_multiple_capabilities(self, mock_output: MagicMock, self.assertIsInstance(caps, list) self.assertGreater(len(caps), 1) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") @patch("subprocess.check_output", side_effect=Exception("libinput failed")) def test_get_libinput_devices_exception(self, mock_output: MagicMock, mock_find: MagicMock) -> None: @@ -93,7 +93,7 @@ def test_get_libinput_devices_exception(self, mock_output: MagicMock, result = helper._get_libinput_devices_list() self.assertEqual(result, []) - @patch("ovos_utils.device_input.find_executable", return_value=None) + @patch("ovos_utils.device_input.which", return_value=None) def test_get_libinput_devices_no_executable(self, mock_find: MagicMock) -> None: """_get_libinput_devices_list should return empty list when libinput not found.""" from ovos_utils.device_input import InputDeviceHelper @@ -101,7 +101,7 @@ def test_get_libinput_devices_no_executable(self, mock_find: MagicMock) -> None: result = helper._get_libinput_devices_list() self.assertEqual(result, []) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") @patch("subprocess.check_output") def test_build_xinput_devices_list(self, mock_output: MagicMock, mock_find: MagicMock) -> None: @@ -117,7 +117,7 @@ def test_build_xinput_devices_list(self, mock_output: MagicMock, helper._build_xinput_devices_list() self.assertGreater(len(helper.xinput_devices_list), 0) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") @patch("subprocess.check_output", side_effect=Exception("xinput failed")) def test_get_xinput_devices_exception(self, mock_output: MagicMock, mock_find: MagicMock) -> None: @@ -128,7 +128,7 @@ def test_get_xinput_devices_exception(self, mock_output: MagicMock, result = helper._get_xinput_devices_list() self.assertEqual(result, []) - @patch("ovos_utils.device_input.find_executable", return_value=None) + @patch("ovos_utils.device_input.which", return_value=None) def test_get_xinput_devices_no_executable(self, mock_find: MagicMock) -> None: """_get_xinput_devices_list should return empty list when xinput not found.""" from ovos_utils.device_input import InputDeviceHelper @@ -136,7 +136,7 @@ def test_get_xinput_devices_no_executable(self, mock_find: MagicMock) -> None: result = helper._get_xinput_devices_list() self.assertEqual(result, []) - @mock.patch("ovos_utils.device_input.find_executable") + @mock.patch("ovos_utils.device_input.which") def test_can_use_touch_mouse(self, find_exec: MagicMock) -> None: """can_use_touch_mouse should detect touch/mouse/tablet/pointer/gesture.""" from ovos_utils.device_input import InputDeviceHelper @@ -164,7 +164,7 @@ def test_can_use_touch_mouse(self, find_exec: MagicMock) -> None: dev_input.xinput_devices_list.pop() self.assertFalse(dev_input.can_use_touch_mouse()) - @mock.patch("ovos_utils.device_input.find_executable") + @mock.patch("ovos_utils.device_input.which") def test_can_use_keyboard(self, find_exec: MagicMock) -> None: """can_use_keyboard should detect keyboard devices.""" from ovos_utils.device_input import InputDeviceHelper @@ -192,7 +192,7 @@ def test_can_use_keyboard(self, find_exec: MagicMock) -> None: dev_input.xinput_devices_list.pop() self.assertFalse(dev_input.can_use_keyboard()) - @patch("ovos_utils.device_input.find_executable", return_value=None) + @patch("ovos_utils.device_input.which", return_value=None) @patch("ovos_utils.device_input.is_gui_installed", return_value=True) def test_can_use_touch_mouse_no_executable_gui_installed( self, mock_gui: MagicMock, mock_find: MagicMock) -> None: @@ -202,7 +202,7 @@ def test_can_use_touch_mouse_no_executable_gui_installed( result = helper.can_use_touch_mouse() self.assertTrue(result) - @patch("ovos_utils.device_input.find_executable", return_value=None) + @patch("ovos_utils.device_input.which", return_value=None) @patch("ovos_utils.device_input.is_gui_installed", return_value=False) def test_can_use_touch_mouse_no_executable_no_gui( self, mock_gui: MagicMock, mock_find: MagicMock) -> None: @@ -212,7 +212,7 @@ def test_can_use_touch_mouse_no_executable_no_gui( result = helper.can_use_touch_mouse() self.assertFalse(result) - @patch("ovos_utils.device_input.find_executable") + @patch("ovos_utils.device_input.which") def test_get_input_device_list(self, mock_find: MagicMock) -> None: """get_input_device_list should combine libinput and xinput device lists.""" from ovos_utils.device_input import InputDeviceHelper diff --git a/test/unittests/test_sound.py b/test/unittests/test_sound.py index 4843e985..7e175ba2 100644 --- a/test/unittests/test_sound.py +++ b/test/unittests/test_sound.py @@ -26,7 +26,7 @@ if "distutils" not in sys.modules: distutils_stub = types.ModuleType("distutils") spawn_stub = types.ModuleType("distutils.spawn") - spawn_stub.find_executable = lambda x: None + spawn_stub.which = lambda x: None distutils_stub.spawn = spawn_stub sys.modules["distutils"] = distutils_stub sys.modules["distutils.spawn"] = spawn_stub @@ -62,7 +62,7 @@ def test_no_tts_config(self) -> None: class TestFindPlayer(unittest.TestCase): """Tests for _find_player helper.""" - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_sox_play_found(self, mock_find: MagicMock) -> None: """Should prefer sox play when available.""" mock_find.side_effect = lambda x: "/usr/bin/play" if x == "play" else None @@ -71,7 +71,7 @@ def test_sox_play_found(self, mock_find: MagicMock) -> None: self.assertIsNotNone(result) self.assertIn("play", result) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_ogg_player_preferred_for_ogg(self, mock_find: MagicMock) -> None: """Should prefer ogg123 for .ogg files when sox is unavailable.""" def side_effect(x: str) -> str | None: @@ -86,7 +86,7 @@ def side_effect(x: str) -> str | None: self.assertIsNotNone(result) self.assertIn("ogg123", result) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_pw_play_fallback(self, mock_find: MagicMock) -> None: """Should use pw-play when sox is unavailable and file is not ogg.""" def side_effect(x: str) -> str | None: @@ -99,7 +99,7 @@ def side_effect(x: str) -> str | None: self.assertIsNotNone(result) self.assertIn("pw-play", result) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_paplay_for_wav(self, mock_find: MagicMock) -> None: """Should use paplay for .wav when pw-play and sox unavailable.""" def side_effect(x: str) -> str | None: @@ -112,7 +112,7 @@ def side_effect(x: str) -> str | None: self.assertIsNotNone(result) self.assertIn("paplay", result) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_aplay_for_wav_when_paplay_missing(self, mock_find: MagicMock) -> None: """Should fall back to aplay for .wav when paplay is unavailable.""" def side_effect(x: str) -> str | None: @@ -125,7 +125,7 @@ def side_effect(x: str) -> str | None: self.assertIsNotNone(result) self.assertIn("aplay", result) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") def test_mpg123_for_mp3(self, mock_find: MagicMock) -> None: """Should use mpg123 for mp3 when no other player found.""" def side_effect(x: str) -> str | None: @@ -138,7 +138,7 @@ def side_effect(x: str) -> str | None: self.assertIsNotNone(result) self.assertIn("mpg123", result) - @patch("ovos_utils.sound.find_executable", return_value=None) + @patch("ovos_utils.sound.which", return_value=None) def test_returns_none_when_no_player(self, _mock_find: MagicMock) -> None: """Should return None when no suitable player is found.""" from ovos_utils.sound import _find_player @@ -287,7 +287,7 @@ def test_snd_prefix_resolved(self) -> None: duration = get_sound_duration("snd/test.wav", base_dir=base_dir) self.assertGreater(duration, 0) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") @patch("subprocess.Popen") def test_ffprobe_fallback(self, mock_popen: MagicMock, mock_find: MagicMock) -> None: @@ -311,7 +311,7 @@ def find_side_effect(x: str) -> str | None: finally: os.unlink(fname) - @patch("ovos_utils.sound.find_executable") + @patch("ovos_utils.sound.which") @patch("subprocess.Popen") def test_mediainfo_fallback(self, mock_popen: MagicMock, mock_find: MagicMock) -> None: @@ -337,7 +337,7 @@ def find_side_effect(x: str) -> str | None: finally: os.unlink(fname) - @patch("ovos_utils.sound.find_executable", return_value=None) + @patch("ovos_utils.sound.which", return_value=None) def test_no_tool_raises_runtime_error(self, _mock_find: MagicMock) -> None: """get_sound_duration should raise RuntimeError when no tool is available.""" with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: From 61a9e0a4bc13ab37a7719eab039d71c394be5fc7 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:42:05 +0000 Subject: [PATCH 48/64] Increment Version to 0.13.7a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 783e6cc7..527966ba 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 6 +VERSION_BUILD = 7 VERSION_ALPHA = 1 # END_VERSION_BLOCK From 619e87fec71c0da1e0f7d1fe673b42fd6afd2d45 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:42:38 +0000 Subject: [PATCH 49/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d382056..7ce11c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.7a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a1) (2026-07-23) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.6a1...0.13.7a1) + +**Merged pull requests:** + +- fix: replace removed distutils.spawn with shutil.which \(Python 3.12+\) [\#402](https://github.com/OpenVoiceOS/ovos-utils/pull/402) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.6a1) (2026-07-23) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.5a1...0.13.6a1) From 4127f1540f6d077c6f6a98eb89624d2d893b9a7e Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:12:18 +0100 Subject: [PATCH 50/64] refactor: deprecate create_self_signed_cert (#404) create_self_signed_cert() generates a 1024-bit RSA / SHA-1 certificate that modern OpenSSL (SECLEVEL=2, the default on Debian/Ubuntu/Fedora) refuses to load (EE_KEY_TOO_SMALL), making any cert it produces unusable for TLS. Reproduced with: openssl req -x509 -newkey rsa:1024 -sha1 -nodes -keyout w.key -out w.crt -days 1 -subj "/CN=test" python3 -c "import ssl; c=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); c.load_cert_chain('w.crt','w.key')" # -> ssl.SSLError: [SSL: EE_KEY_TOO_SMALL] ee key too small Rather than fix the crypto parameters here, mark the helper deprecated using the repo's existing @deprecated decorator and dynamic VERSION_MAJOR + 1 removal-version convention (see bracket_expansion.py, dialog.py, system.py). The function keeps working unchanged (same signature, same file naming, same reuse-if-present behaviour, same return value) so nothing breaks for existing callers; only the log message steers callers away from it. Co-authored-by: Claude Opus 4.8 --- ovos_utils/security.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ovos_utils/security.py b/ovos_utils/security.py index b9b74312..459ae0f6 100644 --- a/ovos_utils/security.py +++ b/ovos_utils/security.py @@ -8,7 +8,8 @@ import pexpect -from ovos_utils.log import LOG +from ovos_utils.log import LOG, deprecated +from ovos_utils.version import VERSION_MAJOR try: # pycryptodomex @@ -25,10 +26,21 @@ crypto = None +@deprecated("create_self_signed_cert is unmaintained and generates a " + "1024-bit RSA / SHA-1 certificate that modern OpenSSL " + "(SECLEVEL=2, the Debian/Ubuntu/Fedora default) refuses to load " + "('EE_KEY_TOO_SMALL'). Callers should bundle their own " + "self-signed cert generation (RSA >= 2048, SHA-256, with a " + "Subject Alternative Name) instead of relying on this helper.", + f"{VERSION_MAJOR + 1}.0.0") def create_self_signed_cert(cert_dir, name="jarbas"): """ If name.crt and name.key don't exist in cert_dir, create a new self-signed cert and key pair and write them into that directory. + + .. deprecated:: unmaintained; generates a 1024-bit RSA / SHA-1 + certificate that modern OpenSSL rejects at load time. Bundle your + own self-signed cert generation instead. """ if crypto is None: LOG.error("run pip install pyopenssl") From 6143f7e3664ed03a5de9e6010e771a53a91284da Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:12:32 +0000 Subject: [PATCH 51/64] Increment Version to 0.13.7a2 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 527966ba..fb01fbc3 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -2,7 +2,7 @@ VERSION_MAJOR = 0 VERSION_MINOR = 13 VERSION_BUILD = 7 -VERSION_ALPHA = 1 +VERSION_ALPHA = 2 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From cb427707d30038eaf77f9a2951ffa4e0d66ded28 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:13:04 +0000 Subject: [PATCH 52/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce11c84..9f58e1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.7a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a2) (2026-07-24) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a1...0.13.7a2) + +**Merged pull requests:** + +- refactor: deprecate create\_self\_signed\_cert [\#404](https://github.com/OpenVoiceOS/ovos-utils/pull/404) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.7a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a1) (2026-07-23) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.6a1...0.13.7a1) From 93cd46ae60de3d7ce632c656f00e100c4b41f078 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:34:39 +0100 Subject: [PATCH 53/64] fix: FileEventHandler must only fire for the file it watches (#406) FileEventHandler stored file_path but never compared it against event.src_path, so a FileWatcher asked to watch a single file fired its callback for every file changing in that file's directory, and two single-file watchers sharing a directory each fired for both files (duplicate callbacks). FileWatcher now tells the handler which file to filter on (None in directory-watch mode), and the handler compares realpath(event.src_path) against it before dispatching, in both the closed and created/modified branches. Directory-watch mode is unchanged. --- ovos_utils/file_utils.py | 29 ++++++++++++++++++---- test/unittests/test_file_utils.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 881578e0..375a6969 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -376,9 +376,13 @@ def __init__(self, files: List[str], callback: callable, for file_path in files: if os.path.isfile(file_path): watch_dir = dirname(file_path) + # a specific file was requested, only fire for that file + watched_file = file_path else: watch_dir = file_path - self.observer.schedule(FileEventHandler(file_path, callback, + # a directory was requested, fire for any file inside it + watched_file = None + self.observer.schedule(FileEventHandler(watched_file, callback, ignore_creation), watch_dir, recursive=recursive) self.observer.start() @@ -398,17 +402,20 @@ def shutdown(self): from watchdog.events import FileSystemEventHandler class FileEventHandler(FileSystemEventHandler): - def __init__(self, file_path: str, callback: callable, + def __init__(self, file_path: Optional[str], callback: callable, ignore_creation: bool = False): """ Create a handler for file change events - @param file_path: file_path being watched Unused(?) + @param file_path: if set, the single file being watched; events + for any other file in the watched directory are ignored. + If None, this handler is watching a directory and events + for any file inside it are reported. @param callback: function to call on file change with modified file path @param ignore_creation: if True, only track file modification events """ super().__init__() self._callback = callback - self._file_path = file_path + self._file_path = os.path.realpath(file_path) if file_path else None if ignore_creation: self._events = ('modified') else: @@ -416,9 +423,23 @@ def __init__(self, file_path: str, callback: callable, self._changed_files = set() self._lock = RLock() + def _is_watched(self, src_path) -> bool: + """ + Check if a reported event path refers to the file this handler + was asked to watch. Always True in directory-watch mode. + @param src_path: `event.src_path`, str or bytes depending on platform + """ + if self._file_path is None: + return True + if isinstance(src_path, bytes): + src_path = src_path.decode() + return os.path.realpath(src_path) == self._file_path + def on_any_event(self, event): if event.is_directory: return + if not self._is_watched(event.src_path): + return with self._lock: if event.event_type == "closed": if event.src_path in self._changed_files: diff --git a/test/unittests/test_file_utils.py b/test/unittests/test_file_utils.py index 8a0efd84..17c26b3b 100644 --- a/test/unittests/test_file_utils.py +++ b/test/unittests/test_file_utils.py @@ -169,6 +169,46 @@ def test_file_event_handler(self): handler.on_any_event(FileClosedEvent(test_file)) callback.assert_called_once() + # Test events for a different file in the same directory are ignored + # when watching a specific file (the FileWatcher watches the + # containing directory, so watchdog reports every file inside it) + other_file = join(dirname(__file__), "other.watch") + callback.reset_mock() + handler = FileEventHandler(test_file, callback, True) + handler.on_any_event(FileModifiedEvent(other_file)) + handler.on_any_event(FileClosedEvent(other_file)) + callback.assert_not_called() + # but the watched file itself still fires + handler.on_any_event(FileModifiedEvent(test_file)) + handler.on_any_event(FileClosedEvent(test_file)) + callback.assert_called_once() + + # Test directory-watch mode (file_path=None) reports every file + callback.reset_mock() + handler = FileEventHandler(None, callback, True) + handler.on_any_event(FileModifiedEvent(test_file)) + handler.on_any_event(FileClosedEvent(test_file)) + callback.assert_called_once_with(test_file) + callback.reset_mock() + handler.on_any_event(FileModifiedEvent(other_file)) + handler.on_any_event(FileClosedEvent(other_file)) + callback.assert_called_once_with(other_file) + + # Test two handlers watching different files in the same directory + # each fire only for their own file, not for each other's + callback_a = Mock() + callback_b = Mock() + handler_a = FileEventHandler(test_file, callback_a, True) + handler_b = FileEventHandler(other_file, callback_b, True) + # both handlers watch the same directory, so watchdog delivers + # every event to both of them + for handler in (handler_a, handler_b): + for ev_file in (test_file, other_file): + handler.on_any_event(FileModifiedEvent(ev_file)) + handler.on_any_event(FileClosedEvent(ev_file)) + callback_a.assert_called_once_with(test_file) + callback_b.assert_called_once_with(other_file) + # Test include creation callbacks callback.reset_mock() handler = FileEventHandler(test_file, callback, False) From 2b4b7a098d0f3a57dd9e45656eec26ddcf3aa13e Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:34:54 +0000 Subject: [PATCH 54/64] Increment Version to 0.13.8a1 --- ovos_utils/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index fb01fbc3..bedd7bbc 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 7 -VERSION_ALPHA = 2 +VERSION_BUILD = 8 +VERSION_ALPHA = 1 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 756a0113c9bee44dad01da91b54e607f4a939896 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:35:21 +0000 Subject: [PATCH 55/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f58e1c1..0de71440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.8a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.8a1) (2026-07-24) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a2...0.13.8a1) + +**Merged pull requests:** + +- fix: FileEventHandler must only fire for the file it watches [\#406](https://github.com/OpenVoiceOS/ovos-utils/pull/406) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.7a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a2) (2026-07-24) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a1...0.13.7a2) From 5455945c514b19166a5a5241b46991ecd570ec2f Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:36:29 +0100 Subject: [PATCH 56/64] fix: watch files that do not exist yet (#408) FileWatcher dispatched file-vs-directory mode with os.path.isfile(), so a path that doesn't exist yet fell into directory mode: watchdog was asked to schedule an observer on the nonexistent path (which fails) and the handler ran unfiltered. Callers watching a config file that is created later (eg. ovos-config watching mycroft.conf before it exists) never got the 'created' event. Dispatch on os.path.isdir() instead: an existing directory keeps directory mode unchanged; anything else (an existing file, or a path that doesn't exist yet) is file mode, watching the parent directory and filtering to that single path - safe because of the per-file filtering added in #406. If the parent directory also doesn't exist, watchdog can't schedule an observer on it either; skip that entry with a LOG.warning instead of letting an opaque watchdog exception propagate. --- ovos_utils/file_utils.py | 17 +++++++--- test/unittests/test_file_utils.py | 55 ++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 375a6969..aabc3083 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -374,14 +374,21 @@ def __init__(self, files: List[str], callback: callable, self.observer = Observer() self.handlers = [] for file_path in files: - if os.path.isfile(file_path): - watch_dir = dirname(file_path) - # a specific file was requested, only fire for that file - watched_file = file_path - else: + if os.path.isdir(file_path): watch_dir = file_path # a directory was requested, fire for any file inside it watched_file = None + else: + # a specific file was requested, only fire for that file. + # the file may not exist yet (eg. a config that hasn't been + # written), watchdog just needs its (existing) parent dir + watch_dir = dirname(file_path) or "." + watched_file = file_path + if not os.path.isdir(watch_dir): + LOG.warning(f"Can't watch '{file_path}', " + f"parent directory '{watch_dir}' " + f"does not exist") + continue self.observer.schedule(FileEventHandler(watched_file, callback, ignore_creation), watch_dir, recursive=recursive) diff --git a/test/unittests/test_file_utils.py b/test/unittests/test_file_utils.py index 17c26b3b..c1d8579c 100644 --- a/test/unittests/test_file_utils.py +++ b/test/unittests/test_file_utils.py @@ -1,10 +1,11 @@ +import os import shutil import unittest from os import makedirs from os.path import isdir, join, dirname from threading import Event from time import time -from unittest.mock import Mock +from unittest.mock import Mock, patch class TestFileUtils(unittest.TestCase): @@ -143,6 +144,58 @@ def _on_change(fp): shutil.rmtree(test_dir) + def test_filewatcher_not_yet_existing_file(self): + from ovos_utils.file_utils import FileWatcher + + test_dir = join(dirname(__file__), "test_watch_new") + test_file = join(test_dir, "not_yet.watch") + makedirs(test_dir, exist_ok=True) + self.assertFalse(os.path.isfile(test_file)) + + # a path that doesn't exist yet is still watched in file mode: + # the containing (existing) directory is scheduled, and a + # 'created' event for the not-yet-existing file fires the callback + called = Event() + callback = Mock(side_effect=lambda x: called.set()) + watcher = FileWatcher([test_file], callback) + with open(test_file, 'w+'): + pass + self.assertTrue(called.wait(3)) + callback.assert_called_once_with(test_file) + watcher.shutdown() + + # a different file being created in the same directory must + # NOT fire the callback (file mode still filters to the one path) + called.clear() + callback.reset_mock() + watcher = FileWatcher([test_file], callback) + other_file = join(test_dir, "other.watch") + with open(other_file, 'w+'): + pass + self.assertFalse(called.wait(3)) + callback.assert_not_called() + watcher.shutdown() + + shutil.rmtree(test_dir) + + def test_filewatcher_missing_parent_directory(self): + from ovos_utils.file_utils import FileWatcher + + # if the parent directory of a not-yet-existing file also doesn't + # exist, watchdog can't schedule an observer on it; FileWatcher + # must skip that entry (with a LOG.warning) instead of raising + # an opaque watchdog exception + missing_parent = join(dirname(__file__), "definitely_not_there") + missing_file = join(missing_parent, "cfg.json") + self.assertFalse(isdir(missing_parent)) + + callback = Mock() + with patch("ovos_utils.file_utils.LOG") as mock_log: + watcher = FileWatcher([missing_file], callback) + mock_log.warning.assert_called_once() + self.assertEqual(watcher.observer.emitters, set()) + watcher.shutdown() + def test_file_event_handler(self): from ovos_utils.file_utils import FileEventHandler from watchdog.events import FileCreatedEvent, FileModifiedEvent, FileClosedEvent From 3c86cb79a0bc0719a3789434e106fb95b5999e24 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:36:43 +0000 Subject: [PATCH 57/64] Increment Version to 0.13.9a1 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index bedd7bbc..dc1fdea3 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,7 +1,7 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 8 +VERSION_BUILD = 9 VERSION_ALPHA = 1 # END_VERSION_BLOCK From b742ad34b6b49525bc66ca5efe921e997007df72 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:37:13 +0000 Subject: [PATCH 58/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de71440..4fcaa393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.9a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a1) (2026-07-24) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.8a1...0.13.9a1) + +**Merged pull requests:** + +- fix: watch files that do not exist yet [\#408](https://github.com/OpenVoiceOS/ovos-utils/pull/408) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.8a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.8a1) (2026-07-24) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a2...0.13.8a1) From 1a2527dccbd5a2e4fe89ca8a620dc8a3439ae1d9 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:01:41 +0100 Subject: [PATCH 59/64] docs: rewrite README in Simplified Technical English (#413) Co-authored-by: Claude Sonnet 5 --- README.md | 142 ++++++++++++++++++++++++------------------ docs/events.md | 3 + docs/fakebus.md | 3 + docs/log.md | 3 + docs/process-utils.md | 3 + docs/utilities.md | 3 + 6 files changed, 96 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 7dd896e6..1bde44e3 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,107 @@ # OVOS-utils -collection of simple utilities for use across the mycroft ecosystem +`ovos-utils` is a shared utility library for the OpenVoiceOS ecosystem. It provides +logging, process lifecycle management, a testing-friendly fake message bus, event +scheduling, file utilities, network checks, audio playback, and XDG path helpers. +Most OVOS packages, including `ovos-bus-client`, `ovos-config`, and `ovos-workshop`, +depend on it, so most projects get it as a transitive dependency. ## Install ```bash -pip install ovos_utils +pip install ovos-utils ``` -## Commandline scripts -### ovos-logs - Small helper tool to quickly navigate the logs, create slices and quickview errors +## Usage ---------------- -- **ovos-logs slice [options]** +The library exposes many small, independent modules. Import only what you need. +For example, use `FakeBus` to test skill code without a live message bus: - **Slice logs of a given time period. Defaults on the last service start (`-s`) until now (`-u`)** +```python +from ovos_utils.fakebus import FakeBus, FakeMessage - _Different logs can be picked using the `-l` option. All logs will be included if not specified._ - _Optionally the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be specified._ - +bus = FakeBus() - _[ex: `ovos-logs slice`]_ - _Slice all logs from service start up until now._ - - _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25`]_ - _Slice all logs from 17:05:20 until 17:05:25._ - _**no logs in that timeframe in other present logs_ - Screenshot 2023-12-25 185004 - - _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -l skills`]_ - _Slice skills.log from 17:05:20 until 17:05:25._ - - _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -f ~/testslice.log`]_ - _Slice the logs from 17:05:20 until 17:05:25 on all log files and dump the slices in the file ~/testslice.log (default: `~/slice_.log`)._ - Screenshot 2023-12-25 190732 --------------- +def on_utterance(message): + print(message.data["utterances"]) -- **ovos-logs list [-e|-w|-d|-x] [options]** +bus.on("recognizer_loop:utterance", on_utterance) +bus.emit(FakeMessage("recognizer_loop:utterance", {"utterances": ["hello"]})) +``` + +See [docs/index.md](docs/index.md) for the full module overview, with links to +detailed pages on logging, process utilities, `FakeBus`, and event handling. + +## Command line: ovos-logs + +`ovos-logs` is a helper tool that slices, lists, and reduces OVOS service logs. + +- **`ovos-logs slice [options]`**. Slice logs for a time period. The default + period runs from the last service start (`-s`) until now (`-u`). Pick specific + logs with `-l` (default: all logs). Set the log directory with `-p` and the + output file with `-f`. + + ```bash + ovos-logs slice + # Slice all logs from the last service start until now. + + ovos-logs slice -s 17:05:20 -u 17:05:25 + # Slice all logs between 17:05:20 and 17:05:25. + + ovos-logs slice -s 17:05:20 -u 17:05:25 -l skills + # Slice only skills.log between 17:05:20 and 17:05:25. + + ovos-logs slice -s 17:05:20 -u 17:05:25 -f ~/testslice.log + # Slice logs between 17:05:20 and 17:05:25 into ~/testslice.log. + # Default output file: ~/slice_.log + ``` + +- **`ovos-logs list [-e|-w|-d|-x] [options]`**. List log lines by severity + (error, warning, debug, exception). Specify at least one level. You can combine + several. Set the time range with `-s` and `-u` (default: last service start + until now). Pick specific logs with `-l` (default: all logs). + + ```bash + ovos-logs list -x + # List EXCEPTION-level lines (with tracebacks) from the last service start until now. - **List logs by severity (error/warning/debug/exception). A log level has to be specified - more than one can be listed** + ovos-logs list -w -e -s 20-12-2023 -l bus -l skills + # List WARNING and ERROR lines from bus.log and skills.log since 20 December 2023. + ``` - _A start and end date can be specified using the `-s` and `-u` options. Defaults to the last service start until now._ - _Different logs can be picked using the `-l` option. All logs will be included if not specified._ - _Optionally, the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be passed as arguments._ +- **`ovos-logs reduce [options]`**. Shrink logs to a target size in bytes, or + remove entries before a given date. Pick specific logs with `-l` (default: all + logs). Set the log directory with `-p`. - _[ex: `ovos-logs list -x`]_ - _List the logs with level EXCEPTION (plus tracebacks) from the last service start until now._ - Screenshot 2023-12-25 184321 - - _[ex: `ovos-logs list -w -e -s 20-12-2023 -l bus -l skills`]_ - _List the logs with level WARNING and ERROR from the 20th of December 2023 until now from the logs bus.log and skills.log._ - Screenshot 2023-12-25 173739 ---------------------- + ```bash + ovos-logs reduce + # Shrink all logs to 0 bytes. -- **ovos-logs reduce [options]** - - **Downsize logs to a given size (in bytes) or remove entries before a given date.** - - _Different logs can be included using the `-l` option. If not specified, all logs will be included._ - _Optionally the directory where the logs are stored (`-p`) can be specified._ - - _[ex: `ovos-logs reduce`]_ - _Downsize all logs to 0 bytes_ + ovos-logs reduce -s 1000000 + # Shrink all logs to about 1 MB, keeping the latest entries. - _[ex: `ovos-logs reduce -s 1000000`]_ - _Downsize all logs to ~1MB (latest logs)_ + ovos-logs reduce -d "1-12-2023 17:00" + # Shrink all logs to entries after the given date and time. - _[ex: `ovos-logs reduce -d "1-12-2023 17:00"`]_ - _Downsize all logs to entries after the specified date/time_ + ovos-logs reduce -s 1000000 -l skills -l bus + # Shrink skills.log and bus.log to about 1 MB each. + ``` - _[ex: `ovos-logs reduce -s 1000000 -l skills -l bus`]_ - _Downsize skills.log and bus.log to ~1MB (latest logs)_ +- **`ovos-logs show -l `**. Print the contents of a log file. ---------------------- + ```bash + ovos-logs show -l bus + # Print the contents of bus.log. + ``` -- **ovos-logs show -l [servicelog]** + The logs shown depend on which log files exist in the log folder. - **Show logs** +## Related projects - _[ex: `ovos-logs show -l bus`]_ - _Show the logs from bus.log._ +- [OpenVoiceOS/ovos-bus-client](https://github.com/OpenVoiceOS/ovos-bus-client). The real message bus client that `FakeBus` and `FakeMessage` stand in for during testing. +- [OpenVoiceOS/ovos-config](https://github.com/OpenVoiceOS/ovos-config). Reads and writes `mycroft.conf`, the configuration file used by `LOG`, `PIDLock`, and the network utilities. +- [OpenVoiceOS/ovos-workshop](https://github.com/OpenVoiceOS/ovos-workshop). The skill framework built on `EventContainer`, `RuntimeRequirements`, and the other utilities in this library. - _[ex: wrong servicelog]_ - _**logs shown depending on the logs present in the folder_ +## License +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/docs/events.md b/docs/events.md index f15edbeb..da06b66a 100644 --- a/docs/events.md +++ b/docs/events.md @@ -85,3 +85,6 @@ from ovos_bus_client.apis.events import EventSchedulerInterface | `shutdown()` | Cancel repeating events and clear all registered handlers | `when` may be a `datetime`, or a positive `int`/`float` representing seconds from now. + +--- +[← FakeBus](fakebus.md) · [Home](index.md) · [Utilities →](utilities.md) diff --git a/docs/fakebus.md b/docs/fakebus.md index 037d770f..a8deeb13 100644 --- a/docs/fakebus.md +++ b/docs/fakebus.md @@ -155,3 +155,6 @@ msg = FakeMessage("skill:action", {"key": "value"}, {"session_id": "abc"}) ## `dig_for_message()` Tries to import and call `ovos_bus_client.message.dig_for_message`. Returns `None` if `ovos-bus-client` is not installed. + +--- +[← Process Utilities](process-utils.md) · [Home](index.md) · [Events →](events.md) diff --git a/docs/log.md b/docs/log.md index 541b81e8..157a2c1b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -141,3 +141,6 @@ Return all configured log directories across all services. ## `get_available_logs(directories) → List[str]` Return a list of log file basenames (e.g. `["audio", "skills", "bus"]`) found in the configured log directories. + +--- +[Home](index.md) · [Process Utilities →](process-utils.md) diff --git a/docs/process-utils.md b/docs/process-utils.md index 4dba1ef1..fdefeddd 100644 --- a/docs/process-utils.md +++ b/docs/process-utils.md @@ -136,3 +136,6 @@ Chainable POSIX signal handler. Each instance installs a user function as the ne ## `reset_sigint_handler()` Reset `SIGINT` to the default Python handler. Needed when starting OVOS services from shell scripts that have modified the signal mask. + +--- +[← Logging](log.md) · [Home](index.md) · [FakeBus →](fakebus.md) diff --git a/docs/utilities.md b/docs/utilities.md index e5d22cc9..d251dcda 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -201,3 +201,6 @@ cache_dir = xdg_cache_home() / "mycroft" # ~/.cache/mycroft | `xdg_data_dirs()` | `XDG_DATA_DIRS` | `[/usr/local/share, /usr/share]` | Environment variable values are only used if they are absolute paths; relative paths fall back to the default. + +--- +[← Events](events.md) · [Home](index.md) From d3ac4eb0d55f8e0fef39ab321c6a4649596f9ca6 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:01:56 +0000 Subject: [PATCH 60/64] Increment Version to 0.13.9a2 --- ovos_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index dc1fdea3..a1097a91 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -2,7 +2,7 @@ VERSION_MAJOR = 0 VERSION_MINOR = 13 VERSION_BUILD = 9 -VERSION_ALPHA = 1 +VERSION_ALPHA = 2 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From e8f123232d481ad86e6fa796fca7b6e64c87ca1b Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:02:30 +0000 Subject: [PATCH 61/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcaa393..c5feba72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.9a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a2) (2026-08-01) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.9a1...0.13.9a2) + +**Merged pull requests:** + +- docs: rewrite README in Simplified Technical English [\#413](https://github.com/OpenVoiceOS/ovos-utils/pull/413) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.9a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a1) (2026-07-24) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.8a1...0.13.9a1) From 8a4473555a075d4f0b7a4e50603ba99df5e61929 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:29:03 +0100 Subject: [PATCH 62/64] fix: give FakeBus the intent-topic bridge (RULE 1/RULE 2 parity with MessageBusClient) (#417) * fix: give FakeBus the intent-topic bridge (RULE 1/RULE 2 parity with MessageBusClient) FakeBus already mirrored MessageBusClient's fixed legacy<->ovos.* namespace migration (NamespaceTranslator), but not the per-intent dispatch-topic bridge the real client applies on every emit: the send-side legacy twin and the receive-side canonical modernize. In-process tests that raw-emit a legacy :IntentName.intent topic never reached a canonical-only listener (ovos-workshop >= 9.3.11a2 dropped its own dual-bind), while a real websocket deployment dealiased fine. Port the same two rules into FakeBus.emit()/AsyncFakeBus.emit(), gated by the existing modernize/ emit_legacy flags and guarded against recursion with the same INTENT_COMPAT_TWIN_KEY marker the real client uses. Co-Authored-By: Claude Fable 5 * fix: intent-pair subscribe guard + pop-before-dispatch for FakeBus intent bridge Adversarial review found two defects in the FakeBus RULE1/RULE2 intent-topic bridge (#417): - on() had no subscribe-side dedup for the intent bridge at all, so a handler bound to both the canonical and legacy-suffixed spelling of an intent (the common ovos-workshop dual-bind, which uses a fresh wrapper closure per registration) fired twice for one dispatch. Ported MessageBusClient's per-topic-pair mirror guard (shared by every registration on either spelling, not per-handler) into FakeBus.on()/AsyncFakeBus.on(). - emit() popped the RULE2 twin marker too late (inside _bridge_intent_topic, after the main handler dispatch), and the internally-generated RULE1 twin carried the marker into local handler context. A handler that forwarded that twin's context onto an unrelated topic would incorrectly brand the new frame a twin and silently suppress its own modernization. Marker is now popped at the top of emit()/AsyncFakeBus.emit() before any dispatch, and the RULE1 twin no longer carries the marker into local delivery at all (mirroring the real client, whose receiving process pops it before any local handler runs) -- this also makes the pair guard's payload+ context fingerprint match the canonical dispatch's, which the dedup above depends on. Rewrote test_no_double_fire_dual_listener to assert ONE call (matching the real client) and test_canonical_emit_twin_marked to assert the marker is NOT visible locally; added starvation and marker-leak regression tests. Every new/changed assertion verified to fail against the pre-fix source via patch-revert before the fix, and pass after. --------- Co-authored-by: Claude Fable 5 --- ovos_utils/fakebus.py | 204 ++++++++++++++++-- .../test_fakebus_intent_topic_bridge.py | 195 +++++++++++++++++ 2 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 test/unittests/test_fakebus_intent_topic_bridge.py diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index cc80c98f..5a9a3acc 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -1,13 +1,39 @@ import asyncio import warnings +from copy import deepcopy from os import environ from threading import Event from ovos_utils.log import LOG, log_deprecation from ovos_spec_tools import NamespaceTranslator +from ovos_spec_tools.intent_topics import (canonical_intent_topic, + intent_topic_counterpart, + is_intent_topic, + legacy_intent_topic) from pyee import EventEmitter +#: Context flag stamped on a twin intent frame, mirroring +#: ``ovos_bus_client.client.client.INTENT_COMPAT_TWIN_KEY``. Its presence +#: means the canonical spelling of this dispatch was already delivered +#: alongside it, so a receiver that understands the bridge must not +#: modernize the twin a second time (that would fire the handler twice). +INTENT_COMPAT_TWIN_KEY = "_intent_compat_twin" + + +def _verbatim_copy(message, topic: str): + """Retopic ``message`` onto ``topic``, carrying its context byte-for-byte. + + Mirrors ``ovos_bus_client.client.client._verbatim_copy``. NOT + ``Message.forward`` -- ``forward()`` re-stamps the session, which for + the default session would replace the carried session with this + process's own and desync ``lang`` / ``active_skills`` between the + canonical frame and its twin. + """ + return message.__class__(topic, data=deepcopy(message.data), + context=deepcopy(message.context)) + + def dig_for_message(): try: from ovos_bus_client.message import dig_for_message as _dig @@ -69,6 +95,7 @@ def __init__(self, *args, **kwargs): # wins, else env var -> websocket.* config -> default on. self._translator = _resolve_bus_flags(kwargs) self._handler_guards = {} # handler -> shared mirror-guard + self._intent_pair_guards = {} # frozenset({topic, counterpart}) -> shared mirror-guard self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...] self.on_open() try: @@ -80,14 +107,11 @@ def __init__(self, *args, **kwargs): self.on_default_session_update) def on(self, msg_type, handler): - # wrap handlers on migrated topics so a handler subscribed to both the - # legacy and ovos.* topic fires once (the mirror is dropped) - if self._translator.is_migrated(msg_type): - guard = self._handler_guards.get(handler) - if guard is None: - guard = self._translator.new_mirror_guard() - self._handler_guards[handler] = guard - + # wrap handlers on migrated/bridged topics so a handler subscribed to + # both spellings of a mirrored dispatch fires once (the mirror is + # dropped). See _mirror_guard_for for the guard-scope rationale. + guard = self._mirror_guard_for(msg_type, handler) + if guard is not None: def wrapped(message=None): if guard(message): return @@ -98,10 +122,70 @@ def wrapped(message=None): return self.ee.on(msg_type, handler) + def _mirror_guard_for(self, msg_type, handler): + """The mirror guard a registration on ``msg_type`` must wrap with. + + Mirrors ``ovos_bus_client.client.client.MessageBusClient._mirror_guard_for``. + Two bridges deliver one logical event twice, and each needs a + different guard SCOPE: + + - **namespace migration** (legacy <-> ``ovos.*``): the guard is per + HANDLER, shared across that handler's registrations, so its legacy + ``on()`` and its ``ovos.*`` ``on()`` dedupe against each other. + - **intent-topic compat** (canonical <-> ``.intent``-suffixed): the + guard is per TOPIC PAIR, shared by every registration on either + spelling. + + The intent guard cannot be keyed by handler: ``ovos-workshop`` + 9.3.2a1+ binds the same skill method to both spellings through a + FRESH wrapper closure per binding, so the two registrations are two + distinct ``handler`` objects and a per-handler guard would hand each + its own private state -- the canonical frame runs one closure, the + twin runs the other, and the skill handler fires twice for a single + dispatch. Keying on the pair collapses them. + """ + counterpart = intent_topic_counterpart(msg_type) + if counterpart is not None: + pair_key = frozenset({msg_type, counterpart}) + guard = self._intent_pair_guards.get(pair_key) + if guard is None: + guard = self._translator.new_mirror_guard() + self._intent_pair_guards[pair_key] = guard + return guard + if self._translator.is_migrated(msg_type): + guard = self._handler_guards.get(handler) + if guard is None: + guard = self._translator.new_mirror_guard() + self._handler_guards[handler] = guard + return guard + return None + + def _release_intent_pair_guard(self, msg_type): + """Drop the pair guard once nothing is registered on either spelling. + + Mirrors ``MessageBusClient._release_intent_pair_guard``. + """ + counterpart = intent_topic_counterpart(msg_type) + if counterpart is None: + return + pair_key = frozenset({msg_type, counterpart}) + for regs in self._dedup_registrations.values(): + if any(ev in pair_key for ev, _ in regs): + return + self._intent_pair_guards.pop(pair_key, None) + def once(self, msg_type, handler): self.ee.once(msg_type, handler) def emit(self, message): + # RULE 2 dedup marker: read it, then POP it before any local + # dispatch. A handler invoked below may call message.forward()/ + # reply() to emit a descendant frame on an UNRELATED topic; those + # deep-copy the whole context, so leaving the marker in place would + # brand that unrelated frame a twin and silently suppress its + # modernization. Mirrors MessageBusClient.on_message's pop-before- + # dispatch ordering. + is_intent_twin = message.context.pop(INTENT_COMPAT_TWIN_KEY, False) if "session" not in message.context: try: # replicate side effects from ovos_bus_client.session import Session, SessionManager @@ -140,6 +224,82 @@ def emit(self, message): self.ee.emit(topic, message.forward(topic, translated)) except Exception as e: LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") + self._bridge_intent_topic(message, is_twin=is_intent_twin) + + def _bridge_intent_topic(self, message, is_twin=False): + """Legacy <-> canonical intent-topic bridge (RULE 1 + RULE 2). + + Mirrors ``ovos_bus_client.client.client.MessageBusClient``'s + ``_send_legacy_intent_twin`` (RULE 1, send-side) and + ``_modernize_intent_topic`` (RULE 2, receive-side). The real client + splits these across the wire (twin goes out on ``emit()``, the + modernized copy is dispatched locally in ``on_message()``); a + ``FakeBus`` has no separate wire hop, so both rules run inline, + against local listeners only, off of the one message being emitted. + + ``is_twin`` carries the marker decision made in ``emit()``, which + pops :data:`INTENT_COMPAT_TWIN_KEY` off the context BEFORE any + dispatch so it cannot leak onto descendant frames a handler derives + from this one (``message.forward()``/``reply()`` deep-copy context). + The marker is therefore never read from ``message.context`` here -- + only the popped value is trusted. + + RULE 2 first, mirroring the real client's receive order, then + RULE 1 -- so a listener on the canonical topic sees the canonical + dispatch before the legacy twin goes out. + + Note on the capture firehose (deliberate FakeBus-wide convention, + shared with the namespace-migration bridge above): the twin/ + modernized copy is dispatched straight to ``self.ee`` on its own + topic and does NOT re-emit ``"message"``. On the real wire, the + twin is a second frame and therefore fires ``on_message`` (and its + ``"message"`` firehose) a second time in every receiving process; + ``FakeBus`` has no wire hop to put it on, so it keeps the + single-process harness's one-emit-one-capture invariant instead of + reproducing the wire's two-frame shape. + """ + # RULE 2 (receive-side modernize): a suffixed frame WITHOUT the twin + # marker came from an emitter old enough to only put the legacy + # spelling on the bus, so nothing canonical was sent alongside it -- + # a canonical-only listener would never hear it without this. + if self._translator.modernize and not is_twin \ + and is_intent_topic(message.msg_type): + canonical = canonical_intent_topic(message.msg_type) + if canonical != message.msg_type: + try: + self.ee.emit(canonical, _verbatim_copy(message, canonical)) + except Exception as e: + LOG.exception(f"Error in intent modernize dispatch for " + f"'{canonical}': {e}") + + # RULE 1 (send-side twin): every canonical intent dispatch is + # twinned onto its legacy spelling so a listener that only knows + # the old suffixed topic still hears it. An already-suffixed + # dispatch is never twinned (legacy_intent_topic is a no-op on it), + # so the mirror cannot cascade. + # + # On the real wire, this twin goes out MARKED (INTENT_COMPAT_TWIN_KEY), + # so an out-of-process receiver's own RULE 2 knows to skip + # re-modernizing it. FakeBus has no wire hop: RULE 2 above already + # made that call inline for THIS dispatch, so the marker's job is + # already done, and the twin delivered to local listeners here must + # NOT carry it. Carrying it forward would break two things: (a) the + # per-topic-pair mirror guard on ``on()`` fingerprints payload+context, + # so a marked twin would fingerprint differently from the canonical + # dispatch it mirrors and the guard would fail to recognize it as a + # duplicate, double-firing a dual-bound handler; and (b) a handler + # that forwards this frame's context to emit an unrelated topic would + # brand that unrelated frame a twin too (see the marker-leak + # regression test), silently suppressing its own modernization. + if self._translator.emit_legacy and is_intent_topic(message.msg_type): + topic = legacy_intent_topic(message.msg_type) + if topic != message.msg_type: + twin = _verbatim_copy(message, topic) + try: + self.ee.emit(topic, twin) + except Exception as e: + LOG.exception(f"Error in intent twin dispatch for " + f"'{topic}': {e}") def on_message(self, *args): """ @@ -239,6 +399,7 @@ def remove(self, msg_type, handler): if not regs: self._dedup_registrations.pop(handler, None) self._handler_guards.pop(handler, None) + self._release_intent_pair_guard(msg_type) return try: self.ee.remove_listener(msg_type, handler) @@ -384,6 +545,7 @@ def __init__(self, *args, **kwargs): # mirror MessageBusClient's namespace migration (see FakeBus.__init__). self._translator = _resolve_bus_flags(kwargs) self._handler_guards = {} # handler -> shared mirror-guard + self._intent_pair_guards = {} # frozenset({topic, counterpart}) -> shared mirror-guard self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...] self.connected_event = asyncio.Event() self.connected_event.set() @@ -401,15 +563,11 @@ def __init__(self, *args, **kwargs): # ------------------------------------------------------------------ def on(self, msg_type, handler): - # wrap handlers on migrated topics so a handler subscribed to both the - # legacy and ovos.* topic fires once (the mirror is dropped) -- same as - # FakeBus.on / MessageBusClient.on. - if self._translator.is_migrated(msg_type): - guard = self._handler_guards.get(handler) - if guard is None: - guard = self._translator.new_mirror_guard() - self._handler_guards[handler] = guard - + # wrap handlers on migrated/bridged topics so a handler subscribed to + # both spellings of a mirrored dispatch fires once (the mirror is + # dropped) -- same as FakeBus.on / MessageBusClient.on. + guard = self._mirror_guard_for(msg_type, handler) + if guard is not None: def wrapped(message=None): if guard(message): return @@ -420,6 +578,10 @@ def wrapped(message=None): return self.ee.on(msg_type, handler) + # shared with FakeBus -- same guard-scope rules (see FakeBus._mirror_guard_for) + _mirror_guard_for = FakeBus._mirror_guard_for + _release_intent_pair_guard = FakeBus._release_intent_pair_guard + def once(self, msg_type, handler): self.ee.once(msg_type, handler) @@ -435,6 +597,7 @@ def remove(self, msg_type, handler): if not regs: self._dedup_registrations.pop(handler, None) self._handler_guards.pop(handler, None) + self._release_intent_pair_guard(msg_type) return try: self.ee.remove_listener(msg_type, handler) @@ -466,6 +629,10 @@ async def close(self): # ------------------------------------------------------------------ async def emit(self, message): + # RULE 2 dedup marker: pop before any dispatch -- see FakeBus.emit + # for the rationale (a handler-derived forward()/reply() must not + # inherit this frame's twin marker). + is_intent_twin = message.context.pop(INTENT_COMPAT_TWIN_KEY, False) if "session" not in message.context: try: # replicate side effects from ovos_bus_client.session import Session, SessionManager @@ -493,11 +660,14 @@ async def emit(self, message): self.ee.emit(topic, message.forward(topic, translated)) except Exception as e: LOG.exception(f"Error in counterpart dispatch for '{topic}': {e}") + self._bridge_intent_topic(message, is_twin=is_intent_twin) # ------------------------------------------------------------------ # Sync helpers used internally — same as FakeBus # ------------------------------------------------------------------ + _bridge_intent_topic = FakeBus._bridge_intent_topic + def on_message(self, *args): """Handle an incoming websocket message. diff --git a/test/unittests/test_fakebus_intent_topic_bridge.py b/test/unittests/test_fakebus_intent_topic_bridge.py new file mode 100644 index 00000000..988ef084 --- /dev/null +++ b/test/unittests/test_fakebus_intent_topic_bridge.py @@ -0,0 +1,195 @@ +"""FakeBus mirrors MessageBusClient's legacy<->canonical INTENT-topic bridge +(RULE 1 send-side twin / RULE 2 receive-side modernize), so in-process tests +raw-emitting a legacy ``:IntentName.intent`` topic reach a +canonical-only listener the same way a real websocket deployment does, and +vice-versa. + +Root cause this closes: FakeBus already wired ovos_spec_tools's +NamespaceTranslator for the fixed SpecMessage pairs (speak <-> ovos.utterance.speak +etc, see test_fakebus_namespace_migration.py) but NOT the per-intent +dispatch-topic bridge that ovos_bus_client.client.client.MessageBusClient +applies via ``_send_legacy_intent_twin`` / ``_modernize_intent_topic``. Since +ovos-workshop >= 9.3.11a2 dropped its own dual-bind (only registers the +canonical listener), an in-process test emitting the legacy suffixed topic +directly never reached the handler -- while a real deployment, whose bus +client performs this bridge, dealiased fine. +""" +import asyncio +import unittest +from unittest.mock import patch + +from ovos_utils.fakebus import AsyncFakeBus, FakeBus, Message, INTENT_COMPAT_TWIN_KEY + + +def _run(coro): + return asyncio.run(coro) + + +LEGACY = "myskill.foo:HelloIntent.intent" +CANONICAL = "myskill.foo:HelloIntent" + + +class TestFakeBusIntentTopicBridge(unittest.TestCase): + def test_legacy_emit_reaches_canonical_listener(self): + # RULE 2: a raw legacy-suffixed emit (no bus-client, no twin marker) + # must still fire a canonical-only listener. + bus = FakeBus() # both flags default on + got = [] + bus.on(CANONICAL, lambda m: got.append(m.msg_type)) + bus.emit(Message(LEGACY, {"utterance": "hi"})) + self.assertEqual(got, [CANONICAL]) + + def test_canonical_emit_also_fires_legacy_listener(self): + # RULE 1: every canonical intent dispatch is twinned onto its legacy + # spelling so an old suffix-only listener still hears it. + bus = FakeBus() + got = [] + bus.on(LEGACY, lambda m: got.append(m.msg_type)) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(got, [LEGACY]) + + def test_canonical_emit_twin_not_marked_locally(self): + # On the real wire the RULE-1 twin goes out MARKED so an + # out-of-process receiver's RULE 2 knows to skip re-modernizing it. + # FakeBus has no wire hop: it already made that RULE-2 call inline + # for this dispatch, so the twin delivered to LOCAL listeners must + # NOT carry the marker -- matching the real client, whose receiving + # process pops the marker before any local handler ever sees it + # (client.py:351, before local dispatch). Carrying it into the local + # twin would also break the per-topic-pair mirror guard's + # payload+context fingerprint match (see + # test_no_double_fire_dual_listener) and leak onto any descendant + # frame a handler derives via forward()/reply(). + bus = FakeBus() + got = [] + bus.on(LEGACY, lambda m: got.append(m)) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(len(got), 1) + self.assertNotIn(INTENT_COMPAT_TWIN_KEY, got[0].context) + + def test_no_double_fire_dual_listener(self): + # a handler subscribed to BOTH the legacy and canonical topic must + # not see the same logical dispatch twice -- matching the real + # MessageBusClient, whose per-topic-pair mirror guard (shared by + # every registration on either spelling) drops the twin as a + # re-delivery of the same logical event. ovos-workshop 9.3.2a1+ + # binds the skill method to both spellings via a FRESH wrapper + # closure per registration, so this must hold even though the two + # ``bus.on()`` calls below pass the SAME underlying handler object. + bus = FakeBus() + calls = [] + handler = lambda m: calls.append(m.msg_type) + bus.on(LEGACY, handler) + bus.on(CANONICAL, handler) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(len(calls), 1) + self.assertEqual(calls, [CANONICAL]) + + def test_independent_handlers_legacy_only_starves(self): + # two INDEPENDENT handlers -- one on the canonical topic, one on the + # legacy-only spelling -- share the per-topic-pair guard (it cannot + # be scoped to a single handler, see FakeBus._mirror_guard_for), so + # a canonical emit arms the guard and the legacy-only handler + # starves. This matches real-bus behavior: a process holding both + # handlers is unreachable from a single workshop version, so the + # starvation is an accepted trade-off, not a defect. + bus = FakeBus() + canonical_calls = [] + legacy_calls = [] + bus.on(CANONICAL, lambda m: canonical_calls.append(m.msg_type)) + bus.on(LEGACY, lambda m: legacy_calls.append(m.msg_type)) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(canonical_calls, [CANONICAL]) + self.assertEqual(legacy_calls, []) + + def test_twin_marker_does_not_leak_onto_unrelated_forward(self): + # RULE 2 dedup marker regression: a handler on the LEGACY spelling + # that forwards its received message's context onto an UNRELATED + # suffixed topic must not brand that unrelated frame a twin. The + # marker is popped BEFORE dispatch (mirrors + # MessageBusClient.on_message's pop-before-dispatch ordering), so it + # cannot survive onto a descendant frame created by + # Message.forward(), which deep-copies context. + bus = FakeBus() + seen = [] + other_legacy = "other.skill:OtherIntent.intent" + other_canonical = "other.skill:OtherIntent" + + def legacy_handler(m): + bus.emit(m.forward(other_legacy, {})) + + bus.on(LEGACY, legacy_handler) + bus.on(other_canonical, lambda m: seen.append("canonical-modernized")) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(seen, ["canonical-modernized"]) + + def test_twin_marker_suppresses_rule2_recascade(self): + # if a caller manually emits a message already carrying the twin + # marker (simulating what a real client would receive as the twin + # half of a pair), RULE 2 must not modernize it again -- proving the + # bridge cannot cascade into a modernize/twin loop. + bus = FakeBus() + canonical_hits = [] + bus.on(CANONICAL, lambda m: canonical_hits.append(1)) + msg = Message(LEGACY, {"utterance": "hi"}, + {INTENT_COMPAT_TWIN_KEY: True}) + bus.emit(msg) + self.assertEqual(canonical_hits, []) + + def test_non_intent_topic_untouched(self): + bus = FakeBus() + got = [] + bus.on("my.custom.topic", lambda m: got.append(m.msg_type)) + bus.emit(Message("my.custom.topic", {"x": 1})) + self.assertEqual(got, ["my.custom.topic"]) + # and no stray listeners fired for unrelated suffixed-looking topics + got2 = [] + bus.on("speak", lambda m: got2.append(m.msg_type)) + bus.emit(Message("my.custom.topic", {"x": 1})) + self.assertEqual(got2, []) + + def test_flags_off_no_bridging(self): + # each direction gets its own bus/listener pair: a listener on the + # SAME topic as what's emitted always fires (plain same-topic + # dispatch, unrelated to the bridge) -- only the OTHER namespace's + # listener proves whether bridging happened. + bus1 = FakeBus(modernize=False, emit_legacy=False) + got_canonical = [] + bus1.on(CANONICAL, lambda m: got_canonical.append(1)) + bus1.emit(Message(LEGACY, {"utterance": "hi"})) + self.assertEqual(got_canonical, []) # RULE 2 suppressed + + bus2 = FakeBus(modernize=False, emit_legacy=False) + got_legacy = [] + bus2.on(LEGACY, lambda m: got_legacy.append(1)) + bus2.emit(Message(CANONICAL, {"utterance": "hi"})) + self.assertEqual(got_legacy, []) # RULE 1 suppressed + + def test_already_canonical_not_re_twinned_into_itself(self): + # a topic with no legacy counterpart (canonical == legacy, e.g. a + # non-suffixed topic that is not an intent topic at all) is a no-op. + bus = FakeBus() + calls = [] + bus.on(CANONICAL, lambda m: calls.append(1)) + bus.emit(Message(CANONICAL, {"utterance": "hi"})) + # exactly one direct dispatch; the RULE-1 twin went to LEGACY, not + # back onto CANONICAL, so no double count here. + self.assertEqual(calls, [1]) + + def test_async_fakebus_legacy_emit_reaches_canonical_listener(self): + bus = AsyncFakeBus() + got = [] + bus.on(CANONICAL, lambda m: got.append(m.msg_type)) + _run(bus.emit(Message(LEGACY, {"utterance": "hi"}))) + self.assertEqual(got, [CANONICAL]) + + def test_async_fakebus_canonical_emit_fires_legacy_listener(self): + bus = AsyncFakeBus() + got = [] + bus.on(LEGACY, lambda m: got.append(m.msg_type)) + _run(bus.emit(Message(CANONICAL, {"utterance": "hi"}))) + self.assertEqual(got, [LEGACY]) + + +if __name__ == "__main__": + unittest.main() From f304756ade49a3b94e1ab4f71efcc6d9fe6b7f21 Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:29:18 +0000 Subject: [PATCH 63/64] Increment Version to 0.13.10a1 --- ovos_utils/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_utils/version.py b/ovos_utils/version.py index a1097a91..684ed0bf 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 13 -VERSION_BUILD = 9 -VERSION_ALPHA = 2 +VERSION_BUILD = 10 +VERSION_ALPHA = 1 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") From 38eba04549e6e88eebba6a253c263553196d289b Mon Sep 17 00:00:00 2001 From: JarbasAl <33701864+JarbasAl@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:29:52 +0000 Subject: [PATCH 64/64] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5feba72..d7466aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.13.10a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.10a1) (2026-08-13) + +[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.9a2...0.13.10a1) + +**Merged pull requests:** + +- fix: give FakeBus the intent-topic bridge \(RULE 1/RULE 2 parity with MessageBusClient\) [\#417](https://github.com/OpenVoiceOS/ovos-utils/pull/417) ([JarbasAl](https://github.com/JarbasAl)) + ## [0.13.9a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a2) (2026-08-01) [Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.9a1...0.13.9a2)