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/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..4fd6fb9e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,101 @@
+# AGENTS.md
+
+Conventions for AI coding agents (internal and community) working in this
+repository.
+
+## What this repo is
+
+`ovos-utils` is the lowest-level shared utility package in the OVOS stack.
+It covers logging, process management, threading helpers (including the
+killable-daemon primitive used by `ovos-workshop`), XDG path resolution,
+and `fakebus`, an in-process, no-websocket stand-in for
+`ovos_bus_client.MessageBusClient` used by tests and standalone tooling
+throughout the ecosystem.
+
+It has almost no OVOS-specific dependencies of its own (its `extras` group
+is what pulls in `ovos-plugin-manager`, `ovos-config`, `ovos-workshop`, and
+`ovos_bus_client` for the optional higher-level helpers). Nearly every
+other OVOS package depends on the base install of this one, so a behavior
+change here is felt ecosystem-wide.
+
+## Ground rules
+
+- Work on a feature branch. Never push to `dev` or `master` directly.
+- Open pull requests against `dev` as **drafts** until CI is green and the
+ change is ready for review.
+- One commit per PR. Squash before pushing if history accumulates.
+
+- Use conventional commit prefixes (`fix:`, `feat:`, `refactor:`, `docs:`,
+ `test:`, `chore:`). Reserve `feat:` for changes a user or downstream
+ consumer can actually observe.
+- Never hand-edit `ovos_utils/version.py`. CI computes and bumps the version
+ from conventional commit history.
+
+- Every PR description and issue you write or edit carries an AI-authorship
+ disclosure at the top, naming the exact model used, and states the text is
+ not human-reviewed.
+
+## Dependencies
+
+- Use `uv`, never `pip`, for installing and resolving dependencies.
+- Pin floors only, and always allow prereleases: `>=X.Y.Za1`.
+
+- All dependency and metadata declarations live in `pyproject.toml`. A
+ `uv.lock` file is present in this repo for local dev reproducibility
+ only. It is not a substitute for floor pins in `pyproject.toml` and
+ should not be hand-edited. Regenerate it with `uv lock` if it drifts.
+
+- Never install a dependency from a git URL. Publish an alpha to PyPI and
+ depend on that.
+- Keep the base install (no `extras`) free of hard OVOS-internal
+ dependencies. That is what lets low-level tooling and other packages'
+ test suites depend on `ovos-utils` without pulling in the rest of the
+ stack. New functionality that needs `ovos-config`, `ovos_bus_client`, or
+ `ovos-plugin-manager` belongs behind the `extras` group.
+
+## Testing
+
+```bash
+uv venv .venv
+source .venv/bin/activate
+uv pip install -e ".[extras]"
+uv pip install pytest pytest-cov
+pytest test/unittests
+```
+
+There is no `test` extra in `pyproject.toml`; test-only dependencies
+(`pytest`, `pytest-cov`) are installed directly rather than through an
+extras group.
+
+A regression test for a bug must be shown to fail against the code before the
+fix and pass after it. A test that passes against unfixed code proves
+nothing and does not satisfy this gate.
+
+## Docs discipline
+
+Any change that touches observable behavior updates `README.md` and the
+relevant file under `docs/` (`fakebus.md`, `events.md`, `log.md`,
+`process-utils.md`, `utilities.md`) in the same PR.
+
+Also add a version-stamped entry at the top of `docs/prerelease-quirks.md`
+describing the change (create the file if it does not exist yet), newest
+entry first.
+
+## Repo-specific notes
+
+- `ovos_utils.fakebus.FakeBus` must keep behaving like the real
+ `MessageBusClient` API (same method names, same event semantics) without
+ opening a websocket. It is what lets skills and plugins across the
+ ecosystem be unit-tested without a running messagebus. Any divergence
+ from the real client's observable behavior is a bug even if `fakebus`'s
+ own tests pass.
+
+- The `ovos-logs` console script (declared in `[project.scripts]`) points
+ at `ovos_utils.log_parser:ovos_logs`. It lives inside the `ovos_utils`
+ package itself. An empty `ovos_logs_console_script` file exists at the
+ repo root but is not referenced by `pyproject.toml`'s
+ `[tool.setuptools.packages.find]` (`include = ["ovos_utils*"]`). Don't
+ assume it is where the script's code lives.
+
+- `test/unittests` is the real test path (not bare `test/`). Point test
+ commands and CI config at that directory.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed7c503e..2fcdc0e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,290 @@
# Changelog
+## [0.15.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.15.1a1) (2026-09-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.15.0a1...0.15.1a1)
+
+**Merged pull requests:**
+
+- fix: guard slice\(\) against non-datetime timestamps and broken symlinks [\#443](https://github.com/OpenVoiceOS/ovos-utils/pull/443) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.15.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.15.0a1) (2026-09-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.14.2a1...0.15.0a1)
+
+**Merged pull requests:**
+
+- feat: address the pip installer by data.service\_name [\#444](https://github.com/OpenVoiceOS/ovos-utils/pull/444) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.14.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.14.2a1) (2026-09-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.14.1a1...0.14.2a1)
+
+**Merged pull requests:**
+
+- fix: FakeBus/AsyncFakeBus must not crash on a malformed session carrier [\#441](https://github.com/OpenVoiceOS/ovos-utils/pull/441) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.14.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.14.1a1) (2026-09-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.14.0a2...0.14.1a1)
+
+**Merged pull requests:**
+
+- fix: FakeBus stops folding the default session on every observed message [\#437](https://github.com/OpenVoiceOS/ovos-utils/pull/437) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.14.0a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.14.0a2) (2026-09-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.14.0a1...0.14.0a2)
+
+**Merged pull requests:**
+
+- test: drop scheduler tests that pin ovos-bus-client internals [\#438](https://github.com/OpenVoiceOS/ovos-utils/pull/438) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.14.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.14.0a1) (2026-09-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.15a1...0.14.0a1)
+
+**Merged pull requests:**
+
+- feat: FakeBus mirrors the .intent-suffixed twin for aliased intents [\#411](https://github.com/OpenVoiceOS/ovos-utils/pull/411) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.15a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.15a1) (2026-09-01)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.14a2...0.13.15a1)
+
+**Merged pull requests:**
+
+- fix: log\_deprecation checks its dedup set before walking the stack [\#433](https://github.com/OpenVoiceOS/ovos-utils/pull/433) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.14a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.14a2) (2026-08-31)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.14a1...0.13.14a2)
+
+**Merged pull requests:**
+
+- docs: add AGENTS.md with the conventions for coding agents [\#431](https://github.com/OpenVoiceOS/ovos-utils/pull/431) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.14a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.14a1) (2026-08-31)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.13a1...0.13.14a1)
+
+**Merged pull requests:**
+
+- fix: harden MediaEntry against invalid numeric fields and dict2entry error type [\#429](https://github.com/OpenVoiceOS/ovos-utils/pull/429) ([JarbasAl](https://github.com/JarbasAl))
+- perf: skip disabled log call-site resolution [\#415](https://github.com/OpenVoiceOS/ovos-utils/pull/415) ([goldyfruit](https://github.com/goldyfruit))
+
+## [0.13.13a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.13a1) (2026-08-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.12a2...0.13.13a1)
+
+**Merged pull requests:**
+
+- fix: floor ovos-spec-tools at the release that ships intent\_topics [\#427](https://github.com/OpenVoiceOS/ovos-utils/pull/427) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.12a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.12a2) (2026-08-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.12a1...0.13.12a2)
+
+**Merged pull requests:**
+
+- docs: add prerelease-quirks changelog since 0.8.5 [\#425](https://github.com/OpenVoiceOS/ovos-utils/pull/425) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.12a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.12a1) (2026-08-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.11a1...0.13.12a1)
+
+**Merged pull requests:**
+
+- fix: close leaked DNS-probe socket, silence deprecation warning noise [\#422](https://github.com/OpenVoiceOS/ovos-utils/pull/422) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.11a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.11a1) (2026-08-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.10a2...0.13.11a1)
+
+**Merged pull requests:**
+
+- fix: restore idempotent double-registration for intent-topic wrapped handlers [\#421](https://github.com/OpenVoiceOS/ovos-utils/pull/421) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.10a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.10a2) (2026-08-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.10a1...0.13.10a2)
+
+**Merged pull requests:**
+
+- docs\(fakebus\): clarify single-connection model for intent-topic bridge [\#419](https://github.com/OpenVoiceOS/ovos-utils/pull/419) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.10a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.10a1) (2026-08-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.9a2...0.13.10a1)
+
+**Merged pull requests:**
+
+- fix: give FakeBus the intent-topic bridge \(RULE 1/RULE 2 parity with MessageBusClient\) [\#417](https://github.com/OpenVoiceOS/ovos-utils/pull/417) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.9a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a2) (2026-08-01)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.9a1...0.13.9a2)
+
+**Merged pull requests:**
+
+- docs: rewrite README in Simplified Technical English [\#413](https://github.com/OpenVoiceOS/ovos-utils/pull/413) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.9a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.9a1) (2026-07-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.8a1...0.13.9a1)
+
+**Merged pull requests:**
+
+- fix: watch files that do not exist yet [\#408](https://github.com/OpenVoiceOS/ovos-utils/pull/408) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.8a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.8a1) (2026-07-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a2...0.13.8a1)
+
+**Merged pull requests:**
+
+- fix: FileEventHandler must only fire for the file it watches [\#406](https://github.com/OpenVoiceOS/ovos-utils/pull/406) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.7a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a2) (2026-07-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.7a1...0.13.7a2)
+
+**Merged pull requests:**
+
+- refactor: deprecate create\_self\_signed\_cert [\#404](https://github.com/OpenVoiceOS/ovos-utils/pull/404) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.7a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.7a1) (2026-07-23)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.6a1...0.13.7a1)
+
+**Merged pull requests:**
+
+- fix: replace removed distutils.spawn with shutil.which \(Python 3.12+\) [\#402](https://github.com/OpenVoiceOS/ovos-utils/pull/402) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.6a1) (2026-07-23)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.5a1...0.13.6a1)
+
+**Merged pull requests:**
+
+- fix: ovos-logs CLI leaves stray file in cwd [\#400](https://github.com/OpenVoiceOS/ovos-utils/pull/400) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.5a1) (2026-07-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.4a1...0.13.5a1)
+
+**Merged pull requests:**
+
+- fix: log each unique deprecation warning only once [\#398](https://github.com/OpenVoiceOS/ovos-utils/pull/398) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.4a1) (2026-07-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.3a1...0.13.4a1)
+
+**Merged pull requests:**
+
+- fix: FakeBus folds the message session BEFORE handlers, not after [\#396](https://github.com/OpenVoiceOS/ovos-utils/pull/396) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.13.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.13.3a1) (2026-06-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.13.2a1...0.13.3a1)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**Merged pull requests:**
+
+- feat: AsyncFakeBus alongside FakeBus [\#371](https://github.com/OpenVoiceOS/ovos-utils/pull/371) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.8.5](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5) (2026-03-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.5a4...0.8.5)
+
## [0.8.5a4](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.5a4) (2026-03-11)
[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.6a2...0.8.5a4)
@@ -40,6 +325,7 @@
**Merged pull requests:**
+- Release 0.8.5a2 [\#351](https://github.com/OpenVoiceOS/ovos-utils/pull/351) ([github-actions[bot]](https://github.com/apps/github-actions))
- 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)
@@ -48,8 +334,837 @@
**Merged pull requests:**
+- Release 0.8.5a1 [\#344](https://github.com/OpenVoiceOS/ovos-utils/pull/344) ([github-actions[bot]](https://github.com/apps/github-actions))
- 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))
+## [0.8.4](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.4) (2025-11-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.4a1...0.8.4)
+
+## [0.8.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.4a1) (2025-10-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.3a1...0.8.4a1)
+
+**Merged pull requests:**
+
+- fix: handle issues in NVDA python stdlib [\#341](https://github.com/OpenVoiceOS/ovos-utils/pull/341) ([JarbasAl](https://github.com/JarbasAl))
+
+## [0.8.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.3a1) (2025-10-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.2a1...0.8.3a1)
+
+## [0.8.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.2a1) (2025-09-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.1...0.8.2a1)
+
+## [0.8.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.1) (2025-06-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.0a2...0.8.1)
+
+## [0.8.0a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.0a2) (2025-06-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.0...0.8.0a2)
+
+## [0.8.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.0) (2025-06-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.8.0a1...0.8.0)
+
+## [0.8.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.8.0a1) (2025-05-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.7.1...0.8.0a1)
+
+## [0.7.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.7.1) (2025-04-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.7.1a1...0.7.1)
+
+## [0.7.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.7.1a1) (2025-04-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.7.0...0.7.1a1)
+
+## [0.7.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.7.0) (2025-02-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.7.0a1...0.7.0)
+
+## [0.7.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.7.0a1) (2025-02-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.6.1...0.7.0a1)
+
+## [0.6.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.6.1) (2025-01-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.6.1a2...0.6.1)
+
+## [0.6.1a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.6.1a2) (2025-01-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.6.1a1...0.6.1a2)
+
+## [0.6.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.6.1a1) (2025-01-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.6.0...0.6.1a1)
+
+## [0.6.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.6.0) (2024-12-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.6.0a1...0.6.0)
+
+## [0.6.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.6.0a1) (2024-12-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.6...0.6.0a1)
+
+## [0.5.6](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.6) (2024-12-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.6a1...0.5.6)
+
+## [0.5.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.6a1) (2024-12-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.5...0.5.6a1)
+
+## [0.5.5](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.5) (2024-11-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.5a1...0.5.5)
+
+## [0.5.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.5a1) (2024-11-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.4...0.5.5a1)
+
+## [0.5.4](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.4) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.4a1...0.5.4)
+
+## [0.5.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.4a1) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.3...0.5.4a1)
+
+## [0.5.3](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.3) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.3a1...0.5.3)
+
+## [0.5.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.3a1) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.2...0.5.3a1)
+
+## [0.5.2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.2) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.2a1...0.5.2)
+
+## [0.5.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.2a1) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.1...0.5.2a1)
+
+## [0.5.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.1) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.1a1...0.5.1)
+
+## [0.5.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.1a1) (2024-11-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.0...0.5.1a1)
+
+## [0.5.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.0) (2024-11-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.5.0a1...0.5.0)
+
+## [0.5.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.5.0a1) (2024-11-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.4.1...0.5.0a1)
+
+## [0.4.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.4.1) (2024-11-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.4.1a1...0.4.1)
+
+## [0.4.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.4.1a1) (2024-11-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.4.0...0.4.1a1)
+
+## [0.4.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.4.0) (2024-11-19)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.8a2...0.4.0)
+
+## [0.3.8a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.8a2) (2024-11-19)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.8a1...0.3.8a2)
+
+## [0.3.8a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.8a1) (2024-11-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.7...0.3.8a1)
+
+## [0.3.7](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.7) (2024-11-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.7a1...0.3.7)
+
+## [0.3.7a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.7a1) (2024-11-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.6...0.3.7a1)
+
+## [0.3.6](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.6) (2024-10-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.6a1...0.3.6)
+
+## [0.3.6a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.6a1) (2024-10-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.5...0.3.6a1)
+
+## [0.3.5](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.5) (2024-10-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.5a1...0.3.5)
+
+## [0.3.5a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.5a1) (2024-10-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.4...0.3.5a1)
+
+## [0.3.4](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.4) (2024-10-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.4a1...0.3.4)
+
+## [0.3.4a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.4a1) (2024-10-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.3...0.3.4a1)
+
+## [0.3.3](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.3) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.3a1...0.3.3)
+
+## [0.3.3a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.3a1) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.2a1...0.3.3a1)
+
+## [0.3.2a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.2a1) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.1...0.3.2a1)
+
+## [0.3.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.1) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.1a2...0.3.1)
+
+## [0.3.1a2](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.1a2) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.1a1...0.3.1a2)
+
+## [0.3.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.1a1) (2024-10-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.0...0.3.1a1)
+
+## [0.3.0](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.0) (2024-10-09)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.3.0a1...0.3.0)
+
+## [0.3.0a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.3.0a1) (2024-09-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.2.1...0.3.0a1)
+
+## [0.2.1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.2.1) (2024-09-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.2.1a1...0.2.1)
+
+## [0.2.1a1](https://github.com/OpenVoiceOS/ovos-utils/tree/0.2.1a1) (2024-09-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.1.0...0.2.1a1)
+
+## [V0.1.0](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.1.0) (2024-09-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.38...V0.1.0)
+
+## [V0.0.38](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.38) (2023-12-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37.post1...V0.0.38)
+
+## [V0.0.37.post1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37.post1) (2023-12-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0."37.post1"...V0.0.37.post1)
+
+## [V0.0."37.post1"](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0."37.post1") (2023-12-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37...V0.0."37.post1")
+
+## [V0.0.37](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37) (2023-12-28)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37a2...V0.0.37)
+
+## [V0.0.37a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a2) (2023-12-18)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.37a1...V0.0.37a2)
+
+## [V0.0.37a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.37a1) (2023-11-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36...V0.0.37a1)
+
+## [V0.0.36](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36) (2023-10-26)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a12...V0.0.36)
+
+## [V0.0.36a12](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a12) (2023-10-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a11...V0.0.36a12)
+
+## [V0.0.36a11](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a11) (2023-10-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a10...V0.0.36a11)
+
+## [V0.0.36a10](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a10) (2023-10-19)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a9...V0.0.36a10)
+
+## [V0.0.36a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a9) (2023-10-12)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a8...V0.0.36a9)
+
+## [V0.0.36a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a8) (2023-09-26)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a7...V0.0.36a8)
+
+## [V0.0.36a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a7) (2023-09-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a6...V0.0.36a7)
+
+## [V0.0.36a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a6) (2023-09-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a5...V0.0.36a6)
+
+## [V0.0.36a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a5) (2023-09-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a4...V0.0.36a5)
+
+## [V0.0.36a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a4) (2023-09-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a3...V0.0.36a4)
+
+## [V0.0.36a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a3) (2023-08-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a2...V0.0.36a3)
+
+## [V0.0.36a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a2) (2023-08-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.36a1...V0.0.36a2)
+
+## [V0.0.36a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.36a1) (2023-08-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35...V0.0.36a1)
+
+## [V0.0.35](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35) (2023-07-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a9...V0.0.35)
+
+## [V0.0.35a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a9) (2023-07-19)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a8...V0.0.35a9)
+
+## [V0.0.35a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a8) (2023-07-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a7...V0.0.35a8)
+
+## [V0.0.35a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a7) (2023-07-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a6...V0.0.35a7)
+
+## [V0.0.35a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a6) (2023-07-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a5...V0.0.35a6)
+
+## [V0.0.35a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a5) (2023-07-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a4...V0.0.35a5)
+
+## [V0.0.35a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a4) (2023-07-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a3...V0.0.35a4)
+
+## [V0.0.35a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a3) (2023-07-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a2...V0.0.35a3)
+
+## [V0.0.35a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a2) (2023-06-28)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.35a1...V0.0.35a2)
+
+## [V0.0.35a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.35a1) (2023-06-21)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34...V0.0.35a1)
+
+## [V0.0.34](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34) (2023-06-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a9...V0.0.34)
+
+## [V0.0.34a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a9) (2023-06-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a8...V0.0.34a9)
+
+## [V0.0.34a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a8) (2023-06-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a7...V0.0.34a8)
+
+## [V0.0.34a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a7) (2023-06-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a6...V0.0.34a7)
+
+## [V0.0.34a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a6) (2023-06-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a5...V0.0.34a6)
+
+## [V0.0.34a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a5) (2023-06-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a3...V0.0.34a5)
+
+## [V0.0.34a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a3) (2023-06-09)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a2...V0.0.34a3)
+
+## [V0.0.34a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a2) (2023-06-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.34a1...V0.0.34a2)
+
+## [V0.0.34a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.34a1) (2023-06-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33...V0.0.34a1)
+
+## [V0.0.33](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33) (2023-06-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a12...V0.0.33)
+
+## [V0.0.33a12](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a12) (2023-05-31)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a11...V0.0.33a12)
+
+## [V0.0.33a11](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a11) (2023-05-30)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a10...V0.0.33a11)
+
+## [V0.0.33a10](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a10) (2023-05-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a9...V0.0.33a10)
+
+## [V0.0.33a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a9) (2023-05-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a8...V0.0.33a9)
+
+## [V0.0.33a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a8) (2023-05-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a7...V0.0.33a8)
+
+## [V0.0.33a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a7) (2023-05-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a6...V0.0.33a7)
+
+## [V0.0.33a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a6) (2023-05-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a5...V0.0.33a6)
+
+## [V0.0.33a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a5) (2023-05-01)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a4...V0.0.33a5)
+
+## [V0.0.33a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a4) (2023-05-01)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a3...V0.0.33a4)
+
+## [V0.0.33a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a3) (2023-04-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a2...V0.0.33a3)
+
+## [V0.0.33a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a2) (2023-04-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.33a1...V0.0.33a2)
+
+## [V0.0.33a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.33a1) (2023-04-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.32...V0.0.33a1)
+
+## [V0.0.32](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.32) (2023-04-18)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a18...V0.0.32)
+
+## [V0.0.31a18](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a18) (2023-04-18)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a17...V0.0.31a18)
+
+## [V0.0.31a17](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a17) (2023-04-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a16...V0.0.31a17)
+
+## [V0.0.31a16](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a16) (2023-04-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a15...V0.0.31a16)
+
+## [V0.0.31a15](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a15) (2023-04-14)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a14...V0.0.31a15)
+
+## [V0.0.31a14](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a14) (2023-04-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a13...V0.0.31a14)
+
+## [V0.0.31a13](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a13) (2023-04-13)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a12...V0.0.31a13)
+
+## [V0.0.31a12](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a12) (2023-04-12)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a11...V0.0.31a12)
+
+## [V0.0.31a11](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a11) (2023-04-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a10...V0.0.31a11)
+
+## [V0.0.31a10](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a10) (2023-04-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a9...V0.0.31a10)
+
+## [V0.0.31a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a9) (2023-04-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a8...V0.0.31a9)
+
+## [V0.0.31a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a8) (2023-04-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a7...V0.0.31a8)
+
+## [V0.0.31a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a7) (2023-04-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a6...V0.0.31a7)
+
+## [V0.0.31a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a6) (2023-04-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a5...V0.0.31a6)
+
+## [V0.0.31a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a5) (2023-04-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a4...V0.0.31a5)
+
+## [V0.0.31a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a4) (2023-04-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a3...V0.0.31a4)
+
+## [V0.0.31a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a3) (2023-04-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a2...V0.0.31a3)
+
+## [V0.0.31a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a2) (2023-04-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.31a1...V0.0.31a2)
+
+## [V0.0.31a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.31a1) (2023-03-23)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.30...V0.0.31a1)
+
+## [V0.0.30](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.30) (2023-03-09)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.30a4...V0.0.30)
+
+## [V0.0.30a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.30a4) (2023-03-09)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.30a3...V0.0.30a4)
+
+## [V0.0.30a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.30a3) (2023-03-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.30a2...V0.0.30a3)
+
+## [V0.0.30a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.30a2) (2023-03-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.30a1...V0.0.30a2)
+
+## [V0.0.30a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.30a1) (2023-03-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.29...V0.0.30a1)
+
+## [V0.0.29](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.29) (2023-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.29a2...V0.0.29)
+
+## [V0.0.29a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.29a2) (2023-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.29a1...V0.0.29a2)
+
+## [V0.0.29a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.29a1) (2023-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28...V0.0.29a1)
+
+## [V0.0.28](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28) (2023-02-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a7...V0.0.28)
+
+## [V0.0.28a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a7) (2023-02-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a6...V0.0.28a7)
+
+## [V0.0.28a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a6) (2023-02-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a5...V0.0.28a6)
+
+## [V0.0.28a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a5) (2023-02-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a4...V0.0.28a5)
+
+## [V0.0.28a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a4) (2023-02-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a3...V0.0.28a4)
+
+## [V0.0.28a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a3) (2023-02-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a2...V0.0.28a3)
+
+## [V0.0.28a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a2) (2023-02-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.28a1...V0.0.28a2)
+
+## [V0.0.28a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.28a1) (2023-01-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27...V0.0.28a1)
+
+## [V0.0.27](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27) (2023-01-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a8...V0.0.27)
+
+## [V0.0.27a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a8) (2023-01-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a7...V0.0.27a8)
+
+## [V0.0.27a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a7) (2023-01-12)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a6...V0.0.27a7)
+
+## [V0.0.27a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a6) (2023-01-05)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a5...V0.0.27a6)
+
+## [V0.0.27a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a5) (2022-12-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a4...V0.0.27a5)
+
+## [V0.0.27a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a4) (2022-11-30)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a3...V0.0.27a4)
+
+## [V0.0.27a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a3) (2022-11-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a2...V0.0.27a3)
+
+## [V0.0.27a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a2) (2022-11-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.27a1...V0.0.27a2)
+
+## [V0.0.27a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.27a1) (2022-11-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.26...V0.0.27a1)
+
+## [V0.0.26](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.26) (2022-10-29)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.26a2...V0.0.26)
+
+## [V0.0.26a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.26a2) (2022-10-22)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.26a1...V0.0.26a2)
+
+## [V0.0.26a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.26a1) (2022-10-19)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25...V0.0.26a1)
+
+## [V0.0.25](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25) (2022-10-18)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a15...V0.0.25)
+
+## [V0.0.25a15](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a15) (2022-10-18)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a14...V0.0.25a15)
+
+## [V0.0.25a14](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a14) (2022-10-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a13...V0.0.25a14)
+
+## [V0.0.25a13](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a13) (2022-10-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a12...V0.0.25a13)
+
+## [V0.0.25a12](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a12) (2022-10-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a11...V0.0.25a12)
+
+## [V0.0.25a11](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a11) (2022-10-11)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a10...V0.0.25a11)
+
+## [V0.0.25a10](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a10) (2022-10-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a9...V0.0.25a10)
+
+## [V0.0.25a9](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a9) (2022-10-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a8...V0.0.25a9)
+
+## [V0.0.25a8](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a8) (2022-10-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a7...V0.0.25a8)
+
+## [V0.0.25a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a7) (2022-10-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a6...V0.0.25a7)
+
+## [V0.0.25a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a6) (2022-09-28)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a5...V0.0.25a6)
+
+## [V0.0.25a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a5) (2022-09-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a4...V0.0.25a5)
+
+## [V0.0.25a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a4) (2022-09-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a3...V0.0.25a4)
+
+## [V0.0.25a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a3) (2022-09-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a2...V0.0.25a3)
+
+## [V0.0.25a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a2) (2022-09-08)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.25a1...V0.0.25a2)
+
+## [V0.0.25a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.25a1) (2022-09-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.24...V0.0.25a1)
+
+## [V0.0.24](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.24) (2022-09-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.24a4...V0.0.24)
+
+## [V0.0.24a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.24a4) (2022-09-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.24a3...V0.0.24a4)
+
+## [V0.0.24a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.24a3) (2022-09-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.24a2...V0.0.24a3)
+
+## [V0.0.24a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.24a2) (2022-08-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.24a1...V0.0.24a2)
+
+## [V0.0.24a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.24a1) (2022-08-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23...V0.0.24a1)
+
+## [V0.0.23](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23) (2022-07-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a7...V0.0.23)
+
+## [V0.0.23a7](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a7) (2022-07-20)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a6...V0.0.23a7)
+
+## [V0.0.23a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a6) (2022-07-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a5...V0.0.23a6)
+
+## [V0.0.23a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a5) (2022-07-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a4...V0.0.23a5)
+
+## [V0.0.23a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a4) (2022-07-06)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a3...V0.0.23a4)
+
+## [V0.0.23a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a3) (2022-06-15)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a2...V0.0.23a3)
+
+## [V0.0.23a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a2) (2022-06-10)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.23a1...V0.0.23a2)
+
+## [V0.0.23a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.23a1) (2022-06-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.22...V0.0.23a1)
+
+## [V0.0.22](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.22) (2022-06-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.22a3...V0.0.22)
+
+## [V0.0.22a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.22a3) (2022-06-02)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.22a2...V0.0.22a3)
+
+## [V0.0.22a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.22a2) (2022-05-31)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.22a1...V0.0.22a2)
+
+## [V0.0.22a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.22a1) (2022-05-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21...V0.0.22a1)
+
+## [V0.0.21](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21) (2022-05-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a6...V0.0.21)
+
+## [V0.0.21a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a6) (2022-05-17)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a5...V0.0.21a6)
+
+## [V0.0.21a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a5) (2022-05-12)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a4...V0.0.21a5)
+
+## [V0.0.21a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a4) (2022-05-09)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a3...V0.0.21a4)
+
+## [V0.0.21a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a3) (2022-05-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a2...V0.0.21a3)
+
+## [V0.0.21a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a2) (2022-05-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.21a1...V0.0.21a2)
+
+## [V0.0.21a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.21a1) (2022-05-07)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.20...V0.0.21a1)
+
+## [V0.0.20](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.20) (2022-04-27)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.20a4...V0.0.20)
+
+## [V0.0.20a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.20a4) (2022-04-27)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.20a3...V0.0.20a4)
+
+## [V0.0.20a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.20a3) (2022-03-23)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.20a2...V0.0.20a3)
+
+## [V0.0.20a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.20a2) (2022-03-16)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.20a1...V0.0.20a2)
+
+## [V0.0.20a1](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.20a1) (2022-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.19...V0.0.20a1)
+
+## [V0.0.19](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.19) (2022-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.19a3...V0.0.19)
+
+## [V0.0.19a3](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.19a3) (2022-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.19a2...V0.0.19a3)
+
+## [V0.0.19a2](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.19a2) (2022-03-03)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.17a6...V0.0.19a2)
+
+## [V0.0.17a6](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.17a6) (2022-02-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.17a5...V0.0.17a6)
+
+## [V0.0.17a5](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.17a5) (2022-02-25)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.18...V0.0.17a5)
+
+## [V0.0.18](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.18) (2022-02-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/V0.0.17a4...V0.0.18)
+
+## [V0.0.17a4](https://github.com/OpenVoiceOS/ovos-utils/tree/V0.0.17a4) (2022-02-24)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/0.0.12...V0.0.17a4)
+
+## [0.0.12](https://github.com/OpenVoiceOS/ovos-utils/tree/0.0.12) (2021-11-04)
+
+[Full Changelog](https://github.com/OpenVoiceOS/ovos-utils/compare/25fe462e3c19a58f32dc1fd940bf7c96fc18e6de...0.0.12)
+
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
diff --git a/README.md b/README.md
index 7dd896e6..b7544946 100644
--- a/README.md
+++ b/README.md
@@ -1,87 +1,109 @@
# OVOS-utils
-collection of simple utilities for use across the mycroft ecosystem
+`ovos-utils` is a shared utility library for the OpenVoiceOS ecosystem. It provides
+logging, process lifecycle management, a testing-friendly fake message bus, event
+scheduling, file utilities, network checks, audio playback, and XDG path helpers.
+Most OVOS packages, including `ovos-bus-client`, `ovos-config`, and `ovos-workshop`,
+depend on it, so most projects get it as a transitive dependency.
## Install
```bash
-pip install ovos_utils
+pip install ovos-utils
```
-## Commandline scripts
-### ovos-logs
- Small helper tool to quickly navigate the logs, create slices and quickview errors
+## Usage
----------------
-- **ovos-logs slice [options]**
+The library exposes many small, independent modules. Import only what you need.
+For example, use `FakeBus` to test skill code without a live message bus:
- **Slice logs of a given time period. Defaults on the last service start (`-s`) until now (`-u`)**
+```python
+from ovos_utils.fakebus import FakeBus, FakeMessage
- _Different logs can be picked using the `-l` option. All logs will be included if not specified._
- _Optionally the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be specified._
-
+bus = FakeBus()
- _[ex: `ovos-logs slice`]_
- _Slice all logs from service start up until now._
-
- _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25`]_
- _Slice all logs from 17:05:20 until 17:05:25._
- _**no logs in that timeframe in other present logs_
-
-
- _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -l skills`]_
- _Slice skills.log from 17:05:20 until 17:05:25._
-
- _[ex: `ovos-logs slice -s 17:05:20 -u 17:05:25 -f ~/testslice.log`]_
- _Slice the logs from 17:05:20 until 17:05:25 on all log files and dump the slices in the file ~/testslice.log (default: `~/slice_.log`)._
-
---------------
+def on_utterance(message):
+ print(message.data["utterances"])
-- **ovos-logs list [-e|-w|-d|-x] [options]**
+bus.on("recognizer_loop:utterance", on_utterance)
+bus.emit(FakeMessage("recognizer_loop:utterance", {"utterances": ["hello"]}))
+```
+
+See [docs/index.md](docs/index.md) for the full module overview, with links to
+detailed pages on logging, process utilities, `FakeBus`, and event handling.
+See [docs/prerelease-quirks.md](docs/prerelease-quirks.md) for what changed
+since the last stable release.
+
+## Command line: ovos-logs
+
+`ovos-logs` is a helper tool that slices, lists, and reduces OVOS service logs.
+
+- **`ovos-logs slice [options]`**. Slice logs for a time period. The default
+ period runs from the last service start (`-s`) until now (`-u`). Pick specific
+ logs with `-l` (default: all logs). Set the log directory with `-p` and the
+ output file with `-f`.
+
+ ```bash
+ ovos-logs slice
+ # Slice all logs from the last service start until now.
+
+ ovos-logs slice -s 17:05:20 -u 17:05:25
+ # Slice all logs between 17:05:20 and 17:05:25.
+
+ ovos-logs slice -s 17:05:20 -u 17:05:25 -l skills
+ # Slice only skills.log between 17:05:20 and 17:05:25.
+
+ ovos-logs slice -s 17:05:20 -u 17:05:25 -f ~/testslice.log
+ # Slice logs between 17:05:20 and 17:05:25 into ~/testslice.log.
+ # Default output file: ~/slice_.log
+ ```
+
+- **`ovos-logs list [-e|-w|-d|-x] [options]`**. List log lines by severity
+ (error, warning, debug, exception). Specify at least one level. You can combine
+ several. Set the time range with `-s` and `-u` (default: last service start
+ until now). Pick specific logs with `-l` (default: all logs).
+
+ ```bash
+ ovos-logs list -x
+ # List EXCEPTION-level lines (with tracebacks) from the last service start until now.
- **List logs by severity (error/warning/debug/exception). A log level has to be specified - more than one can be listed**
+ ovos-logs list -w -e -s 20-12-2023 -l bus -l skills
+ # List WARNING and ERROR lines from bus.log and skills.log since 20 December 2023.
+ ```
- _A start and end date can be specified using the `-s` and `-u` options. Defaults to the last service start until now._
- _Different logs can be picked using the `-l` option. All logs will be included if not specified._
- _Optionally, the directory where the logs are stored (`-p`) and the file where the slices should be dumped (`-f`) can be passed as arguments._
+- **`ovos-logs reduce [options]`**. Shrink logs to a target size in bytes, or
+ remove entries before a given date. Pick specific logs with `-l` (default: all
+ logs). Set the log directory with `-p`.
- _[ex: `ovos-logs list -x`]_
- _List the logs with level EXCEPTION (plus tracebacks) from the last service start until now._
-
-
- _[ex: `ovos-logs list -w -e -s 20-12-2023 -l bus -l skills`]_
- _List the logs with level WARNING and ERROR from the 20th of December 2023 until now from the logs bus.log and skills.log._
-
----------------------
+ ```bash
+ ovos-logs reduce
+ # Shrink all logs to 0 bytes.
-- **ovos-logs reduce [options]**
-
- **Downsize logs to a given size (in bytes) or remove entries before a given date.**
-
- _Different logs can be included using the `-l` option. If not specified, all logs will be included._
- _Optionally the directory where the logs are stored (`-p`) can be specified._
-
- _[ex: `ovos-logs reduce`]_
- _Downsize all logs to 0 bytes_
+ ovos-logs reduce -s 1000000
+ # Shrink all logs to about 1 MB, keeping the latest entries.
- _[ex: `ovos-logs reduce -s 1000000`]_
- _Downsize all logs to ~1MB (latest logs)_
+ ovos-logs reduce -d "1-12-2023 17:00"
+ # Shrink all logs to entries after the given date and time.
- _[ex: `ovos-logs reduce -d "1-12-2023 17:00"`]_
- _Downsize all logs to entries after the specified date/time_
+ ovos-logs reduce -s 1000000 -l skills -l bus
+ # Shrink skills.log and bus.log to about 1 MB each.
+ ```
- _[ex: `ovos-logs reduce -s 1000000 -l skills -l bus`]_
- _Downsize skills.log and bus.log to ~1MB (latest logs)_
+- **`ovos-logs show -l `**. Print the contents of a log file.
----------------------
+ ```bash
+ ovos-logs show -l bus
+ # Print the contents of bus.log.
+ ```
-- **ovos-logs show -l [servicelog]**
+ The logs shown depend on which log files exist in the log folder.
- **Show logs**
+## Related projects
- _[ex: `ovos-logs show -l bus`]_
- _Show the logs from bus.log._
+- [OpenVoiceOS/ovos-bus-client](https://github.com/OpenVoiceOS/ovos-bus-client). The real message bus client that `FakeBus` and `FakeMessage` stand in for during testing.
+- [OpenVoiceOS/ovos-config](https://github.com/OpenVoiceOS/ovos-config). Reads and writes `mycroft.conf`, the configuration file used by `LOG`, `PIDLock`, and the network utilities.
+- [OpenVoiceOS/ovos-workshop](https://github.com/OpenVoiceOS/ovos-workshop). The skill framework built on `EventContainer`, `RuntimeRequirements`, and the other utilities in this library.
- _[ex: wrong servicelog]_
- _**logs shown depending on the logs present in the folder_
+## License
+Apache License 2.0. See [LICENSE](LICENSE).
diff --git a/docs/events.md b/docs/events.md
index f15edbeb..da06b66a 100644
--- a/docs/events.md
+++ b/docs/events.md
@@ -85,3 +85,6 @@ from ovos_bus_client.apis.events import EventSchedulerInterface
| `shutdown()` | Cancel repeating events and clear all registered handlers |
`when` may be a `datetime`, or a positive `int`/`float` representing seconds from now.
+
+---
+[← FakeBus](fakebus.md) · [Home](index.md) · [Utilities →](utilities.md)
diff --git a/docs/fakebus.md b/docs/fakebus.md
index 49e863c9..a8deeb13 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`
@@ -96,3 +155,6 @@ msg = FakeMessage("skill:action", {"key": "value"}, {"session_id": "abc"})
## `dig_for_message()`
Tries to import and call `ovos_bus_client.message.dig_for_message`. Returns `None` if `ovos-bus-client` is not installed.
+
+---
+[← Process Utilities](process-utils.md) · [Home](index.md) · [Events →](events.md)
diff --git a/docs/index.md b/docs/index.md
index 1df0d780..4b6f0e42 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,7 @@ 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
+- [Prerelease Quirks](prerelease-quirks.md) — behavior changes since the last stable release
diff --git a/docs/log.md b/docs/log.md
index 541b81e8..157a2c1b 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -141,3 +141,6 @@ Return all configured log directories across all services.
## `get_available_logs(directories) → List[str]`
Return a list of log file basenames (e.g. `["audio", "skills", "bus"]`) found in the configured log directories.
+
+---
+[Home](index.md) · [Process Utilities →](process-utils.md)
diff --git a/docs/prerelease-quirks.md b/docs/prerelease-quirks.md
new file mode 100644
index 00000000..c9252741
--- /dev/null
+++ b/docs/prerelease-quirks.md
@@ -0,0 +1,106 @@
+# Prerelease quirks
+
+This page tracks user-visible behavior changes since the last stable release,
+`0.8.5`. The alpha train has run a long time without a stable cut, so this
+list spans `0.9.0a1` through the current HEAD. Newest first. This file resets
+to empty at the next stable release.
+
+## next alpha
+
+- `log_deprecation()` no longer walks the full call stack
+ (`inspect.stack()`) before checking whether a given deprecation warning has
+ already been logged. Repeat calls from an already-seen call site now
+ short-circuit on a cheap per-call-site cache before any stack inspection
+ happens, so the O(n) stack walk is only paid once per unique
+ (message, call site). Logged messages and dedup behavior are unchanged; a
+ caller invoking a deprecated helper in a tight loop, or once per file in a
+ large batch, no longer pays a full stack walk on every repeat.
+- The `ovos-spec-tools` floor moves to `>=1.6.0a2`, the first release whose
+ `intent_topics` module has the shape `ovos_utils.fakebus` imports. The old
+ floor (`>=0.16.1a2`) was satisfied numerically by releases without the
+ module at all, which made ovos-utils unimportable in resolved-but-stale
+ environments — only a real install caught it.
+
+
+## 0.13.12a1 — closed a leaked DNS-probe socket
+
+`is_connected_dns()` opened a raw socket for its reachability probe and never
+closed it. Every call, and every failed probe (the common case on a flaky
+network), leaked a file descriptor. The socket now closes in a `finally`
+block. Return semantics are unchanged.
+
+## 0.13.11a1 — `FakeBus.once()` re-registration is idempotent again
+
+Re-registering the same `(msg_type, handler)` pair on `FakeBus.on()` or
+`once()` used to collapse onto one listener slot, matching how `pyee` keys
+listeners by handler object. A later change minted a fresh wrapper closure on
+every call, so `pyee` saw a new object each time and fired the handler twice.
+Both `on()` and `once()` now reuse the existing wrapper for a given
+`(msg_type, handler)` pair, restoring one-fire-per-registration behavior.
+
+## 0.13.10a1 / 0.13.10a2 — `FakeBus` gained the intent-topic bridge
+
+`FakeBus` now mirrors `MessageBusClient`'s canonical `<-> .intent`-suffixed
+topic bridge (RULE 1 / RULE 2), so tests against `FakeBus` see the same
+dual-spelling delivery a real bus connection gives them.
+
+Capture semantics you should know before writing tests against it:
+
+- `FakeBus` models **one bus connection**. Within that connection, an
+ intent-topic pair shares one dedup guard, so if both spellings have
+ subscribers, only the spelling actually emitted (or its canonical
+ modernization) is delivered — same as sharing one real client connection.
+ A legacy-only listener starves on a canonical emit. This is not a bug.
+- The twin/modernized frame dispatched by the bridge does **not** re-fire the
+ `"message"` firehose a second time. On a real wire the twin is a second
+ frame and does trigger `on_message` (and `"message"`) again in every
+ receiving process. `FakeBus` has no wire hop to put that second frame on,
+ so it keeps a single-process, one-emit-one-capture invariant instead.
+ Test authors who subscribe to `"message"` to capture everything a skill
+ emits will not see the mirrored twin as a separate capture — subscribe to
+ the canonical topic itself if the twin matters to the assertion.
+- `FakeBus` cannot represent multiple bus **connections**. An external
+ observer attached to the same `FakeBus` as the code under test shares its
+ dedup guard instead of running an independent one, unlike two real
+ connections on the wire.
+
+## 0.13.9a1 — `FileEventHandler` fires only for its own file; watches files that don't exist yet
+
+Two related fixes: a `FileEventHandler` no longer fires for changes to other
+files under watch, and `FileWatcher` can now be pointed at a path that does
+not exist yet (it starts watching once created instead of failing).
+
+## 0.13.8a1 — `distutils.spawn` replaced with `shutil.which`
+
+`distutils` is removed in Python 3.12+. Any code path that depended on
+`ovos-utils` transitively importing `distutils.spawn` now uses `shutil.which`.
+
+## 0.13.0a1 – 0.13.x — namespace-migration bridge, `AsyncFakeBus`, `ovos-spec-tools` adoption
+
+This stretch of the alpha train reworked the bus-testing surface:
+
+- `AsyncFakeBus` was added alongside `FakeBus` as an async-native counterpart.
+- `fakebus.Message` now subclasses `ovos_spec_tools.Message` — no API break,
+ but `ovos-utils` now depends on `ovos-spec-tools` (floor raised to
+ `0.10.0a1` for `NamespaceTranslator`).
+- `FakeBus`/`AsyncFakeBus` mirror the legacy `<-> ovos.*` namespace migration:
+ emitting on either spelling of a migrated topic dispatches the counterpart
+ too, with the payload reshaped into the counterpart's shape where the
+ migration changed it. The default session is folded into the message
+ context before handlers run, matching `MessageBusClient.on_message`'s
+ receive-then-fold-then-dispatch order (folding after handlers would wipe
+ in-place session mutations a handler made).
+- `standardize_lang_tag(macro=True)` again preserves region, restoring the
+ original `langcodes` semantics.
+- `json-database` 1.x is now an allowed dependency floor.
+
+## Known nondeterminism / preexisting quirks
+
+- The DNS-probe file-descriptor leak above is fixed; a `ResourceWarning`
+ sourced from the still-installed, unpatched `ovos-bus-client` dependency's
+ own `session.py` can still appear until that fix ships upstream — it does
+ not originate in this repo.
+
+See [docs/fakebus.md](fakebus.md) for the full `FakeBus`/`AsyncFakeBus`
+reference, including the intent-topic bridge and namespace-migration bridge
+in detail.
diff --git a/docs/process-utils.md b/docs/process-utils.md
index 4dba1ef1..fdefeddd 100644
--- a/docs/process-utils.md
+++ b/docs/process-utils.md
@@ -136,3 +136,6 @@ Chainable POSIX signal handler. Each instance installs a user function as the ne
## `reset_sigint_handler()`
Reset `SIGINT` to the default Python handler. Needed when starting OVOS services from shell scripts that have modified the signal mask.
+
+---
+[← Logging](log.md) · [Home](index.md) · [FakeBus →](fakebus.md)
diff --git a/docs/utilities.md b/docs/utilities.md
index e5d22cc9..d251dcda 100644
--- a/docs/utilities.md
+++ b/docs/utilities.md
@@ -201,3 +201,6 @@ cache_dir = xdg_cache_home() / "mycroft" # ~/.cache/mycroft
| `xdg_data_dirs()` | `XDG_DATA_DIRS` | `[/usr/local/share, /usr/share]` |
Environment variable values are only used if they are absolute paths; relative paths fall back to the default.
+
+---
+[← Events](events.md) · [Home](index.md)
diff --git a/ovos_logs_console_script b/ovos_logs_console_script
new file mode 100644
index 00000000..e69de29b
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/device_input.py b/ovos_utils/device_input.py
index 9c7a008a..f001a9a5 100644
--- a/ovos_utils/device_input.py
+++ b/ovos_utils/device_input.py
@@ -1,5 +1,5 @@
import subprocess
-from distutils.spawn import find_executable
+from shutil import which
from ovos_utils.gui import is_gui_installed
from ovos_utils.log import LOG
@@ -9,7 +9,7 @@ class InputDeviceHelper:
def __init__(self) -> None:
self.libinput_devices_list = []
self.xinput_devices_list = []
- if not find_executable("libinput") and not find_executable("xinput"):
+ if not which("libinput") and not which("xinput"):
LOG.warning("Could not find libinput, input device detection will be inaccurate")
# ToDo: add support for discovering the input device based of a connected
@@ -68,7 +68,7 @@ def _build_linput_devices_list(self):
})
def _get_libinput_devices_list(self):
- if find_executable("libinput"):
+ if which("libinput"):
try:
self._build_linput_devices_list()
except Exception:
@@ -98,7 +98,7 @@ def _build_xinput_devices_list(self):
self.xinput_devices_list.append(dev)
def _get_xinput_devices_list(self):
- if find_executable("xinput"):
+ if which("xinput"):
try:
self._build_xinput_devices_list()
except Exception:
@@ -113,7 +113,7 @@ def get_input_device_list(self):
return self.libinput_devices_list + self.xinput_devices_list
def can_use_touch_mouse(self):
- if not find_executable("libinput") and not find_executable("xinput"):
+ if not which("libinput") and not which("xinput"):
# if gui installed assume we have a mouse
# otherwise let's assume we are a server or something...
return is_gui_installed()
diff --git a/ovos_utils/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/fakebus.py b/ovos_utils/fakebus.py
index c0287274..86302014 100644
--- a/ovos_utils/fakebus.py
+++ b/ovos_utils/fakebus.py
@@ -1,11 +1,60 @@
-import json
+import asyncio
+import warnings
from copy import deepcopy
+from os import environ
from threading import Event
-import warnings
+
from ovos_utils.log import LOG, log_deprecation
+from ovos_spec_tools import NamespaceTranslator
+from ovos_spec_tools.intent_topics import (canonical_intent_topic,
+ intent_topic_counterpart,
+ is_intent_topic,
+ legacy_intent_topic)
from pyee import EventEmitter
+#: Context flag stamped on a twin intent frame, mirroring
+#: ``ovos_bus_client.client.client.INTENT_COMPAT_TWIN_KEY``. Its presence
+#: means the canonical spelling of this dispatch was already delivered
+#: alongside it, so a receiver that understands the bridge must not
+#: modernize the twin a second time (that would fire the handler twice).
+INTENT_COMPAT_TWIN_KEY = "_intent_compat_twin"
+
+
+def _verbatim_copy(message, topic: str):
+ """Retopic ``message`` onto ``topic``, carrying its context byte-for-byte.
+
+ Mirrors ``ovos_bus_client.client.client._verbatim_copy``. NOT
+ ``Message.forward`` -- ``forward()`` re-stamps the session, which for
+ the default session would replace the carried session with this
+ process's own and desync ``lang`` / ``active_skills`` between the
+ canonical frame and its twin.
+ """
+ return message.__class__(topic, data=deepcopy(message.data),
+ context=deepcopy(message.context))
+
+
+def _session_rejected_message(parsed_message):
+ """Build the ``ovos.session.rejected`` notice for a dropped message.
+
+ SESSION-1 §2.5 (architecture dev 198a3c9): a malformed session carrier
+ drops the whole message, but the drop is observable -- the bus emits
+ ``SpecMessage.SESSION_REJECTED`` with the dropped message's type and the
+ reason, carrying the dropped message's ``utterance_id`` when present.
+ The rejection itself names no session (there was never a valid one to
+ name), so ``context`` deliberately has no ``session`` key.
+ """
+ from ovos_spec_tools.messages import SpecMessage
+ context = {}
+ utterance_id = parsed_message.context.get("utterance_id")
+ if utterance_id is not None:
+ context["utterance_id"] = utterance_id
+ return FakeMessage(SpecMessage.SESSION_REJECTED,
+ {"msg_type": parsed_message.msg_type,
+ "reason": "malformed_carrier"},
+ context)
+
+
def dig_for_message():
try:
from ovos_bus_client.message import dig_for_message as _dig
@@ -15,12 +64,60 @@ 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
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. 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._intent_pair_guards = {} # frozenset({topic, counterpart}) -> shared mirror-guard
+ self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...]
self.on_open()
try:
self.session_id = kwargs["session"].session_id
@@ -31,12 +128,149 @@ def __init__(self, *args, **kwargs):
self.on_default_session_update)
def on(self, msg_type, handler):
+ # wrap handlers on migrated/bridged topics so a handler subscribed to
+ # both spellings of a mirrored dispatch fires once (the mirror is
+ # dropped). See _mirror_guard_for for the guard-scope rationale.
+ guard = self._mirror_guard_for(msg_type, handler)
+ if guard is not None:
+ # Re-registering the SAME (msg_type, handler) pair used to be
+ # harmless: pyee's EventEmitter keys its listener OrderedDict by
+ # the handler object, so an equal bound method collapsed onto
+ # the same slot instead of firing twice. Minting a fresh
+ # ``wrapped`` closure on every call broke that -- pyee saw a new,
+ # distinct object each time and happily fired both. Reuse the
+ # existing wrapper for this exact (msg_type, handler) pair so
+ # re-registering it re-adds the SAME closure pyee already knows,
+ # restoring the original idempotent-registration behaviour.
+ existing = self._dedup_registrations.get(handler, [])
+ for ev, wrapped in existing:
+ if ev == msg_type:
+ self.ee.on(msg_type, wrapped)
+ return
+
+ 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 _mirror_guard_for(self, msg_type, handler):
+ """The mirror guard a registration on ``msg_type`` must wrap with.
+
+ Mirrors ``ovos_bus_client.client.client.MessageBusClient._mirror_guard_for``.
+ Two bridges deliver one logical event twice, and each needs a
+ different guard SCOPE:
+
+ - **namespace migration** (legacy <-> ``ovos.*``): the guard is per
+ HANDLER, shared across that handler's registrations, so its legacy
+ ``on()`` and its ``ovos.*`` ``on()`` dedupe against each other.
+ - **intent-topic compat** (canonical <-> ``.intent``-suffixed): the
+ guard is per TOPIC PAIR, shared by every registration on either
+ spelling.
+
+ The intent guard cannot be keyed by handler: ``ovos-workshop``
+ 9.3.2a1+ binds the same skill method to both spellings through a
+ FRESH wrapper closure per binding, so the two registrations are two
+ distinct ``handler`` objects and a per-handler guard would hand each
+ its own private state -- the canonical frame runs one closure, the
+ twin runs the other, and the skill handler fires twice for a single
+ dispatch. Keying on the pair collapses them.
+ """
+ counterpart = intent_topic_counterpart(msg_type)
+ if counterpart is not None:
+ pair_key = frozenset({msg_type, counterpart})
+ guard = self._intent_pair_guards.get(pair_key)
+ if guard is None:
+ guard = self._translator.new_mirror_guard()
+ self._intent_pair_guards[pair_key] = guard
+ return guard
+ if self._translator.is_migrated(msg_type):
+ guard = self._handler_guards.get(handler)
+ if guard is None:
+ guard = self._translator.new_mirror_guard()
+ self._handler_guards[handler] = guard
+ return guard
+ return None
+
+ def _release_intent_pair_guard(self, msg_type):
+ """Drop the pair guard once nothing is registered on either spelling.
+
+ Mirrors ``MessageBusClient._release_intent_pair_guard``.
+ """
+ counterpart = intent_topic_counterpart(msg_type)
+ if counterpart is None:
+ return
+ pair_key = frozenset({msg_type, counterpart})
+ for regs in self._dedup_registrations.values():
+ if any(ev in pair_key for ev, _ in regs):
+ return
+ self._intent_pair_guards.pop(pair_key, None)
+
def once(self, msg_type, handler):
+ # Route once() through the same guard-selection as on() (see
+ # _mirror_guard_for): a handler that hears both spellings of a
+ # mirrored dispatch via once() must still fire exactly once, not
+ # twice. Mirrors MessageBusClient.once.
+ guard = self._mirror_guard_for(msg_type, handler)
+ if guard is not None:
+ existing = self._dedup_registrations.get(handler, [])
+ for ev, wrapped in existing:
+ if ev == msg_type:
+ # A once() re-registration of a still-pending (msg_type,
+ # handler) pair reuses the SAME wrapper pyee already
+ # knows -- same rationale as on()'s reuse branch.
+ self.ee.once(msg_type, wrapped)
+ return
+
+ def wrapped(message=None):
+ # pyee's once() already removes this closure from the
+ # emitter the instant it fires (whether or not the guard
+ # below goes on to suppress the call), so drop our own
+ # bookkeeping for it here too -- otherwise a later on()/
+ # once() for this (msg_type, handler) pair would try to
+ # reuse a wrapper pyee no longer holds.
+ self._forget_dedup_entry(handler, msg_type, wrapped)
+ if guard(message):
+ return
+ return handler(message)
+
+ self.ee.once(msg_type, wrapped)
+ self._dedup_registrations.setdefault(handler, []).append((msg_type, wrapped))
+ return
self.ee.once(msg_type, handler)
+ def _forget_dedup_entry(self, handler, msg_type, wrapped):
+ """Drop one wrapper's bookkeeping after pyee auto-removes it (once()).
+
+ Mirrors the cleanup ``remove()`` does for an explicit teardown, so a
+ fired once() registration leaves no stale entry for a later on()/
+ once() call on the same (msg_type, handler) pair to (mis)reuse.
+ """
+ regs = self._dedup_registrations.get(handler)
+ if not regs:
+ return
+ try:
+ regs.remove((msg_type, wrapped))
+ except ValueError:
+ return
+ if not regs:
+ self._dedup_registrations.pop(handler, None)
+ self._handler_guards.pop(handler, None)
+ self._release_intent_pair_guard(msg_type)
+
def emit(self, message):
+ # RULE 2 dedup marker: read it, then POP it before any local
+ # dispatch. A handler invoked below may call message.forward()/
+ # reply() to emit a descendant frame on an UNRELATED topic; those
+ # deep-copy the whole context, so leaving the marker in place would
+ # brand that unrelated frame a twin and silently suppress its
+ # modernization. Mirrors MessageBusClient.on_message's pop-before-
+ # dispatch ordering.
+ is_intent_twin = message.context.pop(INTENT_COMPAT_TWIN_KEY, False)
if "session" not in message.context:
try: # replicate side effects
from ovos_bus_client.session import Session, SessionManager
@@ -45,39 +279,219 @@ def emit(self, message):
message.context["session"] = sess.serialize()
except ImportError: # don't care
message.context["session"] = {"session_id": self.session_id}
+ # on_message runs BEFORE the handlers below, matching the real
+ # MessageBusClient.on_message order (receive -> take-inbound-session
+ # -> dispatch to handlers). It never folds the default session (see
+ # on_message) -- only a named session it observes gets ``update``'d,
+ # and running that after the handlers would wipe any in-place /
+ # synced mutation a handler made this same tick (handle_session_sync
+ # merging intent_context, handle_add_context injecting context
+ # frames, ...), because the message's session snapshot was stamped
+ # at emit-time, before those mutations landed.
+ #
+ # A malformed carrier (SESSION-1 §2.5) is a per-message producer
+ # fault: on_message() drops the whole message -- no listener runs --
+ # and emits ``ovos.session.rejected`` itself. Nothing more to do here.
+ if not self.on_message(message.serialize()):
+ return
+ self._deliver(message, is_intent_twin)
+
+ def _deliver(self, message, is_intent_twin=False):
+ """Dispatch ``message`` to local listeners, unconditionally.
+
+ Split out of :meth:`emit` so ``on_message`` can deliver the
+ ``ovos.session.rejected`` notice (SESSION-1 §2.5) straight to
+ listeners without going back through ``emit()``'s default-session
+ stamping -- the rejection carries no session at all.
+ """
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())
+ # namespace migration: also dispatch the counterpart topic(s) so a
+ # 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:
+ 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}")
+ try:
+ self._bridge_intent_topic(message, is_twin=is_intent_twin)
+ except Exception as e:
+ LOG.exception(f"Error in intent-topic bridge for '{message.msg_type}': {e}")
+
+ def _bridge_intent_topic(self, message, is_twin=False):
+ """Legacy <-> canonical intent-topic bridge (RULE 1 + RULE 2).
+
+ Mirrors ``ovos_bus_client.client.client.MessageBusClient``'s
+ ``_send_legacy_intent_twin`` (RULE 1, send-side) and
+ ``_modernize_intent_topic`` (RULE 2, receive-side). The real client
+ splits these across the wire (twin goes out on ``emit()``, the
+ modernized copy is dispatched locally in ``on_message()``); a
+ ``FakeBus`` has no separate wire hop, so both rules run inline,
+ against local listeners only, off of the one message being emitted.
+
+ ``is_twin`` carries the marker decision made in ``emit()``, which
+ pops :data:`INTENT_COMPAT_TWIN_KEY` off the context BEFORE any
+ dispatch so it cannot leak onto descendant frames a handler derives
+ from this one (``message.forward()``/``reply()`` deep-copy context).
+ The marker is therefore never read from ``message.context`` here --
+ only the popped value is trusted.
+
+ RULE 2 first, mirroring the real client's receive order, then
+ RULE 1 -- so a listener on the canonical topic sees the canonical
+ dispatch before the legacy twin goes out.
+
+ Note on the capture firehose (deliberate FakeBus-wide convention,
+ shared with the namespace-migration bridge above): the twin/
+ modernized copy is dispatched straight to ``self.ee`` on its own
+ topic and does NOT re-emit ``"message"``. On the real wire, the
+ twin is a second frame and therefore fires ``on_message`` (and its
+ ``"message"`` firehose) a second time in every receiving process;
+ ``FakeBus`` has no wire hop to put it on, so it keeps the
+ single-process harness's one-emit-one-capture invariant instead of
+ reproducing the wire's two-frame shape.
+
+ Plain-English note on what this can and cannot represent for tests:
+ a ``FakeBus`` models ONE bus connection, with the same per-client
+ intent-pair dedup guard (see :meth:`_mirror_guard_for`) that a real
+ ``MessageBusClient`` uses. So within one ``FakeBus``, if both
+ spellings of an intent topic have subscribers, only the spelling
+ actually emitted (or its canonical modernization) gets delivered --
+ the mirrored twin/modernized frame is deduped away, and a
+ legacy-only listener starves on a canonical emit. That is exactly
+ what would happen sharing a single real client connection too, so
+ it is not a ``FakeBus`` bug.
+ What a ``FakeBus`` cannot represent is multiple bus CONNECTIONS: on
+ a real wire, a separate connection -- e.g. an external observer
+ process -- receives both spellings, because each connection runs
+ its own independent dedup guard. Tests that attach an observer to
+ the same ``FakeBus`` the skill under test uses are simulating that
+ second connection with the first connection's guard, so the
+ observer should subscribe to the canonical topic
+ (``ovos_spec_tools.intent_topics.canonical_intent_topic``) rather
+ than assuming it will see both spellings.
+ """
+ # RULE 2 (receive-side modernize): a suffixed frame WITHOUT the twin
+ # marker came from an emitter old enough to only put the legacy
+ # spelling on the bus, so nothing canonical was sent alongside it --
+ # a canonical-only listener would never hear it without this.
+ if self._translator.modernize and not is_twin \
+ and is_intent_topic(message.msg_type):
+ canonical = canonical_intent_topic(message.msg_type)
+ if canonical != message.msg_type:
+ try:
+ self.ee.emit(canonical, _verbatim_copy(message, canonical))
+ except Exception as e:
+ LOG.exception(f"Error in intent modernize dispatch for "
+ f"'{canonical}': {e}")
+
+ # RULE 1 (send-side twin): every canonical intent dispatch is
+ # twinned onto its legacy spelling so a listener that only knows
+ # the old suffixed topic still hears it. An already-suffixed
+ # dispatch is never twinned (legacy_intent_topic is a no-op on it),
+ # so the mirror cannot cascade.
+ #
+ # On the real wire, this twin goes out MARKED (INTENT_COMPAT_TWIN_KEY),
+ # so an out-of-process receiver's own RULE 2 knows to skip
+ # re-modernizing it. FakeBus has no wire hop: RULE 2 above already
+ # made that call inline for THIS dispatch, so the marker's job is
+ # already done, and the twin delivered to local listeners here must
+ # NOT carry it. Carrying it forward would break two things: (a) the
+ # per-topic-pair mirror guard on ``on()`` fingerprints payload+context,
+ # so a marked twin would fingerprint differently from the canonical
+ # dispatch it mirrors and the guard would fail to recognize it as a
+ # duplicate, double-firing a dual-bound handler; and (b) a handler
+ # that forwards this frame's context to emit an unrelated topic would
+ # brand that unrelated frame a twin too (see the marker-leak
+ # regression test), silently suppressing its own modernization.
+ if self._translator.emit_legacy and is_intent_topic(message.msg_type):
+ topic = legacy_intent_topic(message.msg_type)
+ if topic != message.msg_type:
+ twin = _verbatim_copy(message, topic)
+ try:
+ self.ee.emit(topic, twin)
+ except Exception as e:
+ LOG.exception(f"Error in intent twin dispatch for "
+ f"'{topic}': {e}")
def on_message(self, *args):
"""
Handle an incoming websocket message
@param args:
message (str): serialized Message
+ @return: False if the message was dropped (SESSION-1 §2.5 malformed
+ carrier -- the caller must not deliver it to listeners), True
+ otherwise.
"""
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)
+ try:
+ # ovos-bus-client is an optional dependency of this extra; only
+ # its ABSENCE is swallowed here. ``resolve_session_id`` /
+ # ``session_carrier`` are guaranteed by the >=2.11.4a1 floor
+ # pin below, so a real bug in the block that follows must not
+ # be misread as "not installed" and silently disable named-
+ # session tracking.
+ from ovos_bus_client.session import (Session, SessionManager,
+ DEFAULT_SESSION_ID,
+ MalformedSession,
+ resolve_session_id,
+ session_carrier)
except ImportError:
- pass # don't care
+ return True # ovos-bus-client not installed -- don't care
+ # OVOS-SESSION-2 §5.1's arrival merge is a once-per-utterance
+ # orchestrator-intake fold, not a per-observed-message one (see
+ # core#915 / ovos-bus-client's MessageBusClient._take_inbound_
+ # session). A FakeBus models ONE bus connection for a test, and a
+ # test drives far more default-session traffic through it than
+ # one fold per utterance -- replies, handled-acks, forwarded
+ # frames. Calling ``update`` (a wholesale replace using spec
+ # defaults for omitted fields, not a field-by-field merge) on
+ # every observed default-session message would wipe stored
+ # fields a later message's carrier simply doesn't restate,
+ # violating §2.6 (mutation only at lifecycle boundaries). A test
+ # that wants the orchestrator's own intake semantics calls
+ # ``SessionManager.fold_inbound`` explicitly, the same as core
+ # does at its own intake point.
+ try:
+ carrier = session_carrier(parsed_message)
+ if resolve_session_id(carrier) != DEFAULT_SESSION_ID:
+ sess = Session.from_message(parsed_message)
+ SessionManager.update(sess)
+ except MalformedSession as e:
+ # OVOS-SESSION-1 §2.5: a non-object session carrier is a
+ # per-message producer fault, not a transport fault -- drop the
+ # WHOLE message (no listener runs) and keep going, same as
+ # MessageBusClient.on_message. A FakeBus has no transport to
+ # tear down, but it still must not silently swallow the
+ # rejection: emit ``ovos.session.rejected`` so a listener can
+ # observe/count the drop, exactly as the real bus does.
+ LOG.warning(f"discarding bus message with malformed session: {e}")
+ self._deliver(_session_rejected_message(parsed_message))
+ return False
+ return True
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)
+ # 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
@@ -135,6 +549,19 @@ 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)
+ self._release_intent_pair_guard(msg_type)
+ return
try:
self.ee.remove_listener(msg_type, handler)
except Exception:
@@ -165,182 +592,389 @@ def close(self):
self.on_close()
-class _MutableMessage(type):
- """ To override isinstance checks we need to use a metaclass """
+# 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
- 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)
+class Message(FakeMessage):
+ """Deprecated alias for the OVOS-MSG-1 ``Message`` envelope.
-# 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"""
+ ``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):
- try: # most common case
- from ovos_bus_client import Message as _M
- return _M(*args, **kwargs)
- except ImportError:
- pass
- return super().__new__(cls)
+ warnings.warn(
+ "ovos_utils.fakebus.Message is deprecated; import "
+ "ovos_spec_tools.Message (or ovos_bus_client.Message)",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ log_deprecation(
+ "please import Message from ovos_spec_tools / "
+ "ovos_bus_client directly", "1.0.0")
+ return FakeMessage(*args, **kwargs)
- 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 {}
+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).
- def __eq__(self, other):
+ 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)
+ # mirror MessageBusClient's namespace migration (see FakeBus.__init__).
+ self._translator = _resolve_bus_flags(kwargs)
+ self._handler_guards = {} # handler -> shared mirror-guard
+ self._intent_pair_guards = {} # frozenset({topic, counterpart}) -> shared mirror-guard
+ self._dedup_registrations = {} # handler -> [(msg_type, wrapped), ...]
+ self.connected_event = asyncio.Event()
+ self.connected_event.set()
+ self.on_open()
try:
- return other.msg_type == self.msg_type and \
- other.data == self.data and \
- other.context == self.context
+ self.session_id = kwargs["session"].session_id
except Exception:
- return False
+ pass # don't care
- def serialize(self):
- """This returns a string of the message info.
+ self.on("ovos.session.update_default",
+ self.on_default_session_update)
- This makes it easy to send over a websocket. This uses
- json dumps to generate the string with type, data and context
+ # ------------------------------------------------------------------
+ # Handler registration (sync — matches AsyncMessageBusClient)
+ # ------------------------------------------------------------------
- Returns:
- str: a json string representation of the message.
- """
- return json.dumps({'type': self.msg_type,
- 'data': self.data,
- 'context': self.context})
+ def on(self, msg_type, handler):
+ # wrap handlers on migrated/bridged topics so a handler subscribed to
+ # both spellings of a mirrored dispatch fires once (the mirror is
+ # dropped) -- same as FakeBus.on / MessageBusClient.on.
+ guard = self._mirror_guard_for(msg_type, handler)
+ if guard is not None:
+ # Reuse the existing wrapper for a repeat (msg_type, handler)
+ # registration instead of minting a new closure -- see the
+ # matching comment in FakeBus.on for why.
+ existing = self._dedup_registrations.get(handler, [])
+ for ev, wrapped in existing:
+ if ev == msg_type:
+ self.ee.on(msg_type, wrapped)
+ return
+
+ 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)
+
+ # shared with FakeBus -- same guard-scope rules (see FakeBus._mirror_guard_for)
+ _mirror_guard_for = FakeBus._mirror_guard_for
+ _release_intent_pair_guard = FakeBus._release_intent_pair_guard
+ _forget_dedup_entry = FakeBus._forget_dedup_entry
+ once = FakeBus.once
+
+ 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)
+ self._release_intent_pair_guard(msg_type)
+ return
+ try:
+ self.ee.remove_listener(msg_type, handler)
+ except Exception:
+ pass
- @staticmethod
- def deserialize(value):
- """This takes a string and constructs a message object.
+ def remove_all_listeners(self, event_name):
+ self.ee.remove_all_listeners(event_name)
- 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.
+ # ------------------------------------------------------------------
+ # Lifecycle (async)
+ # ------------------------------------------------------------------
- Args:
- value(str): This is the json string received from the websocket
+ async def connect(self, *args, **kwargs):
+ """No-op for the fake bus; matches the real client's lifecycle.
- Returns:
- FakeMessage: message object constructed from the json string passed
- int the function.
- value(str): This is the string received from the websocket
+ Returns immediately with ``connected_event`` set.
"""
- obj = json.loads(value)
- return FakeMessage(obj.get('type') or '',
- obj.get('data') or {},
- obj.get('context') or {})
+ self.started_running = True
+ self.connected_event.set()
+ return self
- def forward(self, msg_type, data=None):
- """ Keep context and forward message
+ async def close(self):
+ self.connected_event.clear()
+ self.on_close()
- 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.
+ # ------------------------------------------------------------------
+ # emit (async) — same dispatch shape as FakeBus.emit
+ # ------------------------------------------------------------------
- Args:
- msg_type (str): type of message
- data (dict): data for message
+ async def emit(self, message):
+ # RULE 2 dedup marker: pop before any dispatch -- see FakeBus.emit
+ # for the rationale (a handler-derived forward()/reply() must not
+ # inherit this frame's twin marker).
+ is_intent_twin = message.context.pop(INTENT_COMPAT_TWIN_KEY, False)
+ if "session" not in message.context:
+ try: # replicate side effects
+ from ovos_bus_client.session import Session, SessionManager
+ 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}
+ # on_message runs BEFORE handlers — see FakeBus.emit for the rationale
+ # (running a named-session update after handlers would wipe an
+ # in-place / synced mutation a handler made this same tick with the
+ # stale emit-time snapshot).
+ #
+ # A malformed carrier (SESSION-1 §2.5) drops the whole message --
+ # see FakeBus.on_message / FakeBus._deliver.
+ if not self.on_message(message.serialize()):
+ return
+ self._deliver(message, is_intent_twin)
+
+ # ------------------------------------------------------------------
+ # Sync helpers used internally — same as FakeBus
+ # ------------------------------------------------------------------
+
+ _bridge_intent_topic = FakeBus._bridge_intent_topic
+ _deliver = FakeBus._deliver
- Returns:
- FakeMessage: Message object to be used on the reply to the message
+ def on_message(self, *args):
+ """Handle an incoming websocket message.
+
+ @param args:
+ message (str): serialized Message
+ @return: False if the message was dropped (SESSION-1 §2.5 malformed
+ carrier -- the caller must not deliver it to listeners), True
+ otherwise.
"""
- 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
+ if len(args) == 1:
+ message = args[0]
+ else:
+ message = args[1]
+ parsed_message = FakeMessage.deserialize(message)
+ try:
+ # ovos-bus-client is an optional dependency of this extra; only
+ # its ABSENCE is swallowed here. ``resolve_session_id`` /
+ # ``session_carrier`` are guaranteed by the >=2.11.4a1 floor
+ # pin below, so a real bug in the block that follows must not
+ # be misread as "not installed" and silently disable named-
+ # session tracking.
+ from ovos_bus_client.session import (Session, SessionManager,
+ DEFAULT_SESSION_ID,
+ MalformedSession,
+ resolve_session_id,
+ session_carrier)
+ except ImportError:
+ return True # ovos-bus-client not installed -- don't care
+ # OVOS-SESSION-2 §5.1's arrival merge is a once-per-utterance
+ # orchestrator-intake fold, not a per-observed-message one (see
+ # core#915 / ovos-bus-client's MessageBusClient._take_inbound_
+ # session). A FakeBus models ONE bus connection for a test, and a
+ # test drives far more default-session traffic through it than
+ # one fold per utterance -- replies, handled-acks, forwarded
+ # frames. Calling ``update`` (a wholesale replace using spec
+ # defaults for omitted fields, not a field-by-field merge) on
+ # every observed default-session message would wipe stored
+ # fields a later message's carrier simply doesn't restate,
+ # violating §2.6 (mutation only at lifecycle boundaries). A test
+ # that wants the orchestrator's own intake semantics calls
+ # ``SessionManager.fold_inbound`` explicitly, the same as core
+ # does at its own intake point.
+ try:
+ carrier = session_carrier(parsed_message)
+ if resolve_session_id(carrier) != DEFAULT_SESSION_ID:
+ sess = Session.from_message(parsed_message)
+ SessionManager.update(sess)
+ except MalformedSession as e:
+ # OVOS-SESSION-1 §2.5: a non-object session carrier is a
+ # per-message producer fault, not a transport fault -- drop the
+ # WHOLE message (no listener runs) and keep going, same as
+ # MessageBusClient.on_message. Emit ``ovos.session.rejected``
+ # so a listener can observe/count the drop, exactly as the
+ # real bus does.
+ LOG.warning(f"discarding bus message with malformed session: {e}")
+ self._deliver(_session_rejected_message(parsed_message))
+ return False
+ return True
+
+ 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)
+ # 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
+
+ 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:
- 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
+ The received message or None if the response timed out
"""
- return self.reply(self.msg_type + '.response', data, context)
+ evt = asyncio.Event()
+ captured = {"msg": None}
- 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.
+ def _rcv(m):
+ captured["msg"] = m
+ evt.set()
- Args:
- msg_type (str): type of message
- data (dict): date to send with message
- context: context added to existing context
+ 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:
- FakeMessage: Message object to publish
+ The received message or None if the response timed out
"""
- context = context or {}
- new_context = self.context.copy()
- for key in context:
- new_context[key] = context[key]
+ reply_type = reply_type or message.msg_type + ".response"
+ evt = asyncio.Event()
+ captured = {"msg": None}
- if 'target' in new_context:
- del new_context['target']
+ def _rcv(m):
+ captured["msg"] = m
+ evt.set()
- return FakeMessage(msg_type, data, context=new_context)
+ 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.
+ # ------------------------------------------------------------------
-class Message(FakeMessage):
- """just for compat, stuff in the wild importing from here even with deprecation warnings..."""
+ def create_client(self):
+ return self
- def __new__(cls, *args, **kwargs):
- warnings.warn(
- "import from ovos-bus-client directly",
- 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")
- return FakeMessage(*args, **kwargs)
+ def run_forever(self):
+ self.started_running = True
+
+ def run_in_thread(self):
+ self.run_forever()
diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py
index 55bb8fa8..aabc3083 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
@@ -374,11 +374,22 @@ def __init__(self, files: List[str], callback: callable,
self.observer = Observer()
self.handlers = []
for file_path in files:
- if os.path.isfile(file_path):
- watch_dir = dirname(file_path)
- else:
+ if os.path.isdir(file_path):
watch_dir = file_path
- self.observer.schedule(FileEventHandler(file_path, callback,
+ # a directory was requested, fire for any file inside it
+ watched_file = None
+ else:
+ # a specific file was requested, only fire for that file.
+ # the file may not exist yet (eg. a config that hasn't been
+ # written), watchdog just needs its (existing) parent dir
+ watch_dir = dirname(file_path) or "."
+ watched_file = file_path
+ if not os.path.isdir(watch_dir):
+ LOG.warning(f"Can't watch '{file_path}', "
+ f"parent directory '{watch_dir}' "
+ f"does not exist")
+ continue
+ self.observer.schedule(FileEventHandler(watched_file, callback,
ignore_creation),
watch_dir, recursive=recursive)
self.observer.start()
@@ -398,17 +409,20 @@ def shutdown(self):
from watchdog.events import FileSystemEventHandler
class FileEventHandler(FileSystemEventHandler):
- def __init__(self, file_path: str, callback: callable,
+ def __init__(self, file_path: Optional[str], callback: callable,
ignore_creation: bool = False):
"""
Create a handler for file change events
- @param file_path: file_path being watched Unused(?)
+ @param file_path: if set, the single file being watched; events
+ for any other file in the watched directory are ignored.
+ If None, this handler is watching a directory and events
+ for any file inside it are reported.
@param callback: function to call on file change with modified file path
@param ignore_creation: if True, only track file modification events
"""
super().__init__()
self._callback = callback
- self._file_path = file_path
+ self._file_path = os.path.realpath(file_path) if file_path else None
if ignore_creation:
self._events = ('modified')
else:
@@ -416,9 +430,23 @@ def __init__(self, file_path: str, callback: callable,
self._changed_files = set()
self._lock = RLock()
+ def _is_watched(self, src_path) -> bool:
+ """
+ Check if a reported event path refers to the file this handler
+ was asked to watch. Always True in directory-watch mode.
+ @param src_path: `event.src_path`, str or bytes depending on platform
+ """
+ if self._file_path is None:
+ return True
+ if isinstance(src_path, bytes):
+ src_path = src_path.decode()
+ return os.path.realpath(src_path) == self._file_path
+
def on_any_event(self, event):
if event.is_directory:
return
+ if not self._is_watched(event.src_path):
+ return
with self._lock:
if event.event_type == "closed":
if event.src_path in self._changed_files:
diff --git a/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..3a5fd316 100644
--- a/ovos_utils/lang/__init__.py
+++ b/ovos_utils/lang/__init__.py
@@ -1,47 +1,71 @@
+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"""
+ """Normalize a BCP-47 language tag.
+
+ ``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. ``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)
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()
+ 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)
-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/ovos_utils/log.py b/ovos_utils/log.py
index f5941cf6..c286044e 100644
--- a/ovos_utils/log.py
+++ b/ovos_utils/log.py
@@ -131,8 +131,29 @@ def create_logger(cls, name, tostdout=True):
@classmethod
def set_level(cls, level):
cls.level = level
- for l in cls._loggers:
- cls._loggers[l].setLevel(level)
+ for logger_name in cls._loggers:
+ cls._loggers[logger_name].setLevel(level)
+
+ @classmethod
+ def is_enabled_for(cls, level: int) -> bool:
+ """Return whether a record at ``level`` would be emitted.
+
+ ``LOG.level`` accepts the same integer and named levels as the stdlib
+ logger. Unknown names deliberately fall through as enabled so the
+ existing logger configuration path still raises its normal error.
+ """
+ if logging.root.manager.disable >= level:
+ return False
+ configured = cls.level
+ if isinstance(configured, int):
+ threshold = configured
+ else:
+ threshold = logging.getLevelName(str(configured).upper())
+ if not isinstance(threshold, int):
+ return True
+ if threshold == logging.NOTSET:
+ threshold = logging.root.getEffectiveLevel()
+ return level >= threshold
@classmethod
def _get_real_logger(cls):
@@ -170,22 +191,32 @@ def _get_real_logger(cls):
@classmethod
def info(cls, *args, **kwargs):
+ if not cls.is_enabled_for(logging.INFO):
+ return
cls._get_real_logger().info(*args, **kwargs)
@classmethod
def debug(cls, *args, **kwargs):
+ if not cls.is_enabled_for(logging.DEBUG):
+ return
cls._get_real_logger().debug(*args, **kwargs)
@classmethod
def warning(cls, *args, **kwargs):
+ if not cls.is_enabled_for(logging.WARNING):
+ return
cls._get_real_logger().warning(*args, **kwargs)
@classmethod
def error(cls, *args, **kwargs):
+ if not cls.is_enabled_for(logging.ERROR):
+ return
cls._get_real_logger().error(*args, **kwargs)
@classmethod
def exception(cls, *args, **kwargs):
+ if not cls.is_enabled_for(logging.ERROR):
+ return
cls._get_real_logger().exception(*args, **kwargs)
@@ -258,6 +289,17 @@ def get_logs_config(service_name: Optional[str] = None,
return _logs_conf
+# Tracks (message, caller) pairs already logged so each unique deprecation
+# is only reported once instead of spamming the logs on every call
+_logged_deprecations = set()
+
+# Cheap, `inspect.stack()`-free pre-check keyed on the immediate caller's code
+# object/line rather than the fully resolved origin. Repeat calls from the
+# same call site short-circuit before paying for the O(n) stack walk needed
+# to resolve `_logged_deprecations`'s (message, origin) key.
+_logged_deprecations_fast = set()
+
+
def log_deprecation(log_message: str = "DEPRECATED",
deprecation_version: str = "Unknown",
func_name: str = None,
@@ -274,6 +316,12 @@ def log_deprecation(log_message: str = "DEPRECATED",
determination. i.e. an internal exception handling method should log the
first call external to that package
"""
+ caller = sys._getframe(1)
+ fast_key = (log_message, func_name, func_module, caller.f_code,
+ caller.f_lineno)
+ if fast_key in _logged_deprecations_fast:
+ return
+
stack = inspect.stack()[1:] # [0] is this method
call_info = "Unknown Origin"
origin_module = func_module
@@ -297,6 +345,12 @@ def log_deprecation(log_message: str = "DEPRECATED",
if not name.startswith(origin_module):
call_info = f"{name}:{call.lineno}"
break
+ # Only log each unique deprecation (message + caller) once
+ dedupe_key = (log_message, log_name, call_info)
+ _logged_deprecations_fast.add(fast_key)
+ if dedupe_key in _logged_deprecations:
+ return
+ _logged_deprecations.add(dedupe_key)
# Explicitly format log to print origin log reference
LOG.create_logger(log_name).warning(
f"Deprecation version={deprecation_version}. Caller={call_info}. "
@@ -342,13 +396,12 @@ def get_log_path(service: str, directories: Optional[List[str]] = None) \
if directories:
for directory in directories:
file = os.path.join(directory, f"{service}.log")
- if os.path.exists(file):
+ if os.path.lexists(file):
return directory
return None
from ovos_utils.xdg_utils import xdg_state_home
try:
- from ovos_config import Configuration
from ovos_config.meta import get_xdg_base
except ImportError:
xdg_base = os.environ.get("OVOS_CONFIG_BASE_FOLDER", "mycroft")
diff --git a/ovos_utils/log_parser.py b/ovos_utils/log_parser.py
index 562ba356..555367d9 100644
--- a/ovos_utils/log_parser.py
+++ b/ovos_utils/log_parser.py
@@ -11,7 +11,7 @@
from rich.style import Style
from rich.table import Table
import pydoc
-from combo_lock import ComboLock
+from combo_lock import NamedLock
try:
from ovos_config import Configuration
@@ -25,7 +25,7 @@
TIME_FORMAT = '%Y-%m-%d %H:%M:%S.%f'
-LOGLOCK = ComboLock("ovos_logs_console_script")
+LOGLOCK = NamedLock("ovos_logs_console_script")
@dataclass
@@ -150,7 +150,7 @@ def parse(cls, log_line, last_timestamp=None) -> LogLine:
data['timestamp'] = datetime.strptime(data['timestamp'], TIME_FORMAT)
return LogLine(**data)
- data["timestamp"] = last_timestamp or ""
+ data["timestamp"] = last_timestamp
data["message"] = log_line
return LogLine(**data)
@@ -374,7 +374,7 @@ def slice(start, until, logs, paths, file):
continue
_templog[service] = []
for log in OVOSLogParser.parse_file(logfile):
- if start <= log.timestamp < end:
+ if log.timestamp is not None and start <= log.timestamp < end:
if isinstance(log, Traceback):
_templog[service].extend(log.to_loglines())
else:
diff --git a/ovos_utils/network_utils.py b/ovos_utils/network_utils.py
index 6ad1ecbd..6e1f02f6 100644
--- a/ovos_utils/network_utils.py
+++ b/ovos_utils/network_utils.py
@@ -94,14 +94,16 @@ def is_connected_dns(host: Optional[str] = None, port: int = 53,
return is_connected_dns(cfg.get("dns_primary") or _DEFAULT_TEST_CONFIG['dns_primary']) or \
is_connected_dns(cfg.get("dns_secondary") or _DEFAULT_TEST_CONFIG['dns_secondary'])
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# connect to the host -- tells us if the host is actually reachable
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((host, port))
return True
except OSError:
pass
+ finally:
+ s.close()
return False
diff --git a/ovos_utils/ocp.py b/ovos_utils/ocp.py
index 3e88448f..14c12318 100644
--- a/ovos_utils/ocp.py
+++ b/ovos_utils/ocp.py
@@ -28,6 +28,13 @@
OCP_ID = "ovos.common_play"
+def _is_valid_number(value) -> bool:
+ """A finite, non-boolean int/float."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return False
+ return value == value and abs(value) != float("inf") # rules out NaN/inf
+
+
class MatchConfidence(IntEnum):
EXACT = 95
VERY_HIGH = 90
@@ -202,11 +209,15 @@ def update(self, entry: dict, skipkeys: list = None, newonly: bool = False):
if isinstance(entry, (MediaEntry, PluginStream)):
entry = entry.as_dict
entry = entry or {}
+ numeric_fields = ("length", "position", "match_confidence")
for k, v in entry.items():
if k not in skipkeys and hasattr(self, k):
if newonly and self.__getattribute__(k):
# skip, do not replace existing values
continue
+ if k in numeric_fields and not _is_valid_number(v):
+ LOG.debug(f"ignoring invalid value for '{k}': {v!r}")
+ continue
self.__setattr__(k, v)
@property
@@ -236,8 +247,8 @@ def mpris_metadata(self) -> dict:
meta['xesam:title'] = Variant('s', self.title)
if self.image:
meta['mpris:artUrl'] = Variant('s', self.image)
- if self.length:
- meta['mpris:length'] = Variant('d', self.length)
+ if _is_valid_number(self.length) and self.length:
+ meta['mpris:length'] = Variant('x', int(self.length))
return meta
@property
@@ -622,6 +633,8 @@ def __contains__(self, item):
def dict2entry(track: dict) -> Union[PluginStream, MediaEntry, Playlist]:
+ if not isinstance(track, dict):
+ raise ValueError(f"expected a dict, got {type(track).__name__}: {track!r}")
if track.get("playlist"):
return Playlist.from_dict(track)
elif track.get("extractor_id"):
diff --git a/ovos_utils/security.py b/ovos_utils/security.py
index b9b74312..459ae0f6 100644
--- a/ovos_utils/security.py
+++ b/ovos_utils/security.py
@@ -8,7 +8,8 @@
import pexpect
-from ovos_utils.log import LOG
+from ovos_utils.log import LOG, deprecated
+from ovos_utils.version import VERSION_MAJOR
try:
# pycryptodomex
@@ -25,10 +26,21 @@
crypto = None
+@deprecated("create_self_signed_cert is unmaintained and generates a "
+ "1024-bit RSA / SHA-1 certificate that modern OpenSSL "
+ "(SECLEVEL=2, the Debian/Ubuntu/Fedora default) refuses to load "
+ "('EE_KEY_TOO_SMALL'). Callers should bundle their own "
+ "self-signed cert generation (RSA >= 2048, SHA-256, with a "
+ "Subject Alternative Name) instead of relying on this helper.",
+ f"{VERSION_MAJOR + 1}.0.0")
def create_self_signed_cert(cert_dir, name="jarbas"):
"""
If name.crt and name.key don't exist in cert_dir, create a new
self-signed cert and key pair and write them into that directory.
+
+ .. deprecated:: unmaintained; generates a 1024-bit RSA / SHA-1
+ certificate that modern OpenSSL rejects at load time. Bundle your
+ own self-signed cert generation instead.
"""
if crypto is None:
LOG.error("run pip install pyopenssl")
diff --git a/ovos_utils/skill_installer.py b/ovos_utils/skill_installer.py
index 24a6dc74..2fbac283 100644
--- a/ovos_utils/skill_installer.py
+++ b/ovos_utils/skill_installer.py
@@ -81,9 +81,14 @@ class InstallError(str, enum.Enum):
class ServiceInstaller:
"""Pip installer bound to a single OVOS service process.
- Listens on both the broadcast ``ovos.pip.install`` topic and the
- service-specific ``ovos.pip.install.`` topic so that each
- containerised service can be updated independently.
+ Listens on ``ovos.pip.install`` and ``ovos.pip.uninstall``. A request
+ carrying ``data.service_name`` is acted on only by the service of that
+ name, so a containerised deployment can install a plugin into the
+ environment that loads it; a request without it reaches every installer.
+
+ The suffixed ``ovos.pip.install.`` topics are the pre-spec
+ way of addressing one service. They still work and are logged as
+ deprecated: a topic carries no target, that is what the payload is for.
Args:
bus: Connected ``MessageBusClient`` (or compatible FakeBus).
@@ -129,18 +134,20 @@ def __init__(
)
self.bus = bus
- # Broadcast topics — every service with an installer will respond.
+ # The canonical topics. Addressing is in the payload: a request
+ # naming another service is ignored here (see _addressed_to_us).
self.bus.on("ovos.pip.install", self.handle_install_python)
self.bus.on("ovos.pip.uninstall", self.handle_uninstall_python)
- # Targeted topics — only this service responds.
+ # Pre-spec suffixed topics, kept for one stable cycle so a client
+ # that has not moved to data.service_name keeps working.
self.bus.on(
f"ovos.pip.install.{self.service_name}",
- self.handle_install_python,
+ self._handle_legacy_install,
)
self.bus.on(
f"ovos.pip.uninstall.{self.service_name}",
- self.handle_uninstall_python,
+ self._handle_legacy_uninstall,
)
LOG.info(
@@ -153,13 +160,56 @@ def shutdown(self) -> None:
self.bus.remove("ovos.pip.uninstall", self.handle_uninstall_python)
self.bus.remove(
f"ovos.pip.install.{self.service_name}",
- self.handle_install_python,
+ self._handle_legacy_install,
)
self.bus.remove(
f"ovos.pip.uninstall.{self.service_name}",
- self.handle_uninstall_python,
+ self._handle_legacy_uninstall,
)
+ # ------------------------------------------------------------------
+ # Addressing
+ # ------------------------------------------------------------------
+
+ def _addressed_to_us(self, message: Message) -> bool:
+ """Whether this installer should act on ``message``.
+
+ ``data.service_name`` names the one service a request is for. Absent,
+ every installer acts. Present and naming another service, this one
+ stays silent: it installs nothing and answers nothing, because a
+ refusal from every other installer on the bus would bury the real
+ answer in a burst the client cannot tell it from.
+
+ The comparison is exact. A service name is an identifier, not a
+ pattern.
+ """
+ target = message.data.get("service_name")
+ if target is None:
+ return True
+ if target == self.service_name:
+ return True
+ LOG.debug(f"{message.msg_type} is addressed to '{target}', "
+ f"not '{self.service_name}'; ignoring")
+ return False
+
+ def _warn_deprecated_topic(self, message: Message) -> None:
+ LOG.warning(
+ f"'{message.msg_type}' addresses a service in the topic, which is "
+ f"a pre-spec form kept for one stable cycle. Emit "
+ f"'{message.msg_type.rsplit('.', 1)[0]}' with "
+ f"data.service_name='{self.service_name}' instead."
+ )
+
+ def _handle_legacy_install(self, message: Message) -> None:
+ """Serve the pre-spec ``ovos.pip.install.`` topic."""
+ self._warn_deprecated_topic(message)
+ self.handle_install_python(message)
+
+ def _handle_legacy_uninstall(self, message: Message) -> None:
+ """Serve the pre-spec ``ovos.pip.uninstall.`` topic."""
+ self._warn_deprecated_topic(message)
+ self.handle_uninstall_python(message)
+
# ------------------------------------------------------------------
# Audio feedback helpers
# ------------------------------------------------------------------
@@ -386,7 +436,9 @@ def _on_uninstall_complete(self) -> None:
# ------------------------------------------------------------------
def handle_install_python(self, message: Message) -> None:
- """Handle ``ovos.pip.install`` or ``ovos.pip.install.``."""
+ """Handle ``ovos.pip.install``, addressed by ``data.service_name``."""
+ if not self._addressed_to_us(message):
+ return
if not self.config.get("allow_pip"):
LOG.error(InstallError.DISABLED.value)
self.play_error_sound()
@@ -422,7 +474,9 @@ def handle_install_python(self, message: Message) -> None:
)
def handle_uninstall_python(self, message: Message) -> None:
- """Handle ``ovos.pip.uninstall`` or ``ovos.pip.uninstall.``."""
+ """Handle ``ovos.pip.uninstall``, addressed by ``data.service_name``."""
+ if not self._addressed_to_us(message):
+ return
if not self.config.get("allow_pip"):
LOG.error(InstallError.DISABLED.value)
self.play_error_sound()
diff --git a/ovos_utils/sound.py b/ovos_utils/sound.py
index bf87b648..bfdc640a 100644
--- a/ovos_utils/sound.py
+++ b/ovos_utils/sound.py
@@ -5,7 +5,7 @@
from os.path import isfile
from typing import Optional
-from distutils.spawn import find_executable
+from shutil import which
from ovos_utils.log import LOG
@@ -39,28 +39,28 @@ def _find_player(uri):
_, ext = os.path.splitext(uri)
# scan installed executables that can handle playback
- sox_play = find_executable("play")
+ sox_play = which("play")
# sox should handle almost every format, but fails in some urls
if sox_play:
return sox_play + f" --type {ext} %1"
# determine best available player
- ogg123_play = find_executable("ogg123")
+ ogg123_play = which("ogg123")
if "ogg" in ext and ogg123_play:
return ogg123_play + " -q %1"
- pw_play = find_executable("pw-play")
+ pw_play = which("pw-play")
# pw_play handles both wav and mp3
if pw_play:
return pw_play + " %1"
# wav file
if 'wav' in ext:
- pulse_play = find_executable("paplay")
+ pulse_play = which("paplay")
if pulse_play:
return pulse_play + " %1"
- alsa_play = find_executable("aplay")
+ alsa_play = which("aplay")
if alsa_play:
return alsa_play + " %1"
# guess mp3
- mpg123_play = find_executable("mpg123")
+ mpg123_play = which("mpg123")
if mpg123_play:
return mpg123_play + " %1"
LOG.error("Can't find player for: %s", uri)
@@ -135,14 +135,14 @@ def get_sound_duration(path: str, base_dir: Optional[str] = "") -> float:
frames = f.getnframes()
rate = f.getframerate()
return frames / float(rate)
- ffprobe = find_executable("ffprobe")
+ ffprobe = which("ffprobe")
if ffprobe:
args = (ffprobe, "-show_entries", "format=duration", "-i", path)
popen = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
popen.wait()
output = popen.stdout.read().decode("utf-8")
return float(output.split("duration=")[-1].split("\n")[0])
- media_info = find_executable("mediainfo")
+ media_info = which("mediainfo")
if media_info:
args = (media_info, path)
popen = subprocess.Popen(args, stdout=subprocess.PIPE)
diff --git a/ovos_utils/version.py b/ovos_utils/version.py
index 3a22553d..f38e6aa6 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 = 4
+VERSION_MINOR = 15
+VERSION_BUILD = 1
+VERSION_ALPHA = 1
# END_VERSION_BLOCK
__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
diff --git a/pyproject.toml b/pyproject.toml
index d4534048..33a69f50 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",
@@ -21,6 +21,7 @@ dependencies = [
"rich-click~=1.7",
"rich~=13.7",
"python-dateutil",
+ "ovos-spec-tools>=1.10.7a2", # SpecMessage.SESSION_REJECTED (SESSION-1 §2.5)
]
[project.urls]
@@ -33,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.11.4a1",
"langcodes",
"timezonefinder",
"oauthlib~=3.2",
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 49a447aa..00000000
--- a/requirements/requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-pexpect~=4.9
-requests~=2.26
-json_database~=0.10
-kthread~=0.2
-watchdog
-pyee>=8.0.0
-combo-lock~=0.2
-rich-click~=1.7
-rich~=13.7
\ No newline at end of file
diff --git a/test/unittests/dialog/test_dialog.py b/test/unittests/dialog/test_dialog.py
index cd2150f2..158b9030 100644
--- a/test/unittests/dialog/test_dialog.py
+++ b/test/unittests/dialog/test_dialog.py
@@ -17,9 +17,27 @@
import pathlib
import json
+import pytest
from ovos_utils.dialog import MustacheDialogRenderer, load_dialogs, get_dialog
+# ovos_utils.dialog is a deprecated shim; this module deliberately keeps
+# exercising it for coverage, filtered per-module rather than dropped.
+pytestmark = [
+ pytest.mark.filterwarnings(
+ "ignore:MustacheDialogRenderer is deprecated; use the OVOS-INTENT-2:DeprecationWarning"
+ ),
+ pytest.mark.filterwarnings(
+ "ignore:get_dialog is deprecated; use the OVOS-INTENT-2:DeprecationWarning"
+ ),
+ pytest.mark.filterwarnings(
+ "ignore:load_dialogs is deprecated; use 'ovos_spec_tools.LocaleResources':DeprecationWarning"
+ ),
+ pytest.mark.filterwarnings(
+ "ignore:EventSchedulerInterface moved to ovos_bus_client:DeprecationWarning"
+ ),
+]
+
# TODO - move to ovos-workshop
class DialogTest(unittest.TestCase):
@@ -33,13 +51,15 @@ def test_general_dialog(self):
for file in template_path.iterdir():
if file.suffix == '.dialog':
self.stache.load_template_file(file.name, str(file.absolute()))
- context = json.load(
- file.with_suffix('.context.json').open(
- 'r', encoding='utf-8'))
+ with file.with_suffix('.context.json').open(
+ 'r', encoding='utf-8') as f:
+ context = json.load(f)
+ with file.with_suffix('.result').open(
+ 'r', encoding='utf-8') as f:
+ expected = f.read()
self.assertEqual(
self.stache.render(file.name, context),
- file.with_suffix('.result').open('r',
- encoding='utf-8').read())
+ expected)
def test_unknown_dialog(self):
""" Test for returned file name literals in case of unkown dialog """
@@ -55,13 +75,12 @@ def test_multiple_dialog(self):
for file in template_path.iterdir():
if file.suffix == '.dialog':
self.stache.load_template_file(file.name, str(file.absolute()))
- context = json.load(
- file.with_suffix('.context.json').open(
- 'r', encoding='utf-8'))
- results = [
- line.strip() for line in file.with_suffix('.result').open(
- 'r', encoding='utf-8')
- ]
+ with file.with_suffix('.context.json').open(
+ 'r', encoding='utf-8') as f:
+ context = json.load(f)
+ with file.with_suffix('.result').open(
+ 'r', encoding='utf-8') as fh:
+ results = [line.strip() for line in fh]
# Try all lines
for index, line in enumerate(results):
self.assertEqual(
@@ -82,8 +101,8 @@ def test_comment_dialog(self):
for f in template_path.iterdir():
if f.suffix == '.dialog':
self.stache.load_template_file(f.name, str(f.absolute()))
- results = [line.strip()
- for line in f.with_suffix('.result').open('r')]
+ with f.with_suffix('.result').open('r') as fh:
+ results = [line.strip() for line in fh]
# Try all lines
for index, line in enumerate(results):
self.assertEqual(self.stache.render(f.name, index=index),
diff --git a/test/unittests/test_async_fakebus.py b/test/unittests/test_async_fakebus.py
new file mode 100644
index 00000000..a3a1f79d
--- /dev/null
+++ b/test/unittests/test_async_fakebus.py
@@ -0,0 +1,267 @@
+# 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)
+
+
+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. detach_intent ->
+ # ovos.intent.deregister splits "skill:intent" into skill_id/intent_name.
+ bus = AsyncFakeBus()
+ got = []
+ 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()
+ 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_bracket_expansion.py b/test/unittests/test_bracket_expansion.py
index 5d6bcbe1..4fc1f81d 100644
--- a/test/unittests/test_bracket_expansion.py
+++ b/test/unittests/test_bracket_expansion.py
@@ -1,7 +1,15 @@
import unittest
+import pytest
+
from ovos_utils.bracket_expansion import expand_template, expand_slots
+# expand_template is a deprecated shim (use ovos_spec_tools.expand); this
+# module deliberately keeps exercising it for coverage, filtered per-module.
+pytestmark = pytest.mark.filterwarnings(
+ "ignore:expand_template is deprecated; import 'expand' from 'ovos_spec_tools':DeprecationWarning"
+)
+
class TestTemplateExpansion(unittest.TestCase):
@@ -35,20 +43,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 +63,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 +97,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_device_input.py b/test/unittests/test_device_input.py
index 00e22c17..bbbb2e8c 100644
--- a/test/unittests/test_device_input.py
+++ b/test/unittests/test_device_input.py
@@ -17,16 +17,22 @@
import sys
import types
import unittest
+import warnings
from unittest import mock
from unittest.mock import Mock, MagicMock, patch
-# distutils was removed in Python 3.12+; provide a minimal stub if missing
+# distutils was removed in Python 3.12+; provide a minimal stub if missing.
+# On older interpreters it still exists but is itself deprecated -- that's
+# the stdlib's own noise, not ours to fix here, so it's suppressed locally.
try:
- import distutils.spawn
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore", category=DeprecationWarning,
+ message="The distutils package is deprecated")
+ import distutils.spawn
except ImportError:
distutils_stub = types.ModuleType("distutils")
spawn_stub = types.ModuleType("distutils.spawn")
- spawn_stub.find_executable = lambda x: None
+ spawn_stub.which = lambda x: None
distutils_stub.spawn = spawn_stub
sys.modules["distutils"] = distutils_stub
sys.modules["distutils.spawn"] = spawn_stub
@@ -35,7 +41,7 @@
class TestInputDeviceHelper(unittest.TestCase):
"""Tests for InputDeviceHelper class."""
- @patch("ovos_utils.device_input.find_executable", return_value=None)
+ @patch("ovos_utils.device_input.which", return_value=None)
def test_init_no_executables(self, mock_find: MagicMock) -> None:
"""InputDeviceHelper should initialise with empty device lists."""
from ovos_utils.device_input import InputDeviceHelper
@@ -43,7 +49,7 @@ def test_init_no_executables(self, mock_find: MagicMock) -> None:
self.assertEqual(helper.libinput_devices_list, [])
self.assertEqual(helper.xinput_devices_list, [])
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
@patch("subprocess.check_output")
def test_build_libinput_devices_list(self, mock_output: MagicMock,
mock_find: MagicMock) -> None:
@@ -63,7 +69,7 @@ def test_build_libinput_devices_list(self, mock_output: MagicMock,
self.assertEqual(dev["Device"], "My Keyboard")
self.assertIn("keyboard", dev["Capabilities"])
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
@patch("subprocess.check_output")
def test_build_libinput_multiple_capabilities(self, mock_output: MagicMock,
mock_find: MagicMock) -> None:
@@ -82,7 +88,7 @@ def test_build_libinput_multiple_capabilities(self, mock_output: MagicMock,
self.assertIsInstance(caps, list)
self.assertGreater(len(caps), 1)
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
@patch("subprocess.check_output", side_effect=Exception("libinput failed"))
def test_get_libinput_devices_exception(self, mock_output: MagicMock,
mock_find: MagicMock) -> None:
@@ -93,7 +99,7 @@ def test_get_libinput_devices_exception(self, mock_output: MagicMock,
result = helper._get_libinput_devices_list()
self.assertEqual(result, [])
- @patch("ovos_utils.device_input.find_executable", return_value=None)
+ @patch("ovos_utils.device_input.which", return_value=None)
def test_get_libinput_devices_no_executable(self, mock_find: MagicMock) -> None:
"""_get_libinput_devices_list should return empty list when libinput not found."""
from ovos_utils.device_input import InputDeviceHelper
@@ -101,7 +107,7 @@ def test_get_libinput_devices_no_executable(self, mock_find: MagicMock) -> None:
result = helper._get_libinput_devices_list()
self.assertEqual(result, [])
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
@patch("subprocess.check_output")
def test_build_xinput_devices_list(self, mock_output: MagicMock,
mock_find: MagicMock) -> None:
@@ -117,7 +123,7 @@ def test_build_xinput_devices_list(self, mock_output: MagicMock,
helper._build_xinput_devices_list()
self.assertGreater(len(helper.xinput_devices_list), 0)
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
@patch("subprocess.check_output", side_effect=Exception("xinput failed"))
def test_get_xinput_devices_exception(self, mock_output: MagicMock,
mock_find: MagicMock) -> None:
@@ -128,7 +134,7 @@ def test_get_xinput_devices_exception(self, mock_output: MagicMock,
result = helper._get_xinput_devices_list()
self.assertEqual(result, [])
- @patch("ovos_utils.device_input.find_executable", return_value=None)
+ @patch("ovos_utils.device_input.which", return_value=None)
def test_get_xinput_devices_no_executable(self, mock_find: MagicMock) -> None:
"""_get_xinput_devices_list should return empty list when xinput not found."""
from ovos_utils.device_input import InputDeviceHelper
@@ -136,7 +142,7 @@ def test_get_xinput_devices_no_executable(self, mock_find: MagicMock) -> None:
result = helper._get_xinput_devices_list()
self.assertEqual(result, [])
- @mock.patch("ovos_utils.device_input.find_executable")
+ @mock.patch("ovos_utils.device_input.which")
def test_can_use_touch_mouse(self, find_exec: MagicMock) -> None:
"""can_use_touch_mouse should detect touch/mouse/tablet/pointer/gesture."""
from ovos_utils.device_input import InputDeviceHelper
@@ -164,7 +170,7 @@ def test_can_use_touch_mouse(self, find_exec: MagicMock) -> None:
dev_input.xinput_devices_list.pop()
self.assertFalse(dev_input.can_use_touch_mouse())
- @mock.patch("ovos_utils.device_input.find_executable")
+ @mock.patch("ovos_utils.device_input.which")
def test_can_use_keyboard(self, find_exec: MagicMock) -> None:
"""can_use_keyboard should detect keyboard devices."""
from ovos_utils.device_input import InputDeviceHelper
@@ -192,7 +198,7 @@ def test_can_use_keyboard(self, find_exec: MagicMock) -> None:
dev_input.xinput_devices_list.pop()
self.assertFalse(dev_input.can_use_keyboard())
- @patch("ovos_utils.device_input.find_executable", return_value=None)
+ @patch("ovos_utils.device_input.which", return_value=None)
@patch("ovos_utils.device_input.is_gui_installed", return_value=True)
def test_can_use_touch_mouse_no_executable_gui_installed(
self, mock_gui: MagicMock, mock_find: MagicMock) -> None:
@@ -202,7 +208,7 @@ def test_can_use_touch_mouse_no_executable_gui_installed(
result = helper.can_use_touch_mouse()
self.assertTrue(result)
- @patch("ovos_utils.device_input.find_executable", return_value=None)
+ @patch("ovos_utils.device_input.which", return_value=None)
@patch("ovos_utils.device_input.is_gui_installed", return_value=False)
def test_can_use_touch_mouse_no_executable_no_gui(
self, mock_gui: MagicMock, mock_find: MagicMock) -> None:
@@ -212,7 +218,7 @@ def test_can_use_touch_mouse_no_executable_no_gui(
result = helper.can_use_touch_mouse()
self.assertFalse(result)
- @patch("ovos_utils.device_input.find_executable")
+ @patch("ovos_utils.device_input.which")
def test_get_input_device_list(self, mock_find: MagicMock) -> None:
"""get_input_device_list should combine libinput and xinput device lists."""
from ovos_utils.device_input import InputDeviceHelper
diff --git a/test/unittests/test_dialog.py b/test/unittests/test_dialog.py
index bb823489..f0fadcf6 100644
--- a/test/unittests/test_dialog.py
+++ b/test/unittests/test_dialog.py
@@ -19,6 +19,23 @@
import unittest
from unittest.mock import patch, MagicMock
+import pytest
+
+# ovos_utils.dialog is a deprecated shim (superseded by ovos_spec_tools'
+# dialog renderer); this module deliberately keeps exercising it for
+# coverage, filtered per-module rather than dropped.
+pytestmark = [
+ pytest.mark.filterwarnings(
+ "ignore:MustacheDialogRenderer is deprecated; use the OVOS-INTENT-2:DeprecationWarning"
+ ),
+ pytest.mark.filterwarnings(
+ "ignore:get_dialog is deprecated; use the OVOS-INTENT-2:DeprecationWarning"
+ ),
+ pytest.mark.filterwarnings(
+ "ignore:load_dialogs is deprecated; use 'ovos_spec_tools.LocaleResources':DeprecationWarning"
+ ),
+]
+
class TestMustacheDialogRenderer(unittest.TestCase):
"""Tests for MustacheDialogRenderer class."""
diff --git a/test/unittests/test_event_scheduler.py b/test/unittests/test_event_scheduler.py
index f06caf4b..43e4a4e6 100644
--- a/test/unittests/test_event_scheduler.py
+++ b/test/unittests/test_event_scheduler.py
@@ -2,106 +2,15 @@
Test cases regarding the event scheduler.
"""
-import time
import unittest
-from unittest.mock import MagicMock, patch
-from ovos_bus_client.util.scheduler import EventScheduler
+import pytest
+
from ovos_utils.events import EventSchedulerInterface
from ovos_utils.fakebus import FakeBus
-class TestEventScheduler(unittest.TestCase):
- @patch('threading.Thread')
- @patch('json.load')
- @patch('json.dump')
- @patch('builtins.open')
- def test_create(self, mock_open, mock_json_dump, mock_load, mock_thread):
- """
- Test creating and shutting down event_scheduler.
- """
- mock_load.return_value = ''
- mock_open.return_value = MagicMock()
- emitter = MagicMock()
- es = EventScheduler(emitter)
- es.shutdown()
- self.assertEqual(mock_json_dump.call_args[0][0], {})
-
- @patch('threading.Thread')
- @patch('json.load')
- @patch('json.dump')
- @patch('builtins.open')
- def test_add_remove(self, mock_open, mock_json_dump,
- mock_load, mock_thread):
- """
- Test add an event and then remove it.
- """
- # Thread start is mocked so will not actually run the thread loop
- mock_load.return_value = ''
- mock_open.return_value = MagicMock()
- emitter = MagicMock()
- es = EventScheduler(emitter)
-
- # 900000000000 should be in the future for a long time
- es.schedule_event('test', 90000000000, None)
- es.schedule_event('test-2', 90000000000, None)
-
- es.check_state() # run one cycle
- self.assertTrue('test' in es.events)
- self.assertTrue('test-2' in es.events)
-
- es.remove_event('test')
- es.check_state() # run one cycle
- self.assertTrue('test' not in es.events)
- self.assertTrue('test-2' in es.events)
- es.shutdown()
-
- @patch('threading.Thread')
- @patch('json.load')
- @patch('json.dump')
- @patch('builtins.open')
- def test_save(self, mock_open, mock_dump, mock_load, mock_thread):
- """
- Test save functionality.
- """
- mock_load.return_value = ''
- mock_open.return_value = MagicMock()
- emitter = MagicMock()
- es = EventScheduler(emitter)
-
- # 900000000000 should be in the future for a long time
- es.schedule_event('test', 900000000000, None)
- es.schedule_event('test-repeat', 910000000000, 60)
- es.check_state()
-
- es.shutdown()
-
- # Make sure the dump method wasn't called with test-repeat
- self.assertEqual(mock_dump.call_args[0][0],
- {'test': [(900000000000, None, {}, None)]})
-
- @patch('threading.Thread')
- @patch('json.load')
- @patch('json.dump')
- @patch('builtins.open')
- def test_send_event(self, mock_open, mock_dump, mock_load, mock_thread):
- """
- Test save functionality.
- """
- mock_load.return_value = ''
- mock_open.return_value = MagicMock()
- emitter = MagicMock()
- es = EventScheduler(emitter)
-
- # 0 should be in the future for a long time
- es.schedule_event('test', time.time(), None)
-
- es.check_state()
- self.assertEqual(emitter.emit.call_args[0][0].msg_type, 'test')
- self.assertEqual(emitter.emit.call_args[0][0].data, {})
- es.shutdown()
-
-
+@pytest.mark.filterwarnings("ignore:EventSchedulerInterface moved to ovos_bus_client:DeprecationWarning")
class TestEventSchedulerInterface(unittest.TestCase):
def test_shutdown(self):
def f(message):
diff --git a/test/unittests/test_events.py b/test/unittests/test_events.py
index 6a1366b0..b0ec4928 100644
--- a/test/unittests/test_events.py
+++ b/test/unittests/test_events.py
@@ -1,14 +1,23 @@
import inspect
import unittest
import datetime
+import warnings
from os.path import join, dirname
from threading import Event
from time import time
from unittest.mock import Mock
+import pytest
+
from ovos_utils.fakebus import FakeBus, FakeMessage as Message
+# EventSchedulerInterface is a deprecated shim (moved to ovos_bus_client);
+# these classes deliberately keep exercising it for coverage.
+_ignore_event_scheduler_moved = pytest.mark.filterwarnings(
+ "ignore:EventSchedulerInterface moved to ovos_bus_client:DeprecationWarning"
+)
+
class TestEvents(unittest.TestCase):
bus = FakeBus()
@@ -182,10 +191,20 @@ def test_event_container(self):
self.assertEqual(bus.ee.listeners(event_name), [])
+@_ignore_event_scheduler_moved
class TestEventSchedulerInterface(unittest.TestCase):
from ovos_utils.events import EventSchedulerInterface
bus = FakeBus()
- interface = EventSchedulerInterface(bus=bus, skill_id="test")
+ # class-body instantiation runs at collection/import time, before the
+ # pytest filterwarnings mark above applies to test items -- silence
+ # the deprecated-shim noise locally instead.
+ with warnings.catch_warnings():
+ warnings.filterwarnings(
+ "ignore",
+ message="EventSchedulerInterface moved to ovos_bus_client",
+ category=DeprecationWarning,
+ )
+ interface = EventSchedulerInterface(bus=bus, skill_id="test")
def test_00_init(self):
from ovos_utils.events import EventContainer
@@ -513,6 +532,7 @@ def remove(self, item):
self.assertTrue(result)
+@_ignore_event_scheduler_moved
class TestEventSchedulerInterfaceExtended(unittest.TestCase):
"""Additional tests for EventSchedulerInterface uncovered methods."""
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)
diff --git a/test/unittests/test_fakebus_intent_legacy_reemit.py b/test/unittests/test_fakebus_intent_legacy_reemit.py
new file mode 100644
index 00000000..e618dc9c
--- /dev/null
+++ b/test/unittests/test_fakebus_intent_legacy_reemit.py
@@ -0,0 +1,255 @@
+"""FakeBus mirrors MessageBusClient's legacy intent-topic bridge.
+
+Old ovos-workshop built the per-intent dispatch topic from the resource
+filename, so ``:food.order.intent`` reached the wire. Current
+workshop registers the canonical ``:food.order``. The bridge is two
+stateless rules. The real client splits them over a wire send and a wire
+receive; a fake bus is one process, so both land in ``emit``:
+
+* a CANONICAL dispatch also fires its suffixed twin, marked as a twin;
+* a SUFFIXED dispatch that is not already such a twin also fires its canonical
+ spelling.
+
+Both fake buses must behave like the real client, otherwise every harness
+built on them hides the compat path.
+"""
+import asyncio
+import unittest
+
+from ovos_spec_tools import Message
+
+from ovos_utils.fakebus import (INTENT_COMPAT_TWIN_KEY, AsyncFakeBus, FakeBus)
+
+CANONICAL = "skill-food.jarbas:food.order"
+LEGACY = "skill-food.jarbas:food.order.intent"
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+class TestCanonicalDispatch(unittest.TestCase):
+ """Rule 1: a canonical dispatch also fires the marked suffixed twin."""
+
+ def test_suffixed_handler_receives_the_twin(self):
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(CANONICAL, {"utterance": "one pizza"}))
+ self.assertEqual([m.msg_type for m in got], [LEGACY])
+ self.assertEqual(got[0].data, {"utterance": "one pizza"})
+
+ def test_twin_keeps_context_but_is_delivered_unmarked(self):
+ # the twin keeps the ordinary context it forwards, but the dedup marker
+ # must NOT reach local handlers: it would ride forward()/reply() onto
+ # any follow-up message a handler emits and suppress its modernization.
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(CANONICAL, {"a": 1}, {"source": ["me"]}))
+ self.assertEqual(got[0].context["source"], ["me"])
+ self.assertNotIn(INTENT_COMPAT_TWIN_KEY, got[0].context)
+
+ def test_twin_is_not_replayed_on_the_message_firehose(self):
+ # FakeBus is a single process with no wire hop: the twin/modernized
+ # copy is dispatched straight to its own topic and does NOT re-emit
+ # "message", keeping the harness's one-emit-one-capture invariant
+ # (unlike a real wire, where the twin is a second frame).
+ import json
+ bus = FakeBus()
+ wire = []
+ bus.on("message", lambda m: wire.append(json.loads(m)))
+ bus.emit(Message(CANONICAL))
+ twins = [f for f in wire if f["type"] == LEGACY]
+ self.assertEqual(len(twins), 0)
+
+ def test_canonical_handler_fires_exactly_once(self):
+ bus = FakeBus()
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.on(LEGACY, lambda m: None)
+ bus.emit(Message(CANONICAL))
+ self.assertEqual(len(got), 1)
+
+ def test_listeners_on_both_spellings_share_the_per_pair_dedup_guard(self):
+ # the intent-pair mirror guard is scoped to the TOPIC PAIR, not the
+ # handler, so a bus with listeners on both spellings still delivers
+ # the dispatch exactly once -- to whichever spelling was actually
+ # emitted -- instead of firing the mirrored twin as a second frame.
+ bus = FakeBus()
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(CANONICAL))
+ self.assertEqual([m.msg_type for m in got], [CANONICAL])
+
+ def test_no_twin_when_compat_is_disabled(self):
+ bus = FakeBus(emit_legacy=False)
+ got = []
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(CANONICAL))
+ self.assertEqual(got, [])
+
+
+class TestSuffixedDispatch(unittest.TestCase):
+ """Rule 2: an unmarked suffixed dispatch also fires the canonical form."""
+
+ def test_canonical_handler_hears_an_old_style_dispatch(self):
+ bus = FakeBus()
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.emit(Message(LEGACY, {"utterance": "one pizza"}))
+ self.assertEqual([m.msg_type for m in got], [CANONICAL])
+ self.assertEqual(got[0].data, {"utterance": "one pizza"})
+
+ def test_suffixed_handler_still_gets_the_original(self):
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(LEGACY))
+ self.assertEqual(len(got), 1)
+
+ def test_a_marked_twin_is_not_modernized_again(self):
+ bus = FakeBus()
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.emit(Message(LEGACY, {}, {INTENT_COMPAT_TWIN_KEY: True}))
+ self.assertEqual(got, [])
+
+ def test_the_bridge_does_not_cascade(self):
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, got.append)
+ bus.emit(Message(LEGACY))
+ self.assertEqual(len(got), 1) # not re-twinned off its own canonical
+
+ def test_no_modernization_when_compat_is_disabled(self):
+ # RULE 2 (receive-side modernize) is gated on `modernize`, the
+ # namespace-migration flag -- separate from RULE 1's `emit_legacy`.
+ bus = FakeBus(modernize=False)
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.emit(Message(LEGACY))
+ self.assertEqual(got, [])
+
+
+class TestMarkerDoesNotLeakToDescendants(unittest.TestCase):
+ """The twin marker must not ride forward()/reply() onto later messages.
+
+ Message.forward()/reply() deep-copy the whole context. If a delivered twin
+ kept the marker, a handler that forwards that context to emit an UNRELATED
+ suffixed intent would brand the follow-up a twin, and the bridge would
+ silently drop its canonical spelling.
+ """
+
+ UNRELATED_LEGACY = "other-skill.jarbas:unrelated.intent"
+ UNRELATED_CANON = "other-skill.jarbas:unrelated"
+
+ def test_forward_off_a_twin_does_not_suppress_an_unrelated_intent(self):
+ bus = FakeBus()
+ seen_twin = []
+ bus.on(LEGACY, seen_twin.append)
+ bus.emit(Message(CANONICAL, {"utterance": "one pizza"}))
+ twin_msg = seen_twin[0]
+ self.assertNotIn(INTENT_COMPAT_TWIN_KEY, twin_msg.context)
+ # a handler forwards this frame's context to emit an unrelated intent.
+ got_canon = []
+ bus.on(self.UNRELATED_CANON, got_canon.append)
+ followup = twin_msg.forward(self.UNRELATED_LEGACY, {})
+ self.assertNotIn(INTENT_COMPAT_TWIN_KEY, followup.context)
+ bus.emit(followup)
+ # the unrelated canonical topic IS modernized: the marker did not leak.
+ self.assertEqual([m.msg_type for m in got_canon], [self.UNRELATED_CANON])
+
+ def test_reply_off_a_twin_does_not_suppress_an_unrelated_intent(self):
+ bus = FakeBus()
+ seen_twin = []
+ bus.on(LEGACY, seen_twin.append)
+ bus.emit(Message(CANONICAL))
+ got_canon = []
+ bus.on(self.UNRELATED_CANON, got_canon.append)
+ bus.emit(seen_twin[0].reply(self.UNRELATED_LEGACY, {}))
+ self.assertEqual([m.msg_type for m in got_canon], [self.UNRELATED_CANON])
+
+ def test_marker_survives_on_the_wire_for_a_second_receiver(self):
+ # a marked frame emitted (as if arriving from the wire) is delivered to
+ # its legacy listener but NOT re-modernized: wire survival intact.
+ bus = FakeBus()
+ got_legacy = []
+ got_canon = []
+ bus.on(LEGACY, got_legacy.append)
+ bus.on(CANONICAL, got_canon.append)
+ bus.emit(Message(LEGACY, {}, {INTENT_COMPAT_TWIN_KEY: True}))
+ self.assertEqual(len(got_legacy), 1)
+ self.assertEqual(len(got_canon), 0)
+
+
+class TestNonIntentTopics(unittest.TestCase):
+ def test_dotted_topics_are_untouched(self):
+ bus = FakeBus()
+ got = []
+ bus.on("ovos.utterance.handled", got.append)
+ bus.emit(Message("ovos.utterance.handled"))
+ self.assertEqual(len(got), 1)
+
+ def test_nothing_extra_is_dispatched(self):
+ bus = FakeBus()
+ got = []
+ bus.on("message", got.append)
+ bus.emit(Message("ovos.utterance.handled"))
+ self.assertEqual(len(got), 1)
+
+
+class TestBridgeIntentTopicsErrorIsolation(unittest.TestCase):
+ """A raising `_bridge_intent_topic` must not propagate out of emit(),
+ matching the counterpart-topics loop's own try/except + LOG.exception
+ resilience pattern in the same method (CodeRabbit review, #411)."""
+
+ def test_sync_bus_emit_survives_a_raising_bridge(self):
+ bus = FakeBus()
+ bus._bridge_intent_topic = lambda *a, **k: (_ for _ in ()).throw(
+ RuntimeError("boom"))
+ got = []
+ bus.on(CANONICAL, got.append)
+ bus.emit(Message(CANONICAL)) # must not raise
+ self.assertEqual([m.msg_type for m in got], [CANONICAL])
+
+ def test_async_bus_emit_survives_a_raising_bridge(self):
+ bus = AsyncFakeBus()
+ bus._bridge_intent_topic = lambda *a, **k: (_ for _ in ()).throw(
+ RuntimeError("boom"))
+ got = []
+ bus.on(CANONICAL, got.append)
+ _run(bus.emit(Message(CANONICAL))) # must not raise
+ self.assertEqual([m.msg_type for m in got], [CANONICAL])
+
+
+class TestAsyncFakeBus(unittest.TestCase):
+ """The async double runs the same two rules."""
+
+ def test_canonical_dispatch_fires_the_twin(self):
+ bus = AsyncFakeBus()
+ got = []
+ bus.on(LEGACY, got.append)
+ _run(bus.emit(Message(CANONICAL)))
+ self.assertEqual([m.msg_type for m in got], [LEGACY])
+ # delivered unmarked (no leak onto descendants); marker rides the wire
+ self.assertNotIn(INTENT_COMPAT_TWIN_KEY, got[0].context)
+
+ def test_suffixed_dispatch_fires_the_canonical_form(self):
+ bus = AsyncFakeBus()
+ got = []
+ bus.on(CANONICAL, got.append)
+ _run(bus.emit(Message(LEGACY)))
+ self.assertEqual([m.msg_type for m in got], [CANONICAL])
+
+ def test_no_bridge_when_compat_is_disabled(self):
+ bus = AsyncFakeBus(emit_legacy=False)
+ got = []
+ bus.on(LEGACY, got.append)
+ _run(bus.emit(Message(CANONICAL)))
+ self.assertEqual(got, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/unittests/test_fakebus_intent_topic_bridge.py b/test/unittests/test_fakebus_intent_topic_bridge.py
new file mode 100644
index 00000000..2f163abf
--- /dev/null
+++ b/test/unittests/test_fakebus_intent_topic_bridge.py
@@ -0,0 +1,380 @@
+"""FakeBus mirrors MessageBusClient's legacy<->canonical INTENT-topic bridge
+(RULE 1 send-side twin / RULE 2 receive-side modernize), so in-process tests
+raw-emitting a legacy ``:IntentName.intent`` topic reach a
+canonical-only listener the same way a real websocket deployment does, and
+vice-versa.
+
+Root cause this closes: FakeBus already wired ovos_spec_tools's
+NamespaceTranslator for the fixed SpecMessage pairs (speak <-> ovos.utterance.speak
+etc, see test_fakebus_namespace_migration.py) but NOT the per-intent
+dispatch-topic bridge that ovos_bus_client.client.client.MessageBusClient
+applies via ``_send_legacy_intent_twin`` / ``_modernize_intent_topic``. Since
+ovos-workshop >= 9.3.11a2 dropped its own dual-bind (only registers the
+canonical listener), an in-process test emitting the legacy suffixed topic
+directly never reached the handler -- while a real deployment, whose bus
+client performs this bridge, dealiased fine.
+"""
+import asyncio
+import unittest
+from unittest.mock import patch
+
+import pytest
+
+from ovos_utils.fakebus import AsyncFakeBus, FakeBus, Message, INTENT_COMPAT_TWIN_KEY
+
+# ovos_utils.fakebus.Message is a deprecated shim (use ovos_spec_tools.Message
+# or ovos_bus_client.Message); this module deliberately keeps exercising it
+# for coverage, filtered per-module.
+pytestmark = pytest.mark.filterwarnings(
+ "ignore:ovos_utils.fakebus.Message is deprecated:DeprecationWarning"
+)
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+LEGACY = "myskill.foo:HelloIntent.intent"
+CANONICAL = "myskill.foo:HelloIntent"
+
+
+class TestFakeBusIntentTopicBridge(unittest.TestCase):
+ def test_legacy_emit_reaches_canonical_listener(self):
+ # RULE 2: a raw legacy-suffixed emit (no bus-client, no twin marker)
+ # must still fire a canonical-only listener.
+ bus = FakeBus() # both flags default on
+ got = []
+ bus.on(CANONICAL, lambda m: got.append(m.msg_type))
+ bus.emit(Message(LEGACY, {"utterance": "hi"}))
+ self.assertEqual(got, [CANONICAL])
+
+ def test_canonical_emit_also_fires_legacy_listener(self):
+ # RULE 1: every canonical intent dispatch is twinned onto its legacy
+ # spelling so an old suffix-only listener still hears it.
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, lambda m: got.append(m.msg_type))
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(got, [LEGACY])
+
+ def test_canonical_emit_twin_not_marked_locally(self):
+ # On the real wire the RULE-1 twin goes out MARKED so an
+ # out-of-process receiver's RULE 2 knows to skip re-modernizing it.
+ # FakeBus has no wire hop: it already made that RULE-2 call inline
+ # for this dispatch, so the twin delivered to LOCAL listeners must
+ # NOT carry the marker -- matching the real client, whose receiving
+ # process pops the marker before any local handler ever sees it
+ # (client.py:351, before local dispatch). Carrying it into the local
+ # twin would also break the per-topic-pair mirror guard's
+ # payload+context fingerprint match (see
+ # test_no_double_fire_dual_listener) and leak onto any descendant
+ # frame a handler derives via forward()/reply().
+ bus = FakeBus()
+ got = []
+ bus.on(LEGACY, lambda m: got.append(m))
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(len(got), 1)
+ self.assertNotIn(INTENT_COMPAT_TWIN_KEY, got[0].context)
+
+ def test_no_double_fire_dual_listener(self):
+ # a handler subscribed to BOTH the legacy and canonical topic must
+ # not see the same logical dispatch twice -- matching the real
+ # MessageBusClient, whose per-topic-pair mirror guard (shared by
+ # every registration on either spelling) drops the twin as a
+ # re-delivery of the same logical event. ovos-workshop 9.3.2a1+
+ # binds the skill method to both spellings via a FRESH wrapper
+ # closure per registration, so this must hold even though the two
+ # ``bus.on()`` calls below pass the SAME underlying handler object.
+ bus = FakeBus()
+ calls = []
+ handler = lambda m: calls.append(m.msg_type)
+ bus.on(LEGACY, handler)
+ bus.on(CANONICAL, handler)
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls, [CANONICAL])
+
+ def test_independent_handlers_legacy_only_starves(self):
+ # two INDEPENDENT handlers -- one on the canonical topic, one on the
+ # legacy-only spelling -- share the per-topic-pair guard (it cannot
+ # be scoped to a single handler, see FakeBus._mirror_guard_for), so
+ # a canonical emit arms the guard and the legacy-only handler
+ # starves. This matches real-bus behavior: a process holding both
+ # handlers is unreachable from a single workshop version, so the
+ # starvation is an accepted trade-off, not a defect.
+ bus = FakeBus()
+ canonical_calls = []
+ legacy_calls = []
+ bus.on(CANONICAL, lambda m: canonical_calls.append(m.msg_type))
+ bus.on(LEGACY, lambda m: legacy_calls.append(m.msg_type))
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(canonical_calls, [CANONICAL])
+ self.assertEqual(legacy_calls, [])
+
+ def test_twin_marker_does_not_leak_onto_unrelated_forward(self):
+ # RULE 2 dedup marker regression: a handler on the LEGACY spelling
+ # that forwards its received message's context onto an UNRELATED
+ # suffixed topic must not brand that unrelated frame a twin. The
+ # marker is popped BEFORE dispatch (mirrors
+ # MessageBusClient.on_message's pop-before-dispatch ordering), so it
+ # cannot survive onto a descendant frame created by
+ # Message.forward(), which deep-copies context.
+ bus = FakeBus()
+ seen = []
+ other_legacy = "other.skill:OtherIntent.intent"
+ other_canonical = "other.skill:OtherIntent"
+
+ def legacy_handler(m):
+ bus.emit(m.forward(other_legacy, {}))
+
+ bus.on(LEGACY, legacy_handler)
+ bus.on(other_canonical, lambda m: seen.append("canonical-modernized"))
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(seen, ["canonical-modernized"])
+
+ def test_twin_marker_suppresses_rule2_recascade(self):
+ # if a caller manually emits a message already carrying the twin
+ # marker (simulating what a real client would receive as the twin
+ # half of a pair), RULE 2 must not modernize it again -- proving the
+ # bridge cannot cascade into a modernize/twin loop.
+ bus = FakeBus()
+ canonical_hits = []
+ bus.on(CANONICAL, lambda m: canonical_hits.append(1))
+ msg = Message(LEGACY, {"utterance": "hi"},
+ {INTENT_COMPAT_TWIN_KEY: True})
+ bus.emit(msg)
+ self.assertEqual(canonical_hits, [])
+
+ def test_non_intent_topic_untouched(self):
+ bus = FakeBus()
+ got = []
+ bus.on("my.custom.topic", lambda m: got.append(m.msg_type))
+ bus.emit(Message("my.custom.topic", {"x": 1}))
+ self.assertEqual(got, ["my.custom.topic"])
+ # and no stray listeners fired for unrelated suffixed-looking topics
+ got2 = []
+ bus.on("speak", lambda m: got2.append(m.msg_type))
+ bus.emit(Message("my.custom.topic", {"x": 1}))
+ self.assertEqual(got2, [])
+
+ def test_flags_off_no_bridging(self):
+ # each direction gets its own bus/listener pair: a listener on the
+ # SAME topic as what's emitted always fires (plain same-topic
+ # dispatch, unrelated to the bridge) -- only the OTHER namespace's
+ # listener proves whether bridging happened.
+ bus1 = FakeBus(modernize=False, emit_legacy=False)
+ got_canonical = []
+ bus1.on(CANONICAL, lambda m: got_canonical.append(1))
+ bus1.emit(Message(LEGACY, {"utterance": "hi"}))
+ self.assertEqual(got_canonical, []) # RULE 2 suppressed
+
+ bus2 = FakeBus(modernize=False, emit_legacy=False)
+ got_legacy = []
+ bus2.on(LEGACY, lambda m: got_legacy.append(1))
+ bus2.emit(Message(CANONICAL, {"utterance": "hi"}))
+ self.assertEqual(got_legacy, []) # RULE 1 suppressed
+
+ def test_already_canonical_not_re_twinned_into_itself(self):
+ # a topic with no legacy counterpart (canonical == legacy, e.g. a
+ # non-suffixed topic that is not an intent topic at all) is a no-op.
+ bus = FakeBus()
+ calls = []
+ bus.on(CANONICAL, lambda m: calls.append(1))
+ bus.emit(Message(CANONICAL, {"utterance": "hi"}))
+ # exactly one direct dispatch; the RULE-1 twin went to LEGACY, not
+ # back onto CANONICAL, so no double count here.
+ self.assertEqual(calls, [1])
+
+ def test_async_fakebus_legacy_emit_reaches_canonical_listener(self):
+ bus = AsyncFakeBus()
+ got = []
+ bus.on(CANONICAL, lambda m: got.append(m.msg_type))
+ _run(bus.emit(Message(LEGACY, {"utterance": "hi"})))
+ self.assertEqual(got, [CANONICAL])
+
+ def test_async_fakebus_canonical_emit_fires_legacy_listener(self):
+ bus = AsyncFakeBus()
+ got = []
+ bus.on(LEGACY, lambda m: got.append(m.msg_type))
+ _run(bus.emit(Message(CANONICAL, {"utterance": "hi"})))
+ self.assertEqual(got, [LEGACY])
+
+
+class TestFakeBusIntentRegistrationIdempotency(unittest.TestCase):
+ """Registering the SAME handler twice on the same intent topic used to
+ be idempotent: pyee's EventEmitter keys its listener OrderedDict by the
+ handler object, so an equal bound method collapsed onto one slot instead
+ of firing twice. The intent-topic bridge (see class above) wraps every
+ intent-topic registration in a wrapper closure to dedupe the mirrored
+ canonical/legacy pair -- but minting a FRESH closure on every ``on()``
+ call broke the pre-existing double-registration idempotency, because
+ pyee then saw two distinct wrapper objects for what a caller (or
+ ovos-workshop's own registration.py) intends as one subscription.
+ """
+
+ def test_same_handler_twice_one_topic_fires_once_and_off_removes_fully(self):
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on(CANONICAL, handler)
+ bus.on(CANONICAL, handler) # duplicate registration, same handler
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(len(calls), 1)
+
+ bus.remove(CANONICAL, handler)
+ calls.clear()
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(calls, []) # fully removed, no leftover registration
+
+ def test_same_handler_both_spellings_fires_once(self):
+ # the ovos-workshop scenario: one handler bound to both the
+ # canonical and legacy spelling of the same intent -- since both
+ # canonicalize to one logical topic, this must fire exactly once
+ # per dispatch, not twice.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on(CANONICAL, handler)
+ bus.on(LEGACY, handler)
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(len(calls), 1)
+
+ def test_two_different_handlers_one_topic_both_fire(self):
+ # must not over-dedup: distinct handlers on the same topic are
+ # distinct subscriptions and both must fire.
+ bus = FakeBus()
+ calls_a, calls_b = [], []
+
+ def handler_a(message=None):
+ calls_a.append(1)
+
+ def handler_b(message=None):
+ calls_b.append(1)
+
+ bus.on(CANONICAL, handler_a)
+ bus.on(CANONICAL, handler_b)
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(len(calls_a), 1)
+ self.assertEqual(len(calls_b), 1)
+
+ def test_non_intent_topic_keeps_pyee_semantics(self):
+ # a plain, non-migrated, non-intent topic goes straight to
+ # ``self.ee.on`` with no wrapper at all -- unaffected by this bridge,
+ # and pyee's own dedup-by-equal-handler semantics apply directly.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on("some.plain.topic", handler)
+ bus.on("some.plain.topic", handler)
+ bus.emit(Message("some.plain.topic", {}))
+ self.assertEqual(len(calls), 1)
+
+ def test_async_fakebus_same_handler_twice_one_topic_fires_once(self):
+ bus = AsyncFakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on(CANONICAL, handler)
+ bus.on(CANONICAL, handler)
+ _run(bus.emit(Message(CANONICAL, {})))
+ self.assertEqual(len(calls), 1)
+
+ bus.remove(CANONICAL, handler)
+ calls.clear()
+ _run(bus.emit(Message(CANONICAL, {})))
+ self.assertEqual(calls, [])
+
+ def test_async_fakebus_same_handler_both_spellings_fires_once(self):
+ bus = AsyncFakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on(CANONICAL, handler)
+ bus.on(LEGACY, handler)
+ _run(bus.emit(Message(CANONICAL, {})))
+ self.assertEqual(len(calls), 1)
+
+ def test_once_both_spellings_fires_once(self):
+ # once() used to bypass the mirror guard entirely (self.ee.once
+ # called straight through), so a handler bound to both spellings
+ # via once() fired TWICE for one logical dispatch instead of once.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.once(CANONICAL, handler)
+ bus.once(LEGACY, handler)
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(len(calls), 1)
+
+ def test_on_duplicate_registration_legacy_spelling_fires_once(self):
+ # the pre-existing on() dedup fix is exercised elsewhere only on the
+ # CANONICAL spelling; the guard-wrapping it relies on
+ # (_mirror_guard_for) is keyed the same way for the LEGACY spelling,
+ # so a duplicate registration there must collapse to one wrapper too
+ # -- not mint a fresh closure per call and fire twice.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on(LEGACY, handler)
+ bus.on(LEGACY, handler) # duplicate registration, same handler
+ bus.emit(Message(LEGACY, {}))
+ self.assertEqual(len(calls), 1)
+
+ def test_once_then_on_same_handler_does_not_stack_a_second_listener(self):
+ # once() never recorded itself in _dedup_registrations, so a later
+ # on() of the same handler minted a BRAND NEW wrapper instead of
+ # finding/reusing the once() registration -- pyee then held two
+ # independent listeners (the bare once() handler plus the fresh
+ # on() wrapper) and a single dispatch fired the handler twice.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.once(CANONICAL, handler)
+ bus.on(CANONICAL, handler)
+ bus.emit(Message(CANONICAL, {}))
+ self.assertEqual(len(calls), 1)
+
+ def test_on_duplicate_registration_migrated_topic_pinned_at_one(self):
+ # recognizer_loop:utterance <-> ovos.utterance.handle is a
+ # namespace-migration pair (is_migrated()), not an intent-topic pair
+ # -- the OTHER branch of _mirror_guard_for. This pins the CHOSEN
+ # behaviour for a duplicate on() registration there at 1 fire: the
+ # guard-wrapping fix governs the is_migrated branch identically to
+ # the intent-pair branch, so a naive re-mint-per-call bug would
+ # double it back to baseline's 2 fires.
+ bus = FakeBus()
+ calls = []
+
+ def handler(message=None):
+ calls.append(1)
+
+ bus.on("recognizer_loop:utterance", handler)
+ bus.on("recognizer_loop:utterance", handler) # duplicate
+ bus.emit(Message("recognizer_loop:utterance", {"utterances": ["hi"]}))
+ self.assertEqual(len(calls), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/unittests/test_fakebus_malformed_session.py b/test/unittests/test_fakebus_malformed_session.py
new file mode 100644
index 00000000..a67b0716
--- /dev/null
+++ b/test/unittests/test_fakebus_malformed_session.py
@@ -0,0 +1,123 @@
+# 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
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""OVOS-SESSION-1 §2.5 (architecture dev 198a3c9) -- a malformed session
+carrier drops the whole message rather than crashing the consumer or its
+transport, and the drop is observable: the bus emits
+``ovos.session.rejected`` naming the dropped message's type and reason.
+
+``ovos_bus_client.client.client.MessageBusClient.on_message`` wraps its
+session intake in ``try/except MalformedSession`` for exactly this reason
+(see ``client.py`` around ``_take_inbound_session``): a non-object
+``context.session`` is a per-message producer fault, not a transport fault.
+``FakeBus``/``AsyncFakeBus`` must mirror that: drop the malformed message
+without delivering it to listeners, and emit the rejection notice, which
+itself carries no session and is delivered normally.
+"""
+import asyncio
+import unittest
+
+from ovos_spec_tools.messages import SpecMessage
+
+from ovos_utils.fakebus import AsyncFakeBus, FakeBus, FakeMessage
+
+try:
+ from ovos_bus_client.session import SessionManager
+ HAS_BUS_CLIENT = True
+except ImportError:
+ HAS_BUS_CLIENT = False
+
+
+@unittest.skipUnless(HAS_BUS_CLIENT, "ovos-bus-client not installed")
+class TestFakeBusMalformedSessionIsRejected(unittest.TestCase):
+ def setUp(self):
+ SessionManager.reset_default_session()
+ self.bus = FakeBus()
+
+ def tearDown(self):
+ SessionManager.reset_default_session()
+
+ def test_malformed_carrier_is_dropped_and_rejected(self):
+ dropped = []
+ rejected = []
+ self.bus.on("x", dropped.append)
+ self.bus.on(SpecMessage.SESSION_REJECTED, rejected.append)
+
+ # context.session is a string, not an object -- malformed per §2.5.
+ message = FakeMessage("x", {}, {"session": "notanobject"})
+ self.bus.emit(message) # must not raise
+
+ self.assertEqual(dropped, [])
+ self.assertEqual(len(rejected), 1)
+ notice = rejected[0]
+ self.assertEqual(notice.msg_type, SpecMessage.SESSION_REJECTED)
+ self.assertEqual(notice.data, {"msg_type": "x",
+ "reason": "malformed_carrier"})
+ self.assertNotIn("session", notice.context)
+
+ def test_utterance_id_carried_onto_the_rejection_when_present(self):
+ rejected = []
+ self.bus.on(SpecMessage.SESSION_REJECTED, rejected.append)
+
+ message = FakeMessage("x", {}, {"session": "notanobject",
+ "utterance_id": "abc-123"})
+ self.bus.emit(message)
+
+ self.assertEqual(len(rejected), 1)
+ self.assertEqual(rejected[0].context.get("utterance_id"), "abc-123")
+ self.assertNotIn("session", rejected[0].context)
+
+ def test_well_formed_message_is_still_delivered_normally(self):
+ received = []
+ self.bus.on("x", received.append)
+
+ message = FakeMessage("x", {}, {})
+ self.bus.emit(message)
+
+ self.assertEqual(len(received), 1)
+ self.assertEqual(received[0].msg_type, "x")
+
+
+@unittest.skipUnless(HAS_BUS_CLIENT, "ovos-bus-client not installed")
+class TestAsyncFakeBusMalformedSessionIsRejected(unittest.TestCase):
+ def setUp(self):
+ SessionManager.reset_default_session()
+ self.bus = AsyncFakeBus()
+
+ def tearDown(self):
+ SessionManager.reset_default_session()
+
+ def test_malformed_carrier_is_dropped_and_rejected(self):
+ dropped = []
+ rejected = []
+ self.bus.on("x", dropped.append)
+ self.bus.on(SpecMessage.SESSION_REJECTED, rejected.append)
+
+ message = FakeMessage("x", {}, {"session": "notanobject"})
+ asyncio.run(self.bus.emit(message)) # must not raise
+
+ self.assertEqual(dropped, [])
+ self.assertEqual(len(rejected), 1)
+ notice = rejected[0]
+ self.assertEqual(notice.msg_type, SpecMessage.SESSION_REJECTED)
+ self.assertEqual(notice.data, {"msg_type": "x",
+ "reason": "malformed_carrier"})
+ self.assertNotIn("session", notice.context)
+
+ def test_well_formed_message_is_still_delivered_normally(self):
+ received = []
+ self.bus.on("x", received.append)
+
+ message = FakeMessage("x", {}, {})
+ asyncio.run(self.bus.emit(message))
+
+ self.assertEqual(len(received), 1)
+ self.assertEqual(received[0].msg_type, "x")
diff --git a/test/unittests/test_fakebus_namespace_migration.py b/test/unittests/test_fakebus_namespace_migration.py
new file mode 100644
index 00000000..18c9ec0a
--- /dev/null
+++ b/test/unittests/test_fakebus_namespace_migration.py
@@ -0,0 +1,159 @@
+"""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
+
+import pytest
+
+from ovos_utils.fakebus import AsyncFakeBus, FakeBus, Message
+
+# ovos_utils.fakebus.Message is a deprecated shim (use ovos_spec_tools.Message
+# or ovos_bus_client.Message); this module deliberately keeps exercising it
+# for coverage, filtered per-module.
+pytestmark = pytest.mark.filterwarnings(
+ "ignore:ovos_utils.fakebus.Message is deprecated:DeprecationWarning"
+)
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+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_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.deregister", lambda m: got.append(dict(m.data)))
+ bus.emit(Message("detach_intent", {"intent_name": "skill.foo:HelloIntent"}))
+ self.assertEqual(len(got), 1)
+ 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("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)
+ # 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()
+ 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 = []
+ 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, [])
+
+
+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()
diff --git a/test/unittests/test_fakebus_session_fold.py b/test/unittests/test_fakebus_session_fold.py
new file mode 100644
index 00000000..5b2ef06e
--- /dev/null
+++ b/test/unittests/test_fakebus_session_fold.py
@@ -0,0 +1,118 @@
+# 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
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""OVOS-SESSION-2 §5.1 — a FakeBus never folds the default session on its
+own observed traffic.
+
+The §5.1 arrival merge is a once-per-utterance orchestrator-intake fold (see
+core#915), not something every bus consumer repeats on every message it
+sees. ``FakeBus`` models one bus connection for a test, so a test drives far
+more default-session traffic through it than one fold per utterance --
+``on_message`` must dispatch that traffic without folding it into the
+``SessionManager`` singleton. A test that wants the orchestrator's own
+intake semantics calls ``SessionManager.fold_inbound`` explicitly, exactly
+as core's real intake does.
+"""
+import unittest
+
+from ovos_utils.fakebus import FakeBus, FakeMessage
+
+try:
+ from ovos_bus_client.session import SessionManager
+ HAS_BUS_CLIENT = True
+except ImportError:
+ HAS_BUS_CLIENT = False
+
+
+@unittest.skipUnless(HAS_BUS_CLIENT, "ovos-bus-client not installed")
+class TestFakeBusNeverFoldsObservedDefaultSession(unittest.TestCase):
+ def setUp(self):
+ SessionManager.reset_default_session()
+ self.bus = FakeBus()
+
+ def tearDown(self):
+ SessionManager.reset_default_session()
+
+ def _inbound(self, carrier):
+ return FakeMessage("recognizer_loop:utterance",
+ {"utterances": ["hello"]},
+ {"session": carrier}).serialize()
+
+ def test_observed_default_session_traffic_leaves_the_store_alone(self):
+ stored = SessionManager.get_default_session()
+ stored.lang = "pt-PT"
+ stored.site_id = "kitchen"
+
+ # a stale/minimal default carrier observed off the bus (e.g. an
+ # ovos.utterance.handled ack from earlier in a pipeline) must not
+ # wipe fields the live store has already moved past.
+ self.bus.on_message(self._inbound({"session_id": "default"}))
+ self.assertIs(SessionManager.get_default_session(), stored)
+ self.assertEqual(stored.lang, "pt-PT")
+ self.assertEqual(stored.site_id, "kitchen")
+
+ # nor does a fully-populated observed carrier overwrite it -- that
+ # fold belongs to the orchestrator's intake alone.
+ self.bus.on_message(self._inbound({"session_id": "default",
+ "site_id": "bedroom"}))
+ self.assertEqual(stored.site_id, "kitchen")
+
+ def test_emit_does_not_fold_the_default_session_either(self):
+ # emit() runs on_message() internally before handlers -- confirm the
+ # no-fold contract holds through the public emit() path too.
+ stored = SessionManager.get_default_session()
+ stored.site_id = "kitchen"
+ from ovos_bus_client.message import Message
+ self.bus.emit(Message("speak", {"utterance": "hi"},
+ {"session": {"session_id": "default",
+ "site_id": "bedroom"}}))
+ self.assertEqual(SessionManager.get_default_session().site_id,
+ "kitchen")
+
+
+@unittest.skipUnless(HAS_BUS_CLIENT, "ovos-bus-client not installed")
+class TestExplicitFoldInboundStillWorksAgainstAFakeBusMessage(unittest.TestCase):
+ """The orchestrator's own explicit §5.1 fold is unaffected.
+
+ A test simulating core's own intake still calls ``fold_inbound``
+ explicitly -- this is that honest test shape, and it must still merge
+ field-by-field the way §5.1 describes.
+ """
+
+ def setUp(self):
+ SessionManager.reset_default_session()
+
+ def tearDown(self):
+ SessionManager.reset_default_session()
+
+ def test_explicit_fold_inbound_merges_field_by_field(self):
+ from ovos_bus_client.message import Message
+ first = Message.deserialize(
+ FakeMessage("recognizer_loop:utterance", {"utterances": ["hi"]},
+ {"session": {"session_id": "default",
+ "lang": "pt-pt",
+ "site_id": "kitchen"}}).serialize())
+ SessionManager.fold_inbound(first)
+ stored = SessionManager.get_default_session()
+ self.assertEqual(stored.lang, "pt-PT")
+ self.assertEqual(stored.site_id, "kitchen")
+
+ second = Message.deserialize(
+ FakeMessage("recognizer_loop:utterance", {"utterances": ["hi"]},
+ {"session": {"session_id": "default"}}).serialize())
+ SessionManager.fold_inbound(second)
+ self.assertIs(SessionManager.get_default_session(), stored)
+ self.assertEqual(stored.lang, "pt-PT")
+ self.assertEqual(stored.site_id, "kitchen")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/unittests/test_file_utils.py b/test/unittests/test_file_utils.py
index 8a0efd84..c1d8579c 100644
--- a/test/unittests/test_file_utils.py
+++ b/test/unittests/test_file_utils.py
@@ -1,10 +1,11 @@
+import os
import shutil
import unittest
from os import makedirs
from os.path import isdir, join, dirname
from threading import Event
from time import time
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
class TestFileUtils(unittest.TestCase):
@@ -143,6 +144,58 @@ def _on_change(fp):
shutil.rmtree(test_dir)
+ def test_filewatcher_not_yet_existing_file(self):
+ from ovos_utils.file_utils import FileWatcher
+
+ test_dir = join(dirname(__file__), "test_watch_new")
+ test_file = join(test_dir, "not_yet.watch")
+ makedirs(test_dir, exist_ok=True)
+ self.assertFalse(os.path.isfile(test_file))
+
+ # a path that doesn't exist yet is still watched in file mode:
+ # the containing (existing) directory is scheduled, and a
+ # 'created' event for the not-yet-existing file fires the callback
+ called = Event()
+ callback = Mock(side_effect=lambda x: called.set())
+ watcher = FileWatcher([test_file], callback)
+ with open(test_file, 'w+'):
+ pass
+ self.assertTrue(called.wait(3))
+ callback.assert_called_once_with(test_file)
+ watcher.shutdown()
+
+ # a different file being created in the same directory must
+ # NOT fire the callback (file mode still filters to the one path)
+ called.clear()
+ callback.reset_mock()
+ watcher = FileWatcher([test_file], callback)
+ other_file = join(test_dir, "other.watch")
+ with open(other_file, 'w+'):
+ pass
+ self.assertFalse(called.wait(3))
+ callback.assert_not_called()
+ watcher.shutdown()
+
+ shutil.rmtree(test_dir)
+
+ def test_filewatcher_missing_parent_directory(self):
+ from ovos_utils.file_utils import FileWatcher
+
+ # if the parent directory of a not-yet-existing file also doesn't
+ # exist, watchdog can't schedule an observer on it; FileWatcher
+ # must skip that entry (with a LOG.warning) instead of raising
+ # an opaque watchdog exception
+ missing_parent = join(dirname(__file__), "definitely_not_there")
+ missing_file = join(missing_parent, "cfg.json")
+ self.assertFalse(isdir(missing_parent))
+
+ callback = Mock()
+ with patch("ovos_utils.file_utils.LOG") as mock_log:
+ watcher = FileWatcher([missing_file], callback)
+ mock_log.warning.assert_called_once()
+ self.assertEqual(watcher.observer.emitters, set())
+ watcher.shutdown()
+
def test_file_event_handler(self):
from ovos_utils.file_utils import FileEventHandler
from watchdog.events import FileCreatedEvent, FileModifiedEvent, FileClosedEvent
@@ -169,6 +222,46 @@ def test_file_event_handler(self):
handler.on_any_event(FileClosedEvent(test_file))
callback.assert_called_once()
+ # Test events for a different file in the same directory are ignored
+ # when watching a specific file (the FileWatcher watches the
+ # containing directory, so watchdog reports every file inside it)
+ other_file = join(dirname(__file__), "other.watch")
+ callback.reset_mock()
+ handler = FileEventHandler(test_file, callback, True)
+ handler.on_any_event(FileModifiedEvent(other_file))
+ handler.on_any_event(FileClosedEvent(other_file))
+ callback.assert_not_called()
+ # but the watched file itself still fires
+ handler.on_any_event(FileModifiedEvent(test_file))
+ handler.on_any_event(FileClosedEvent(test_file))
+ callback.assert_called_once()
+
+ # Test directory-watch mode (file_path=None) reports every file
+ callback.reset_mock()
+ handler = FileEventHandler(None, callback, True)
+ handler.on_any_event(FileModifiedEvent(test_file))
+ handler.on_any_event(FileClosedEvent(test_file))
+ callback.assert_called_once_with(test_file)
+ callback.reset_mock()
+ handler.on_any_event(FileModifiedEvent(other_file))
+ handler.on_any_event(FileClosedEvent(other_file))
+ callback.assert_called_once_with(other_file)
+
+ # Test two handlers watching different files in the same directory
+ # each fire only for their own file, not for each other's
+ callback_a = Mock()
+ callback_b = Mock()
+ handler_a = FileEventHandler(test_file, callback_a, True)
+ handler_b = FileEventHandler(other_file, callback_b, True)
+ # both handlers watch the same directory, so watchdog delivers
+ # every event to both of them
+ for handler in (handler_a, handler_b):
+ for ev_file in (test_file, other_file):
+ handler.on_any_event(FileModifiedEvent(ev_file))
+ handler.on_any_event(FileClosedEvent(ev_file))
+ callback_a.assert_called_once_with(test_file)
+ callback_b.assert_called_once_with(other_file)
+
# Test include creation callbacks
callback.reset_mock()
handler = FileEventHandler(test_file, callback, False)
diff --git a/test/unittests/test_lang.py b/test/unittests/test_lang.py
index 19cbeb20..09d01c17 100644
--- a/test/unittests/test_lang.py
+++ b/test/unittests/test_lang.py
@@ -20,44 +20,55 @@
import unittest.mock
from unittest.mock import patch
+import pytest
+
+@pytest.mark.filterwarnings(
+ "ignore:standardize_lang_tag is deprecated; use 'standardize_lang' from 'ovos_spec_tools' instead:DeprecationWarning"
+)
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."""
+ 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
- # 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")
+ 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_no_region_tag(self) -> None:
- """standardize_lang_tag with no '-' should return lowercased tag."""
+ 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
- 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"
+ self.assertEqual(standardize_lang_tag("cmn", macro=True), "zh")
+ self.assertEqual(standardize_lang_tag("cmn", macro=False), "cmn")
- 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)
+ 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}):
+ self.assertEqual(
+ standardize_lang_tag("en-us", macro=True), "en-US")
+ self.assertEqual(
+ standardize_lang_tag("EN", macro=False), "en")
+@pytest.mark.filterwarnings(
+ "ignore:get_language_dir is deprecated; use 'closest_lang' from 'ovos_spec_tools':DeprecationWarning"
+)
class TestGetLanguageDir(unittest.TestCase):
"""Tests for get_language_dir."""
diff --git a/test/unittests/test_log.py b/test/unittests/test_log.py
index f816cf1b..e92a7f6e 100644
--- a/test/unittests/test_log.py
+++ b/test/unittests/test_log.py
@@ -2,6 +2,7 @@
import shutil
import unittest
import importlib
+import logging
from os.path import join, dirname, isdir, isfile
from unittest.mock import patch, Mock
@@ -54,8 +55,9 @@ def test_log(self):
log_file = join(LOG.base_path, f"{LOG.name}.log")
self.assertFalse(isfile(log_file))
LOG.info("This won't print")
- self.assertTrue(isfile(log_file))
+ self.assertFalse(isfile(log_file))
LOG.warning("This will print")
+ self.assertTrue(isfile(log_file))
with open(log_file) as f:
lines = f.readlines()
self.assertEqual(len(lines), 1)
@@ -106,6 +108,75 @@ def test_log(self):
self.assertEqual(len(lines), 1)
self.assertTrue(lines[0].endswith("99\n"))
+ def test_disabled_levels_skip_call_site_resolution(self):
+ from ovos_utils.log import LOG
+
+ cases = [
+ ("debug", logging.DEBUG, logging.INFO),
+ ("info", logging.INFO, logging.WARNING),
+ ("warning", logging.WARNING, logging.ERROR),
+ ("error", logging.ERROR, logging.CRITICAL),
+ ("exception", logging.ERROR, logging.CRITICAL),
+ ]
+ for method_name, _record_level, configured_level in cases:
+ with self.subTest(method=method_name), \
+ patch.object(LOG, "level", configured_level), \
+ patch.object(LOG, "_get_real_logger") as get_logger:
+ getattr(LOG, method_name)("not emitted")
+ get_logger.assert_not_called()
+
+ def test_enabled_levels_keep_existing_logger_path(self):
+ from ovos_utils.log import LOG
+
+ cases = [
+ ("debug", logging.DEBUG),
+ ("info", logging.INFO),
+ ("warning", logging.WARNING),
+ ("error", logging.ERROR),
+ ("exception", logging.ERROR),
+ ]
+ for method_name, configured_level in cases:
+ logger = Mock()
+ with self.subTest(method=method_name), \
+ patch.object(LOG, "level", configured_level), \
+ patch.object(LOG, "_get_real_logger",
+ return_value=logger):
+ getattr(LOG, method_name)("emitted: %s", "value")
+ getattr(logger, method_name).assert_called_once_with(
+ "emitted: %s", "value")
+
+ def test_is_enabled_for_accepts_named_and_numeric_levels(self):
+ from ovos_utils.log import LOG
+
+ with patch.object(LOG, "level", "DEBUG"):
+ self.assertTrue(LOG.is_enabled_for(logging.DEBUG))
+ with patch.object(LOG, "level", "INFO"):
+ self.assertFalse(LOG.is_enabled_for(logging.DEBUG))
+ self.assertTrue(LOG.is_enabled_for(logging.WARNING))
+ with patch.object(LOG, "level", logging.ERROR):
+ self.assertFalse(LOG.is_enabled_for(logging.WARNING))
+ self.assertTrue(LOG.is_enabled_for(logging.ERROR))
+
+ def test_is_enabled_for_honors_global_disable(self):
+ from ovos_utils.log import LOG
+
+ original_disable = logging.root.manager.disable
+ try:
+ logging.disable(logging.CRITICAL)
+ with patch.object(LOG, "level", "DEBUG"):
+ self.assertFalse(LOG.is_enabled_for(logging.DEBUG))
+ finally:
+ logging.disable(original_disable)
+
+ def test_is_enabled_for_uses_effective_root_level_for_notset(self):
+ from ovos_utils.log import LOG
+
+ with patch.object(LOG, "level", logging.NOTSET), \
+ patch.object(logging.root, "getEffectiveLevel",
+ return_value=logging.INFO):
+ self.assertFalse(LOG.is_enabled_for(logging.DEBUG))
+ self.assertTrue(LOG.is_enabled_for(logging.WARNING))
+
@patch("ovos_utils.log.get_logs_config")
@patch("ovos_config.Configuration.set_config_watcher")
def test_init_service_logger(self, set_config_watcher, log_config):
@@ -166,7 +237,7 @@ def test_deprecated_decorator(self, create_logger):
self.assertIn('test_log', log_msg, log_msg)
self.assertIn('imported deprecation', log_msg, log_msg)
- test_class = Deprecated()
+ Deprecated()
log_msg = log_warning.call_args[0][0]
self.assertIn('version=0.2.0', log_msg, log_msg)
self.assertIn('Class Deprecated', log_msg, log_msg)
@@ -184,6 +255,58 @@ def _deprecated_function(test_arg):
self.assertIn('version=1.0.0', log_msg, log_msg)
self.assertIn('test deprecation', log_msg, log_msg)
+ @patch("ovos_utils.log.LOG.create_logger")
+ def test_log_deprecation_dedupe(self, create_logger):
+ fake_log = Mock()
+ log_warning = fake_log.warning
+ create_logger.return_value = fake_log
+ import ovos_utils.log
+ from ovos_utils.log import log_deprecation, deprecated
+ ovos_utils.log._logged_deprecations.clear()
+ ovos_utils.log._logged_deprecations_fast.clear()
+
+ # Repeated calls from the same caller only log once
+ for _ in range(10):
+ log_deprecation("repeated deprecation")
+ log_warning.assert_called_once()
+ log_msg = log_warning.call_args[0][0]
+ self.assertIn('repeated deprecation', log_msg, log_msg)
+
+ # A different message still logs
+ log_deprecation("other deprecation")
+ self.assertEqual(log_warning.call_count, 2)
+ log_msg = log_warning.call_args[0][0]
+ self.assertIn('other deprecation', log_msg, log_msg)
+
+ # Deprecated decorator is also deduplicated
+ @deprecated("decorated deprecation", "1.0.0")
+ def _deprecated_function():
+ pass
+
+ for _ in range(10):
+ _deprecated_function()
+ self.assertEqual(log_warning.call_count, 3)
+ log_msg = log_warning.call_args[0][0]
+ self.assertIn('decorated deprecation', log_msg, log_msg)
+
+ @patch("ovos_utils.log.LOG.create_logger")
+ def test_log_deprecation_dedupe_skips_stack_walk(self, create_logger):
+ fake_log = Mock()
+ create_logger.return_value = fake_log
+ import ovos_utils.log
+ from ovos_utils.log import log_deprecation
+ ovos_utils.log._logged_deprecations.clear()
+ ovos_utils.log._logged_deprecations_fast.clear()
+
+ with patch("ovos_utils.log.inspect.stack",
+ wraps=ovos_utils.log.inspect.stack) as stack_mock:
+ for _ in range(3):
+ log_deprecation("perf guard deprecation")
+ # Only the first call (populating the dedup set) may walk the stack;
+ # deduplicated repeats must short-circuit before `inspect.stack()`.
+ self.assertLessEqual(stack_mock.call_count, 1, stack_mock.call_count)
+ fake_log.warning.assert_called_once()
+
@patch("ovos_utils.log.get_logs_config")
@patch("ovos_utils.log.LOG")
def test_monitor_log_level(self, log, get_config):
@@ -271,6 +394,17 @@ def test_get_log_path(self, get_config):
self.assertEqual(get_log_path("test"), self.test_dir)
get_config.assert_called_once_with(service_name="test")
+ def test_get_log_path_broken_symlink(self):
+ from ovos_utils.log import get_log_path
+
+ symlink_path = join(self.test_dir, "broken.log")
+ os.symlink(join(self.test_dir, "does_not_exist.log"), symlink_path)
+ try:
+ self.assertEqual(get_log_path("broken", [self.test_dir]),
+ self.test_dir)
+ finally:
+ os.unlink(symlink_path)
+
@patch('ovos_config.Configuration')
def test_get_log_paths(self, config):
from ovos_utils.log import get_log_paths
diff --git a/test/unittests/test_log_parser.py b/test/unittests/test_log_parser.py
index 48a02714..4b3cba2d 100644
--- a/test/unittests/test_log_parser.py
+++ b/test/unittests/test_log_parser.py
@@ -214,6 +214,13 @@ def test_parse_invalid_line_with_last_timestamp(self) -> None:
result = OVOSLogParser.parse("some system message\n", last_timestamp=ts)
self.assertEqual(result.timestamp, ts)
+ def test_parse_invalid_line_without_last_timestamp(self) -> None:
+ """parse should leave timestamp as None, not a string, when there is
+ no last_timestamp to fall back to."""
+ from ovos_utils.log_parser import OVOSLogParser
+ result = OVOSLogParser.parse("some system message\n")
+ self.assertIsNone(result.timestamp)
+
def test_parse_file_valid(self) -> None:
"""parse_file should yield LogLine objects from a valid log file."""
from ovos_utils.log_parser import OVOSLogParser, LogLine
@@ -283,6 +290,36 @@ def test_parse_file_skips_blank_lines(self) -> None:
finally:
os.unlink(fname)
+ def test_parse_file_leading_line_without_timestamp(self) -> None:
+ """A log line that does not match LOG_PATTERN (e.g. it is missing a
+ field) before any timestamped line is seen must not leave a string
+ in LogLine.timestamp, since callers compare it against datetimes."""
+ from ovos_utils.log_parser import OVOSLogParser
+
+ content = (
+ "2024-07-17 21:59:57.530 - common_query.openvoiceos - INFO - "
+ "First run of common_query.openvoiceos\n"
+ "2024-07-17 22:00:01.123 - skills - core.MSM - DEBUG - loaded skill\n"
+ )
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
+ f.write(content)
+ fname = f.name
+
+ try:
+ results = list(OVOSLogParser.parse_file(fname))
+ first, second = results
+ self.assertIsNone(first.timestamp)
+ self.assertIsInstance(second.timestamp, datetime)
+ start = datetime(2024, 1, 1)
+ end = datetime.now()
+ # must not raise: comparing None against datetimes previously
+ # raised TypeError because the fallback timestamp was ""
+ filtered = [log for log in results
+ if log.timestamp is not None and start <= log.timestamp < end]
+ self.assertEqual(len(filtered), 1)
+ finally:
+ os.unlink(fname)
+
class TestParseTime(unittest.TestCase):
"""Tests for the parse_time helper."""
@@ -412,5 +449,52 @@ def test_reads_log_for_last_load(self) -> None:
self.assertGreater(result, datetime.fromtimestamp(0))
+class TestOvosLogsCLINoStrayFiles(unittest.TestCase):
+ """Regression test: importing/using the ovos-logs CLI must not create
+ a stray lock file in the current working directory.
+
+ Previously `LOGLOCK = ComboLock("ovos_logs_console_script")` was
+ instantiated at module import time with a bare relative path, which
+ made ComboLock create (and leave behind) an empty file named
+ `ovos_logs_console_script` in whatever directory the process was
+ started from.
+ """
+
+ def test_help_does_not_create_stray_file(self) -> None:
+ import subprocess
+ import sys
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ before = set(os.listdir(tmpdir))
+ subprocess.run(
+ [sys.executable, "-c",
+ "import sys; sys.argv=['ovos-logs', '--help']; "
+ "from ovos_utils.log_parser import ovos_logs; "
+ "ovos_logs()"],
+ cwd=tmpdir,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ after = set(os.listdir(tmpdir))
+ self.assertEqual(before, after,
+ f"stray files created in cwd: {after - before}")
+
+ def test_importing_log_parser_does_not_create_stray_file(self) -> None:
+ import subprocess
+ import sys
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ before = set(os.listdir(tmpdir))
+ subprocess.run(
+ [sys.executable, "-c", "import ovos_utils.log_parser"],
+ cwd=tmpdir,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ after = set(os.listdir(tmpdir))
+ self.assertEqual(before, after,
+ f"stray files created in cwd: {after - before}")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/test/unittests/test_network_utils.py b/test/unittests/test_network_utils.py
index b103465c..5eb47bbc 100644
--- a/test/unittests/test_network_utils.py
+++ b/test/unittests/test_network_utils.py
@@ -2,6 +2,8 @@
import unittest
from time import sleep
+import pytest
+
class TestNetworkUtils(unittest.TestCase):
def test_get_network_tests_config(self):
@@ -34,6 +36,7 @@ def test_is_connected_http(self):
self.assertIsInstance(is_connected_http(), bool)
# TODO
+ @pytest.mark.filterwarnings("ignore:use is_connected_http or is_connected_dns:DeprecationWarning")
def test_is_connected(self):
from ovos_utils.network_utils import is_connected
self.assertIsInstance(is_connected(), bool)
diff --git a/test/unittests/test_ocp_extra.py b/test/unittests/test_ocp_extra.py
index 44851f9e..91c95d64 100644
--- a/test/unittests/test_ocp_extra.py
+++ b/test/unittests/test_ocp_extra.py
@@ -391,5 +391,146 @@ def test_playback_mode_values(self):
self.assertEqual(PlaybackMode.AUDIO_ONLY, 10)
+# ---- MPRIS / numeric hardening tests -----------------------------------------
+
+def _stub_dbus_next():
+ """Install a minimal dbus_next stub that records the Variant signature used."""
+ import sys
+ from unittest.mock import MagicMock
+
+ class Variant:
+ def __init__(self, signature, value):
+ if signature != 'x' and not isinstance(value, (int, float, str, list)):
+ raise TypeError(f"bad value for signature {signature}: {value!r}")
+ self.signature = signature
+ self.value = value
+
+ dbus_stub = MagicMock()
+ dbus_stub.service.Variant = Variant
+ sys.modules["dbus_next"] = dbus_stub
+ sys.modules["dbus_next.service"] = dbus_stub.service
+ return Variant
+
+
+def _unstub_dbus_next():
+ import sys
+ sys.modules.pop("dbus_next", None)
+ sys.modules.pop("dbus_next.service", None)
+
+
+class TestMprisLengthVariant(unittest.TestCase):
+ """mpris:length must use MPRIS2 signature 'x' (int64), never 'd'."""
+
+ def setUp(self):
+ _stub_dbus_next()
+
+ def tearDown(self):
+ _unstub_dbus_next()
+
+ def test_length_uses_int_signature(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=180)
+ meta = entry.mpris_metadata
+ variant = meta["mpris:length"]
+ self.assertEqual(variant.signature, 'x')
+ self.assertIsInstance(variant.value, int)
+ self.assertEqual(variant.value, 180)
+
+ def test_non_numeric_length_is_omitted_not_crashed(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3")
+ entry.length = "not-a-number" # bypass update() validation directly
+ meta = entry.mpris_metadata # must not raise
+ self.assertNotIn("mpris:length", meta)
+
+ def test_nan_length_is_omitted(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3")
+ entry.length = float("nan")
+ meta = entry.mpris_metadata
+ self.assertNotIn("mpris:length", meta)
+
+ def test_inf_length_is_omitted(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3")
+ entry.length = float("inf")
+ meta = entry.mpris_metadata
+ self.assertNotIn("mpris:length", meta)
+
+
+class TestMediaEntryUpdateValidation(unittest.TestCase):
+ """update() must reject invalid values for numeric fields, keeping prior value."""
+
+ def test_update_rejects_non_numeric_length(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100)
+ entry.update({"length": "garbage"})
+ self.assertEqual(entry.length, 100)
+
+ def test_update_rejects_nan_length(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100)
+ entry.update({"length": float("nan")})
+ self.assertEqual(entry.length, 100)
+
+ def test_update_rejects_inf_length(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100)
+ entry.update({"length": float("inf")})
+ self.assertEqual(entry.length, 100)
+
+ def test_update_rejects_bool_length(self):
+ # bool is a subclass of int - must not be accepted as a numeric length
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100)
+ entry.update({"length": True})
+ self.assertEqual(entry.length, 100)
+
+ def test_update_accepts_valid_length(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100)
+ entry.update({"length": 250})
+ self.assertEqual(entry.length, 250)
+
+ def test_update_rejects_non_numeric_match_confidence(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", match_confidence=50)
+ entry.update({"match_confidence": "high"})
+ self.assertEqual(entry.match_confidence, 50)
+
+ def test_update_still_applies_non_numeric_fields(self):
+ entry = MediaEntry(uri="http://x.com/f.mp3", length=100, title="Old")
+ entry.update({"length": "garbage", "title": "New"})
+ self.assertEqual(entry.length, 100)
+ self.assertEqual(entry.title, "New")
+
+ def test_playlist_length_survives_poisoned_entry(self):
+ # a non-numeric length must never reach Playlist.length's sum()
+ pl = Playlist()
+ pl.add_entry(MediaEntry(uri="a", length=10))
+ e2 = MediaEntry(uri="b", length=20)
+ e2.update({"length": "poison"})
+ pl.add_entry(e2)
+ self.assertEqual(pl.length, 30)
+
+
+class TestDict2EntryErrorType(unittest.TestCase):
+ """dict2entry must always raise ValueError on garbage input, never AssertionError/AttributeError."""
+
+ def test_none_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry(None)
+
+ def test_int_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry(5)
+
+ def test_str_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry("not-a-dict")
+
+ def test_list_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry(["not", "a", "dict"])
+
+ def test_empty_dict_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry({})
+
+ def test_dict_without_known_keys_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ dict2entry({"foo": "bar"})
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/test/unittests/test_skill_installer.py b/test/unittests/test_skill_installer.py
index b64394cc..dbae69f2 100644
--- a/test/unittests/test_skill_installer.py
+++ b/test/unittests/test_skill_installer.py
@@ -13,7 +13,8 @@
# limitations under the License.
"""Unit tests for :class:`~ovos_utils.skill_installer.ServiceInstaller`."""
import sys
-from unittest.mock import MagicMock, patch, call
+import unittest
+from unittest.mock import MagicMock, Mock, patch, call
import pytest
@@ -513,3 +514,75 @@ def test_on_uninstall_complete_called(self, bus: FakeBus) -> None:
):
inst.pip_uninstall(["custom-pkg"])
hook.assert_called_once()
+
+
+class TestServiceNameAddressing:
+ """OVOS-INSTALL-1 §2.2: ``data.service_name`` names the one service a
+ request is for, and every other installer ignores it in silence."""
+
+ @staticmethod
+ def _installer(bus: FakeBus) -> ServiceInstaller:
+ inst = ServiceInstaller(bus, service_name="ovos_audio",
+ config={"allow_pip": True})
+ inst.pip_install = Mock(return_value=True)
+ inst.pip_uninstall = Mock(return_value=True)
+ return inst
+
+ def test_a_request_for_another_service_is_ignored_in_silence(
+ self, bus: FakeBus) -> None:
+ inst = self._installer(bus)
+ inst.handle_install_python(Message(
+ "ovos.pip.install",
+ {"packages": ["some-plugin"], "service_name": "ovos_gui"}))
+ inst.pip_install.assert_not_called()
+ assert bus.emitted == [], \
+ "an installer that is not addressed must not answer"
+
+ def test_a_request_naming_this_service_is_acted_on(
+ self, bus: FakeBus) -> None:
+ inst = self._installer(bus)
+ inst.handle_install_python(Message(
+ "ovos.pip.install",
+ {"packages": ["some-plugin"], "service_name": "ovos_audio"}))
+ inst.pip_install.assert_called_once()
+ assert bus.last_type() == "ovos.pip.install.complete"
+
+ def test_a_request_naming_nobody_reaches_every_installer(
+ self, bus: FakeBus) -> None:
+ inst = self._installer(bus)
+ inst.handle_install_python(
+ Message("ovos.pip.install", {"packages": ["some-plugin"]}))
+ inst.pip_install.assert_called_once()
+ assert bus.last_type() == "ovos.pip.install.complete"
+
+ @pytest.mark.parametrize("near_miss",
+ ["OVOS_AUDIO", "ovos_audio_extra", "ovos_"])
+ def test_the_comparison_is_exact(self, bus: FakeBus,
+ near_miss: str) -> None:
+ inst = self._installer(bus)
+ inst.handle_install_python(Message(
+ "ovos.pip.install",
+ {"packages": ["p"], "service_name": near_miss}))
+ inst.pip_install.assert_not_called()
+ assert bus.emitted == []
+
+ def test_uninstall_is_addressed_the_same_way(self, bus: FakeBus) -> None:
+ inst = self._installer(bus)
+ inst.handle_uninstall_python(Message(
+ "ovos.pip.uninstall",
+ {"packages": ["some-plugin"], "service_name": "ovos_gui"}))
+ inst.pip_uninstall.assert_not_called()
+ assert bus.emitted == []
+
+ def test_the_pre_spec_suffixed_topic_is_served_and_warns(
+ self, bus: FakeBus) -> None:
+ inst = self._installer(bus)
+ assert inst._handle_legacy_install in \
+ bus.handlers["ovos.pip.install.ovos_audio"]
+ with patch("ovos_utils.skill_installer.LOG.warning") as warn:
+ inst._handle_legacy_install(Message(
+ "ovos.pip.install.ovos_audio", {"packages": ["some-plugin"]}))
+ inst.pip_install.assert_called_once()
+ assert bus.last_type() == "ovos.pip.install.complete"
+ assert any("pre-spec" in c.args[0] for c in warn.call_args_list), \
+ f"expected a deprecation warning, got {warn.call_args_list}"
diff --git a/test/unittests/test_sound.py b/test/unittests/test_sound.py
index 4843e985..7e175ba2 100644
--- a/test/unittests/test_sound.py
+++ b/test/unittests/test_sound.py
@@ -26,7 +26,7 @@
if "distutils" not in sys.modules:
distutils_stub = types.ModuleType("distutils")
spawn_stub = types.ModuleType("distutils.spawn")
- spawn_stub.find_executable = lambda x: None
+ spawn_stub.which = lambda x: None
distutils_stub.spawn = spawn_stub
sys.modules["distutils"] = distutils_stub
sys.modules["distutils.spawn"] = spawn_stub
@@ -62,7 +62,7 @@ def test_no_tts_config(self) -> None:
class TestFindPlayer(unittest.TestCase):
"""Tests for _find_player helper."""
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_sox_play_found(self, mock_find: MagicMock) -> None:
"""Should prefer sox play when available."""
mock_find.side_effect = lambda x: "/usr/bin/play" if x == "play" else None
@@ -71,7 +71,7 @@ def test_sox_play_found(self, mock_find: MagicMock) -> None:
self.assertIsNotNone(result)
self.assertIn("play", result)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_ogg_player_preferred_for_ogg(self, mock_find: MagicMock) -> None:
"""Should prefer ogg123 for .ogg files when sox is unavailable."""
def side_effect(x: str) -> str | None:
@@ -86,7 +86,7 @@ def side_effect(x: str) -> str | None:
self.assertIsNotNone(result)
self.assertIn("ogg123", result)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_pw_play_fallback(self, mock_find: MagicMock) -> None:
"""Should use pw-play when sox is unavailable and file is not ogg."""
def side_effect(x: str) -> str | None:
@@ -99,7 +99,7 @@ def side_effect(x: str) -> str | None:
self.assertIsNotNone(result)
self.assertIn("pw-play", result)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_paplay_for_wav(self, mock_find: MagicMock) -> None:
"""Should use paplay for .wav when pw-play and sox unavailable."""
def side_effect(x: str) -> str | None:
@@ -112,7 +112,7 @@ def side_effect(x: str) -> str | None:
self.assertIsNotNone(result)
self.assertIn("paplay", result)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_aplay_for_wav_when_paplay_missing(self, mock_find: MagicMock) -> None:
"""Should fall back to aplay for .wav when paplay is unavailable."""
def side_effect(x: str) -> str | None:
@@ -125,7 +125,7 @@ def side_effect(x: str) -> str | None:
self.assertIsNotNone(result)
self.assertIn("aplay", result)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
def test_mpg123_for_mp3(self, mock_find: MagicMock) -> None:
"""Should use mpg123 for mp3 when no other player found."""
def side_effect(x: str) -> str | None:
@@ -138,7 +138,7 @@ def side_effect(x: str) -> str | None:
self.assertIsNotNone(result)
self.assertIn("mpg123", result)
- @patch("ovos_utils.sound.find_executable", return_value=None)
+ @patch("ovos_utils.sound.which", return_value=None)
def test_returns_none_when_no_player(self, _mock_find: MagicMock) -> None:
"""Should return None when no suitable player is found."""
from ovos_utils.sound import _find_player
@@ -287,7 +287,7 @@ def test_snd_prefix_resolved(self) -> None:
duration = get_sound_duration("snd/test.wav", base_dir=base_dir)
self.assertGreater(duration, 0)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
@patch("subprocess.Popen")
def test_ffprobe_fallback(self, mock_popen: MagicMock,
mock_find: MagicMock) -> None:
@@ -311,7 +311,7 @@ def find_side_effect(x: str) -> str | None:
finally:
os.unlink(fname)
- @patch("ovos_utils.sound.find_executable")
+ @patch("ovos_utils.sound.which")
@patch("subprocess.Popen")
def test_mediainfo_fallback(self, mock_popen: MagicMock,
mock_find: MagicMock) -> None:
@@ -337,7 +337,7 @@ def find_side_effect(x: str) -> str | None:
finally:
os.unlink(fname)
- @patch("ovos_utils.sound.find_executable", return_value=None)
+ @patch("ovos_utils.sound.which", return_value=None)
def test_no_tool_raises_runtime_error(self, _mock_find: MagicMock) -> None:
"""get_sound_duration should raise RuntimeError when no tool is available."""
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: