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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] =?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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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)