diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml new file mode 100644 index 00000000..050eeacf --- /dev/null +++ b/.github/workflows/build-tests.yml @@ -0,0 +1,15 @@ +name: Build Tests + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + build: + uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev + secrets: inherit + with: + python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' + install_extras: 'extras' + test_path: 'test/unittests' diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml deleted file mode 100644 index d8688c4a..00000000 --- a/.github/workflows/build_tests.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Run Build Tests -on: - push: - branches: - - master - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_utils/version.py' - - 'test/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - workflow_dispatch: - -jobs: - build_tests: - strategy: - max-parallel: 2 - matrix: - python-version: ["3.10", "3.11" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev - - name: Build Source Packages - run: | - python setup.py sdist - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Install package - run: | - pip install .[all] diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..19bc9a38 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,17 @@ +name: Code Coverage + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + coverage: + uses: OpenVoiceOS/gh-automations/.github/workflows/coverage.yml@dev + secrets: inherit + with: + python_version: '3.11' + coverage_source: 'ovos_utils' + test_path: 'test/' + install_extras: '' + min_coverage: 0 diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index dffb1459..e1b4dd40 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -5,47 +5,11 @@ on: branches: [dev] schedule: - cron: "0 0 * * *" # Runs daily at midnight UTC - workflow_dispatch: # Allows manual triggering - -env: - TARGET_PACKAGE: "ovos-utils" # Set the package to track here + workflow_dispatch: jobs: - check-dependencies: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Download requirements file - run: | - curl -o constraints-alpha.txt https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-alpha.txt - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev swig libssl-dev libfann-dev portaudio19-dev libpulse-dev python3-fann2 - python -m venv venv - source venv/bin/activate - pip install build wheel - pip install -r constraints-alpha.txt - pip install pipdeptree - - - name: Find downstream dependencies - run: | - source venv/bin/activate - pipdeptree -r -p "$TARGET_PACKAGE" > downstream_report.txt || echo "No dependencies found" - - - name: Commit and push changes - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git checkout dev || git checkout -b dev - git add downstream_report.txt - git commit -m "Update downstream dependencies for $TARGET_PACKAGE" || echo "No changes to commit" - git push origin dev + check_downstream: + uses: OpenVoiceOS/gh-automations/.github/workflows/downstream-check.yml@dev + secrets: inherit + with: + package_name: 'ovos-utils' diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml new file mode 100644 index 00000000..8757eee1 --- /dev/null +++ b/.github/workflows/license_check.yml @@ -0,0 +1,11 @@ +name: License Check + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + license_check: + uses: OpenVoiceOS/gh-automations/.github/workflows/license-check.yml@dev + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..9a6b7a53 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: Lint + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + lint: + uses: OpenVoiceOS/gh-automations/.github/workflows/lint.yml@dev + secrets: inherit + with: + ruff: true + pre_commit: false # set true if .pre-commit-config.yaml exists diff --git a/.github/workflows/pip_audit.yml b/.github/workflows/pip_audit.yml new file mode 100644 index 00000000..bb3ca4d3 --- /dev/null +++ b/.github/workflows/pip_audit.yml @@ -0,0 +1,11 @@ +name: PIP Audit + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + pip_audit: + uses: OpenVoiceOS/gh-automations/.github/workflows/pip-audit.yml@dev + secrets: inherit diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index db85957d..35c0e063 100644 --- a/.github/workflows/publish_stable.yml +++ b/.github/workflows/publish_stable.yml @@ -6,53 +6,12 @@ on: jobs: publish_stable: - uses: TigreGotico/gh-automations/.github/workflows/publish-stable.yml@master + if: github.actor != 'github-actions[bot]' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-stable.yml@dev secrets: inherit with: branch: 'master' version_file: 'ovos_utils/version.py' - setup_py: 'setup.py' + publish_pypi: true + sync_dev: true publish_release: true - - publish_pypi: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: master - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Build Distribution Packages - run: | - python setup.py sdist bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} - - - sync_dev: - needs: publish_stable - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - ref: master - - name: Push master -> dev - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: dev diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 00000000..3574eb61 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,14 @@ +name: Release Preview + +on: + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + release_preview: + uses: OpenVoiceOS/gh-automations/.github/workflows/release-preview.yml@dev + secrets: inherit + with: + package_name: 'ovos_utils' + version_file: 'ovos_utils/version.py' diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index dbb357be..dd179a1b 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -8,101 +8,16 @@ on: jobs: publish_alpha: - uses: TigreGotico/gh-automations/.github/workflows/publish-alpha.yml@master + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + uses: OpenVoiceOS/gh-automations/.github/workflows/publish-alpha.yml@dev secrets: inherit with: branch: 'dev' version_file: 'ovos_utils/version.py' - setup_py: 'setup.py' update_changelog: true publish_prerelease: true + propose_release: true changelog_max_issues: 100 - - notify: - if: github.event.pull_request.merged == true - needs: publish_alpha - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Send message to Matrix bots channel - id: matrix-chat-message - uses: fadenb/matrix-chat-message@v0.0.6 - with: - homeserver: 'matrix.org' - token: ${{ secrets.MATRIX_TOKEN }} - channel: '!WjxEKjjINpyBRPFgxl:krbel.duckdns.org' - message: | - new ${{ github.event.repository.name }} PR merged! https://github.com/${{ github.repository }}/pull/${{ github.event.number }} - - publish_pypi: - needs: publish_alpha - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Build Distribution Packages - run: | - python setup.py sdist bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} - - - propose_release: - needs: publish_alpha - if: success() # Ensure this job only runs if the previous job succeeds - runs-on: ubuntu-latest - steps: - - name: Checkout dev branch - uses: actions/checkout@v6 - with: - ref: dev - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Get version from setup.py - id: get_version - run: | - VERSION=$(python setup.py --version) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Create and push new branch - run: | - git checkout -b release-${{ env.VERSION }} - git push origin release-${{ env.VERSION }} - - - name: Open Pull Request from dev to master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Variables - BRANCH_NAME="release-${{ env.VERSION }}" - BASE_BRANCH="master" - HEAD_BRANCH="release-${{ env.VERSION }}" - PR_TITLE="Release ${{ env.VERSION }}" - PR_BODY="Human review requested!" - - # Create a PR using GitHub API - curl -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token $GITHUB_TOKEN" \ - -d "{\"title\":\"$PR_TITLE\",\"body\":\"$PR_BODY\",\"head\":\"$HEAD_BRANCH\",\"base\":\"$BASE_BRANCH\"}" \ - https://api.github.com/repos/${{ github.repository }}/pulls + publish_pypi: true + notify_matrix: true diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml new file mode 100644 index 00000000..d6797041 --- /dev/null +++ b/.github/workflows/repo-health.yml @@ -0,0 +1,13 @@ +name: Repo Health + +on: + pull_request: + branches: [dev, master, main] + workflow_dispatch: + +jobs: + repo_health: + uses: OpenVoiceOS/gh-automations/.github/workflows/repo-health.yml@dev + secrets: inherit + with: + version_file: 'ovos_utils/version.py' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml deleted file mode 100644 index 596524d1..00000000 --- a/.github/workflows/unit_tests.yml +++ /dev/null @@ -1,64 +0,0 @@ -# This workflow will run unit tests - -name: Run Unit Tests -on: - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_utils/version.py' - - 'requirements/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - push: - branches: - - master - paths-ignore: - - 'ovos_utils/version.py' - - 'requirements/**' - - 'examples/**' - - '.github/**' - - '.gitignore' - - 'LICENSE' - - 'CHANGELOG.md' - - 'MANIFEST.in' - - 'readme.md' - - 'scripts/**' - workflow_dispatch: - -jobs: - unit_tests: - strategy: - max-parallel: 2 - matrix: - python-version: ["3.10", "3.11", "3.12" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt install python3-dev - python -m pip install build wheel - - name: Install core repo - run: | - pip install -e .[extras] - - name: Install test dependencies - run: | - pip install pytest pytest-timeout pytest-cov - - name: Run unittests - run: | - pytest --cov=ovos_utils --cov-report xml test/unittests - # NOTE: additional pytest invocations should also add the --cov-append flag - # or they will overwrite previous invocations' coverage reports - # (for an example, see OVOS Skill Manager's workflow) \ No newline at end of file diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 00000000..6da4198f --- /dev/null +++ b/FAQ.md @@ -0,0 +1,32 @@ + +# FAQ — `ovos-utils` + +## What is `ovos-utils`? +`ovos-utils` is collection of simple utilities for use across the openvoiceos ecosystem. + +## How do I install it? +```bash +pip install ovos-utils +``` +Or for development: +```bash +uv pip install -e ovos-utils/ +``` + +## Where do I report bugs? +Open an issue on the GitHub repository. Ensure you are targeting the `dev` branch for fixes. + +## How do I run tests? +```bash +uv run pytest ovos-utils/test/ --cov=ovos_utils +``` + +## How do I contribute? +1. Fork the repository and create a feature branch from `dev`. +2. Write tests for your changes. +3. Open a PR targeting the `dev` branch. +4. Ensure CI passes before requesting review. + +## Why do I get `AttributeError: module 'ovos_utils.version' has no attribute '__version__'` during build? +This usually happens in isolated build environments (like `python -m build`) when a dependency (like `kthread`) is missing and the package `__init__.py` attempts to import it before the version can be read. +We have mitigated this by making `kthread` a lazy import in `ovos_utils/thread_utils.py`. If you encounter this with other dependencies, ensure they are also loaded lazily. diff --git a/MAINTENANCE_REPORT.md b/MAINTENANCE_REPORT.md new file mode 100644 index 00000000..8548b8be --- /dev/null +++ b/MAINTENANCE_REPORT.md @@ -0,0 +1,98 @@ + +# Maintenance Report — `ovos-utils` + +## [2026-03-11] — Fix build failures and address CI/CD audit + +### Changes +- **`ovos_utils/thread_utils.py`** — Moved `import kthread` inside functions. This fixes `AttributeError: module 'ovos_utils.version' has no attribute '__version__'` during `python -m build` by allowing the package to be partially imported in isolated environments without all dependencies present. +- **`ovos_utils/skill_installer.py`** — Added `"-y"` flag to `uv pip uninstall` to ensure non-interactive execution, matching the pip fallback. +- **`ovos_utils/ocp.py`** — Added `__all__` to restore `from ovos_utils.ocp import *` functionality, which was broken by the `MediaType` deprecation shim. +- **`ovos_utils/ocp.py`** — Fixed `Playlist.add_entry` to correctly increment `position` *after* insertion, ensuring the logical selection remains on the same track when inserting before it. +- **`test/unittests/test_ocp.py`** — Updated assertions to verify that logical track selection is preserved during playlist insertions. +- **`test/unittests/test_dialog.py`** — Fixed `test_get_dialog_none_lang_config_import_error` to use `sys.modules` patching for `ovos_config` and removed broad `try/except` block. +- **`test/unittests/test_bracket_extra.py`** — Moved `Word` instance creation outside `catch_warnings` to prevent false positives in deprecation tests. +- **`test/unittests/test_device_input.py`** — Updated patch targets to `ovos_utils.device_input.find_executable` to correctly stub module-local imports. +- **`test/unittests/test_smtp_utils.py`** — Replaced broad `try/except` with explicit `sys.modules` mock for `ovos_config` to ensure test failures are surfaced. +- **`pyproject.toml`** — Added `python-dateutil` to core dependencies as it is required by `ovos_utils/time.py`. +- **`.github/workflows/build-tests.yml`** — Changed `install_extras` to `extras` to match `pyproject.toml` and ensure test dependencies are available. +- **`.github/workflows/python-support.yml`** — Removed redundant and deprecated workflow to resolve name collisions in CI. +- **`ovos_utils/log.py`** — Added `os.path.isdir` check in `get_available_logs` to prevent `FileNotFoundError` when log directories are missing (critical for environments like CI). +- **`ovos_utils/skill_installer.py`** — Implemented robust package name extraction and normalization using `packaging` library; improved error messaging for failed pip operations when `print_logs=True`. +- **`ovos_utils/thread_utils.py`** — Aligned `create_killable_daemon` return type annotation with docstring and used `TYPE_CHECKING` for `kthread`. +- **`pyproject.toml`** — Added `packaging` to `extras` dependencies. +- **`test/unittests/test_dialog.py`** — Improved `TestMustacheDialogRenderer` with failure mode coverage and deterministic selection testing; tightened `join_list` assertions and verified language fallback in `get_dialog`. +- **`test/unittests/test_ocp.py`** — Suppressed `DeprecationWarning` from `ovos_utils.ocp` during test collection. +- **`test/unittests/test_smtp_utils.py`** — Refactored `test_send_email_raises_when_no_config` to avoid patching `builtins.__import__`. +- **`test/unittests/test_device_input.py`** — Refined `distutils` stubbing logic to be less intrusive. + +### Rationale +Fixed critical build-time attribute error and addressed multiple security/correctness findings from CodeRabbit audit. + +### Verification +- `uv run pytest test/ -v` — All 897 tests passed. +- `python3 -c "import sys; sys.path.insert(0, '.'); import ovos_utils.version; print(ovos_utils.version.__version__)"` — Successfully returns version even with missing dependencies. + +### AI Transparency Report +- **AI Model**: gemini-2.0-flash-thinking +- **Actions Taken**: Diagnosed `AttributeError` during build as a dependency issue in `__init__.py` chain, implemented lazy imports, and applied targeted fixes to test suite based on audit feedback. +- **Oversight**: Verified locally via pytest and manual import tests. + +--- + +## [2026-03-11] — AUDIT.md bug fixes (20 confirmed fixes across 10 files) + +### Changes +- **`oauth.py:143`** — Added `None` guard before accessing `token_data["expires_at"]`; fixes `TypeError` when token is absent. +- **`oauth.py:146`** — Fixed inverted expiry comparison: `>=` changed to `<=`; tokens were being refreshed on every call while still valid. +- **`oauth.py:120`** — Fixed `expires_in` read from stale `token_data` instead of `new_token_data` after a successful refresh. +- **`network_utils.py:94`** — Fixed operator precedence bug: `cfg.get("dns_primary" or ...)` split into `cfg.get("dns_primary") or cfg.get(...)` so fallback key is actually used. +- **`network_utils.py:124`** — Bare `except:` changed to `except Exception:`. +- **`skill_installer.py:336`** — Added `if proc.stderr:` guard before `.read()` to prevent `AttributeError` when `print_logs=True`. +- **`lang/__init__.py:17`** — Fixed `split("-", 2)` unpacked into 2 variables; changed to `split("-", 1)` to avoid `ValueError` on tags like `zh-Hant-TW`. +- **`lang/__init__.py:13,32`** — Bare `except:` changed to `except Exception:`. +- **`log_parser.py:144`** — `@classmethod` `parse` renamed first argument from `self` to `cls`. +- **`log_parser.py:145`** — `log_line.rstrip("\n")` result now assigned back: `log_line = log_line.rstrip("\n")`. +- **`log_parser.py:158`** — `@classmethod` `parse_file` renamed first argument from `self` to `cls` and updated `self.parse`/`self.LOG_PATTERN` references. +- **`log_parser.py:181`** — Blank-line skip check updated from `== "\n"` to `not log.message.strip()` to match post-rstrip empty strings. +- **`log_parser.py:386,521`** — `open(file, 'w')` writability tests changed to `open(file, 'a')` to prevent truncating log files; bare `except:` changed to `except Exception:`. +- **`geolocation.py:87`** — `if lat and lon:` changed to `if lat is not None and lon is not None:` to handle zero coordinates correctly. +- **`file_utils.py:416`** — `_changed_files` changed from `list` to `set`; `.append()` → `.add()`, `.remove()` → `.discard()`, `except:` → `except Exception:`. +- **`fakebus.py:27,140,208`** — All three bare `except:` changed to `except Exception:`. +- **`device_input.py:74,104`** — Bare `except:` changed to `except Exception:`. +- **`xml_helper.py:33`** — Bare `except:` changed to `except Exception:`. +- **`security.py:64-67`** — Two `open(...).write(...)` calls without context manager replaced with `with open(...) as f: f.write(...)`. +- **`test/unittests/test_oauth.py`** — Updated tests to reflect correct expiry semantics and added `expires_in` to `new_token_data` fixture. + +### Rationale +All fixes address confirmed bugs documented in `AUDIT.md`. No behavioural changes beyond correcting the described defects. + +### Verification +- `uv run pytest test/ --cov=ovos_utils --cov-report=term-missing` — 897 passed, 1 skipped, coverage 85%. + +### AI Transparency Report +- **AI Model**: claude-sonnet-4-6 +- **Actions Taken**: Read all affected source files, applied minimal targeted fixes to each confirmed bug, updated two tests that encoded the old buggy behaviour. +- **Oversight**: Human review required before committing. + +--- + +## [2026-03-08] — Initial compliance scaffold + +### Changes +- Created `QUICK_FACTS.md` with machine-readable package metadata. +- Created `FAQ.md` with common Q&A. +- Created `MAINTENANCE_REPORT.md` (this file) as the change log. +- Created `SUGGESTIONS.md` with initial improvement proposals. +- Created `docs/index.md` as the documentation entry point (if missing). + +### Rationale +Establishing the required file set mandated by `AGENTS.md` for all active workspace repositories. + +### Verification +- All required files exist at repo root and `docs/` folder. +- No existing content was overwritten. + +### AI Transparency Report +- **AI Model**: Claude Sonnet 4.6 +- **Actions Taken**: Generated boilerplate compliance scaffold (QUICK_FACTS, FAQ, MAINTENANCE_REPORT, SUGGESTIONS, docs/index). +- **Oversight**: Files are stubs — human review and enrichment required before treating as authoritative. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 5c04800b..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -recursive-include ovos_utils/ * -recursive-include requirements/ * -include CHANGELOG.md -include LICENSE \ No newline at end of file diff --git a/QUICK_FACTS.md b/QUICK_FACTS.md new file mode 100644 index 00000000..69d3e792 --- /dev/null +++ b/QUICK_FACTS.md @@ -0,0 +1,18 @@ + +# Quick Facts — `ovos-utils` + +collection of simple utilities for use across the openvoiceos ecosystem + +| Feature | Details | +|---------|---------| +| Package Name | `ovos-utils` | +| Version | `0.8.5a3` | +| License | Apache | +| Repository | [https://github.com/OpenVoiceOS/ovos_utils](https://github.com/OpenVoiceOS/ovos_utils) | +| Python Support | >=3.9 | + +## Entry Points + +### Scripts +- `ovos-logs`: `ovos_utils.log_parser:ovos_logs` + diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 00000000..f15edbeb --- /dev/null +++ b/docs/events.md @@ -0,0 +1,87 @@ +# Events + +**Module:** `ovos_utils.events` + +--- + +## `EventContainer` + +Tracks message bus handlers registered by a skill or plugin, allowing clean unregistration on shutdown. + +```python +from ovos_utils.events import EventContainer + +container = EventContainer(bus) + +container.add("my.event", handler) +container.add("my.once", handler, once=True) + +# Later: +container.remove("my.event") +container.clear() # remove all +``` + +### Methods + +| Method | Description | +|---|---| +| `set_bus(bus)` | Attach a message bus | +| `add(name, handler, once=False)` | Register a handler. If `once=True`, automatically unregisters after first call | +| `remove(name) → bool` | Remove all handlers for `name`. Returns `True` if any were found | +| `clear()` | Unregister and clear all tracked handlers | +| `__iter__` | Iterate over `(name, handler)` pairs | + +> **Note on `remove()`:** Due to wrapper functions, `bus.remove(name, handler)` may not find the actual registered callable. `EventContainer.remove()` therefore calls `bus.remove_all_listeners(name)` to reliably clear all handlers for a given event name. + +--- + +## Handler Wrappers + +### `create_wrapper(handler, skill_id, on_start, on_end, on_error)` + +Creates a wrapped handler for skill intent handlers. The wrapper: + +1. Calls `unmunge_message(message, skill_id)` to strip the skill ID prefix from intent entity keys +2. Calls `on_start(message)` if provided +3. Calls `handler()` or `handler(message)` depending on signature +4. Calls `on_error(e)` or `on_error(e, message)` on exception +5. Always calls `on_end(message)` in the `finally` block + +### `create_basic_wrapper(handler, on_error)` + +Simpler wrapper that calls `handler()` or `handler(message)` and calls `on_error(e)` on exception. Used by `EventSchedulerInterface` for scheduled events. + +### `unmunge_message(message, skill_id)` + +Strips the letterified skill ID prefix from intent entity keys in `message.data`. For example, key `myskillMyentity` becomes `Myentity` for skill `my.skill`. + +### `get_handler_name(handler) → str` + +Returns a human-readable name for a handler function, including the owner object's name if available. + +--- + +## `EventSchedulerInterface` + +> **Deprecated.** Moved to `ovos_bus_client.apis.events.EventSchedulerInterface`. + +Interface for scheduling events via the OVOS message bus scheduler. Emits `mycroft.scheduler.*` messages. + +```python +# Preferred import: +from ovos_bus_client.apis.events import EventSchedulerInterface +``` + +### Methods + +| Method | Description | +|---|---| +| `schedule_event(handler, when, data, name, context)` | Schedule a one-time event | +| `schedule_repeating_event(handler, when, interval, data, name, context)` | Schedule a repeating event | +| `update_scheduled_event(name, data)` | Update event data | +| `cancel_scheduled_event(name)` | Cancel a pending event | +| `get_scheduled_event_status(name) → int` | Seconds until next trigger | +| `cancel_all_repeating_events()` | Cancel all repeating events | +| `shutdown()` | Cancel repeating events and clear all registered handlers | + +`when` may be a `datetime`, or a positive `int`/`float` representing seconds from now. diff --git a/docs/fakebus.md b/docs/fakebus.md new file mode 100644 index 00000000..49e863c9 --- /dev/null +++ b/docs/fakebus.md @@ -0,0 +1,98 @@ +# FakeBus + +**Module:** `ovos_utils.fakebus` + +In-process message bus and message implementation for testing, standalone usage, or environments where `ovos-bus-client` is not available. Behaves like the real `MessageBusClient` API without any WebSocket connection. + +--- + +## `FakeBus` + +```python +from ovos_utils.fakebus import FakeBus, FakeMessage + +bus = FakeBus() + +def on_utterance(message): + print(message.data["utterances"]) + +bus.on("recognizer_loop:utterance", on_utterance) +bus.emit(FakeMessage("recognizer_loop:utterance", {"utterances": ["hello"]})) +``` + +### Internals + +`FakeBus` uses a `pyee.EventEmitter` internally. When `emit()` is called: + +1. Injects `session` into `message.context` if not present (replicates the real bus side effect) +2. Emits `"message"` with the serialized payload on the emitter +3. Emits `message.msg_type` directly on the emitter (for `bus.on()` handlers) +4. Calls `on_message()` with the serialized payload + +### Session Handling + +`FakeBus` replicates `SessionManager` side effects from `ovos-bus-client` if that package is installed: +- `emit()` serializes the current session into `message.context["session"]` +- `on_message()` calls `Session.from_message()` and `SessionManager.update()` +- `on_default_session_update()` updates the default session when `ovos.session.update_default` is received + +### Key Methods + +| Method | Description | +|---|---| +| `on(msg_type, handler)` | Register a handler for a message type | +| `once(msg_type, handler)` | Register a one-time handler | +| `emit(message)` | Dispatch a message locally | +| `remove(msg_type, handler)` | Unregister a handler | +| `remove_all_listeners(event_name)` | Remove all handlers for a message type | +| `wait_for_message(message_type, timeout)` | Block until a message of that type arrives | +| `wait_for_response(message, reply_type, timeout)` | Emit a message and wait for its response | +| `run_forever()` | No-op (sets `started_running = True`) | +| `run_in_thread()` | Calls `run_forever()` | +| `close()` | Calls `on_close()` | +| `create_client()` | Returns `self` | + +--- + +## `FakeMessage` + +Drop-in replacement for `ovos_bus_client.Message`. Transparently proxies to the real `Message` class if `ovos-bus-client` is installed: + +```python +from ovos_utils.fakebus import FakeMessage + +msg = FakeMessage("skill:action", {"key": "value"}, {"session_id": "abc"}) +``` + +| Attribute | Description | +|---|---| +| `msg_type` | Message type string | +| `data` | Payload dict | +| `context` | Context dict (includes session, source, destination) | + +### Key Methods + +| Method | Description | +|---|---| +| `serialize() → str` | JSON-encode the message | +| `FakeMessage.deserialize(value) → FakeMessage` | Construct from JSON string | +| `forward(msg_type, data)` | Create a message with the same context | +| `reply(msg_type, data, context)` | Create a reply (swaps source ↔ destination) | +| `response(data, context)` | Shorthand for `reply(msg_type + ".response", ...)` | +| `publish(msg_type, data, context)` | Forward without a target | + +### `isinstance` Compatibility + +`FakeMessage` uses a metaclass (`_MutableMessage`) that makes `isinstance(msg, FakeMessage)` return `True` for real `ovos_bus_client.Message` objects as well, so code that checks `isinstance(msg, FakeMessage)` works in both environments. + +--- + +## `Message` (Deprecated) + +`ovos_utils.fakebus.Message` is a deprecated alias for `FakeMessage`. Import from `ovos_bus_client` directly. + +--- + +## `dig_for_message()` + +Tries to import and call `ovos_bus_client.message.dig_for_message`. Returns `None` if `ovos-bus-client` is not installed. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..1df0d780 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ + +# ovos-utils + +Shared utility library used by all OVOS components. Provides logging, process lifecycle management, a testing-friendly fake message bus, event scheduling, file utilities, network checks, audio playback, and XDG path helpers. + +--- + +## Module Overview + +| Module | Description | +|---|---| +| `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.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()` | +| `ovos_utils.sound` | `play_audio()`, `get_sound_duration()` | +| `ovos_utils.thread_utils` | `create_daemon()`, `create_killable_daemon()`, `wait_for_exit_signal()`, `threaded_timeout` | +| `ovos_utils.xdg_utils` | XDG Base Directory helpers (`xdg_config_home()`, `xdg_data_home()`, etc.) | +| `ovos_utils.bracket_expansion` | Dialog template `{option1\|option2}` expansion | +| `ovos_utils.decorators` | `classproperty`, `resting_screen_handler`, `skill_api_method`, etc. | +| `ovos_utils.json_helper` | JSON load/save helpers | +| `ovos_utils.list_utils` | `flatten_list()` and related helpers | +| `ovos_utils.parse` | Utterance parsing helpers | +| `ovos_utils.dialog` | Dialog file loading | +| `ovos_utils.lang/` | Language normalization utilities | +| `ovos_utils.ssml` | SSML tag helpers | +| `ovos_utils.time` | Timezone-aware `now_local()`, `get_config_tz()` | +| `ovos_utils.system` | `system_info()` and related helpers | +| `ovos_utils.gui` | GUI interface utilities | +| `ovos_utils.skills` | Skill ID helpers | +| `ovos_utils.ocp` | OCP (media player) helpers | +| `ovos_utils.signal` | IPC signal file helpers | +| `ovos_utils.smtp_utils` | Email-sending utilities | + +--- + +## Installation + +```bash +pip install ovos-utils +``` + +`ovos-utils` is a dependency of `ovos-bus-client`, `ovos-config`, `ovos-workshop`, and virtually every other OVOS package. Most projects get it transitively. + +--- + +## Environment Variables + +| Variable | Used by | Description | +|---|---|---| +| `OVOS_DEFAULT_LOG_NAME` | `LOG` | Default logger name (default: `OVOS`) | +| `OVOS_DEFAULT_LOG_LEVEL` | `LOG` | Default log level (default: `INFO`) | +| `OVOS_CONFIG_BASE_FOLDER` | `LOG`, `PIDLock` | XDG base folder name (default: `mycroft`) | + +--- + +## Further Reading + +- [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 +- [Events](events.md) — `EventContainer`, `EventSchedulerInterface`, handler wrappers +- [Utilities](utilities.md) — file, network, sound, threading, XDG helpers diff --git a/docs/log.md b/docs/log.md new file mode 100644 index 00000000..541b81e8 --- /dev/null +++ b/docs/log.md @@ -0,0 +1,143 @@ +# Logging + +**Module:** `ovos_utils.log` + +--- + +## `LOG` + +A class-based logger that acts as a drop-in replacement for `logging.Logger`. All methods are `@classmethod`, so it can be used without instantiation. + +```python +from ovos_utils.log import LOG + +LOG.debug("Debug message: %s", value) +LOG.info("Started") +LOG.warning("Watch out") +LOG.error("Something failed") +LOG.exception("Unhandled exception") +``` + +### Logger Name + +The logger name is determined by inspection of the call stack — it includes the module, function, and line number of the caller, plus `LOG.name` as a prefix. This makes OVOS log lines self-identifying without manually passing a logger name. + +``` +2024-01-01 12:00:00.000 - OVOS - ovos_core.skills.skill_manager:load:123 - INFO - Loading skill +``` + +Set a custom prefix: + +```python +LOG("my-service").info("Ready") +``` + +Or set the class-level name: + +```python +LOG.name = "audio" +``` + +### Configuration + +`LOG` reads from `mycroft.conf["logging"]`: + +```json +{ + "logging": { + "log_level": "DEBUG", + "logs": { + "path": "/opt/ovos/logs/", + "max_bytes": 50000000, + "backup_count": 6 + }, + "audio": { + "log_level": "INFO", + "logs": { + "path": "/var/log/ovos/" + } + } + } +} +``` + +Service-specific sections (e.g. `logging.audio`) override the global defaults for that service. + +### Class Attributes + +| Attribute | Default | Description | +|---|---|---| +| `name` | `$OVOS_DEFAULT_LOG_NAME` or `OVOS` | Logger name prefix | +| `level` | `$OVOS_DEFAULT_LOG_LEVEL` or `INFO` | Log level | +| `base_path` | `stdout` | Log directory (or `"stdout"` for console only) | +| `max_bytes` | `50_000_000` | Max log file size before rotation | +| `backup_count` | `3` | Number of rotated log files to keep | +| `diagnostic_mode` | `False` | If True, log the source bus message for each log call | + +### `LOG.init(config)` + +Apply configuration from a dict (as returned by `get_logs_config()`). Updates `base_path`, `max_bytes`, `backup_count`, `level`, and `diagnostic_mode`. + +### `LOG.set_level(level)` + +Update the log level for the class and all existing loggers. + +--- + +## `init_service_logger(service_name)` + +Initialize `LOG` for a named OVOS service. Sets `LOG.name`, calls `LOG.init()`, and registers a config watcher to reload the log level when `mycroft.conf` changes. + +```python +from ovos_utils.log import init_service_logger + +init_service_logger("audio") +``` + +Call this once at service startup. Afterwards, `LOG.info(...)` etc. will tag log lines with the service name and write to the configured log file. + +--- + +## `get_logs_config(service_name, _cfg) → dict` + +Resolve the logging configuration for a given service name by walking the `mycroft.conf["logging"]` hierarchy. Returns a dict with at least `{"level": "INFO"}`. + +--- + +## `log_deprecation(log_message, deprecation_version, ...)` + +Log a deprecation warning that identifies the external caller (not the deprecation site itself). + +```python +from ovos_utils.log import log_deprecation + +log_deprecation("Use new_method() instead", "2.0.0") +``` + +--- + +## `@deprecated(log_message, deprecation_version)` + +Decorator that logs a deprecation warning on every call: + +```python +from ovos_utils.log import deprecated + +@deprecated("Use new_method() instead", "2.0.0") +def old_method(): + ... +``` + +--- + +## `get_log_path(service, directories) → Optional[str]` + +Return the log directory path for a given service, as configured in `mycroft.conf`. If `directories` is provided, search that list instead of reading config. + +## `get_log_paths(config) → Set[str]` + +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. diff --git a/docs/process-utils.md b/docs/process-utils.md new file mode 100644 index 00000000..4dba1ef1 --- /dev/null +++ b/docs/process-utils.md @@ -0,0 +1,138 @@ +# Process Utilities + +**Module:** `ovos_utils.process_utils` + +--- + +## `RuntimeRequirements` + +Dataclass that declares what external resources a skill or plugin requires before it can be loaded and while it handles utterances. + +```python +from ovos_utils.process_utils import RuntimeRequirements + +class MySkill: + runtime_requirements = RuntimeRequirements( + network_before_load=False, + internet_before_load=False, + requires_internet=False, + requires_network=False, + no_network_fallback=True, + ) +``` + +| Field | Default | Description | +|---|---|---| +| `network_before_load` | `True` | Wait for network before loading the skill | +| `internet_before_load` | `True` | Wait for internet before loading the skill | +| `gui_before_load` | `False` | Wait for GUI before loading the skill | +| `requires_internet` | `True` | Internet needed to handle utterances | +| `requires_network` | `True` | Network needed to handle utterances | +| `requires_gui` | `False` | GUI needed to handle utterances | +| `no_internet_fallback` | `False` | Has a cached/offline fallback mode | +| `no_network_fallback` | `False` | Has a cached/offline fallback mode | +| `no_gui_fallback` | `True` | Can work voice-only without GUI | + +The default values (`True` for `network_before_load` / `internet_before_load`) preserve backwards compatibility with older skills that assumed network availability at load time. + +--- + +## `ProcessState` + +`IntEnum` representing OVOS service lifecycle stages. Ordered so that `>= ProcessState.ALIVE` works as a range check: + +| Value | Name | Description | +|---|---|---| +| 0 | `NOT_STARTED` | Process not yet started | +| 1 | `STARTED` | Process started (basic init done) | +| 2 | `ERROR` | Non-recoverable error | +| 3 | `STOPPING` | Shutdown in progress | +| 4 | `ALIVE` | Core setup complete | +| 5 | `READY` | Fully loaded and ready to serve | + +--- + +## `ProcessStatus` + +Tracks the lifecycle state of an OVOS service. Registers message bus handlers for status queries and fires optional callbacks on state transitions. + +```python +from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap + +def on_ready(): + LOG.info("Service ready!") + +status = ProcessStatus( + name="audio", + bus=bus, + callback_map=StatusCallbackMap(on_ready=on_ready), + namespace="mycroft", +) + +status.set_started() +status.set_alive() +status.set_ready() # fires on_ready() +``` + +### Bus Events Registered + +| Event | Response | +|---|---| +| `{namespace}.{name}.is_alive` | `{"status": bool}` — True if state ≥ ALIVE | +| `{namespace}.{name}.is_ready` | `{"status": bool}` — True if state ≥ READY | +| `mycroft.{name}.all_loaded` | Same as `is_ready` (backwards compat) | + +### State Transition Methods + +| Method | Sets state to | Fires callback | +|---|---|---| +| `set_started()` | `STARTED` | `on_started` | +| `set_alive()` | `ALIVE` | `on_alive` | +| `set_ready()` | `READY` | `on_ready` | +| `set_stopping()` | `STOPPING` | `on_stopping` | +| `set_error(err)` | `ERROR` | `on_error(err)` | + +### `StatusCallbackMap` + +Named tuple with optional fields: `on_started`, `on_alive`, `on_ready`, `on_error`, `on_stopping`. All default to `None`. + +--- + +## `MonotonicEvent` + +A `threading.Event` subclass with a timeout implementation based on `time.monotonic` to avoid being affected by system clock changes. + +```python +from ovos_utils.process_utils import MonotonicEvent + +event = MonotonicEvent() +result = event.wait(timeout=5.0) # monotonic-safe timeout +``` + +`wait_timeout(timeout)` polls in 0.1-second increments until the event is set or the monotonic deadline passes. + +--- + +## `PIDLock` + +Creates and maintains a PID file in the system temp directory. On construction, kills any existing process with the same service name, then writes the current PID. + +```python +from ovos_utils.process_utils import PIDLock + +lock = PIDLock("skills") # creates /tmp/mycroft/skills.pid +``` + +Registers `SIGINT` / `SIGTERM` handlers to delete the PID file on exit. The directory is resolved from `ovos_config` if available, otherwise from `OVOS_CONFIG_BASE_FOLDER` env var (default: `mycroft`). + +--- + +## `Signal` + +Chainable POSIX signal handler. Each instance installs a user function as the new handler and calls the previous handler in LIFO order. Restored on garbage collection. + +--- + +## `reset_sigint_handler()` + +Reset `SIGINT` to the default Python handler. Needed when starting OVOS services from shell scripts that have modified the signal mask. diff --git a/docs/utilities.md b/docs/utilities.md new file mode 100644 index 00000000..e5d22cc9 --- /dev/null +++ b/docs/utilities.md @@ -0,0 +1,203 @@ +# Utilities + +Reference for the remaining utility modules in `ovos_utils`. + +--- + +## File Utilities (`ovos_utils.file_utils`) + +### Path and Directory Helpers + +#### `get_temp_path(*args) → str` + +Return a path inside the system temp directory without creating it: + +```python +from ovos_utils.file_utils import get_temp_path + +path = get_temp_path("mycroft", "audio", "example.wav") +# → "/tmp/mycroft/audio/example.wav" +``` + +#### `get_cache_directory(folder) → str` + +Return a cache directory path, preferring RAM (`/dev/shm`-based via `memory_tempfile`) on Linux. Creates the directory. + +#### `ensure_directory_exists(directory, domain=None) → str` + +Create `directory` (and optional `domain` subdirectory) with `0o777` permissions. + +#### `to_alnum(skill_id) → str` + +Convert a skill ID to alphanumeric characters only (non-alphanumeric → `_`). + +### Resource Resolution + +#### `resolve_ovos_resource_file(res_name, extra_res_dirs) → Optional[str]` + +Locate a bundled resource file by searching in order: +1. Absolute path (if already absolute) +2. `extra_res_dirs` +3. `ovos_utils/res/` +4. `ovos_workshop/res/` +5. `ovos_gui/res/` +6. `mycroft/res/` (legacy) + +#### `resolve_resource_file(res_name, root_path, config) → Optional[str]` + +Locate a resource file by searching: +1. Absolute path +2. `~/.mycroft/` +3. `/` (from config) +4. `resolve_ovos_resource_file()` + +### Vocab / Regex Loading + +| Function | Description | +|---|---| +| `read_vocab_file(path) → List[List[str]]` | Read a `.voc` file, expanding `{alt1\|alt2}` templates | +| `load_regex_from_file(path, skill_id) → List[str]` | Load regexes from a `.rx` file, munging skill ID | +| `load_vocabulary(basedir, skill_id) → dict` | Load all `.voc` files from a directory tree | +| `load_regex(basedir, skill_id) → List[str]` | Load all `.rx` files from a directory | +| `read_value_file(filename, delim) → OrderedDict` | Read a 2-column CSV as key-value pairs | +| `read_translated_file(filename, data) → List[str]` | Read a file with `{key}` substitutions | + +### `FileWatcher` + +Watch files or directories for changes using `watchdog`: + +```python +from ovos_utils.file_utils import FileWatcher + +watcher = FileWatcher( + files=["~/.config/mycroft/mycroft.conf"], + callback=lambda path: print(f"Changed: {path}"), + recursive=False, + ignore_creation=True, +) + +# Later: +watcher.shutdown() +``` + +Fires the callback with the modified file path when a file is created or modified (closed after write). Optional `ignore_creation=True` skips creation events. + +--- + +## Network Utilities (`ovos_utils.network_utils`) + +```python +from ovos_utils.network_utils import get_ip, is_connected_dns, is_connected_http +``` + +| Function | Description | +|---|---| +| `is_valid_ip(ip) → bool` | Validate an IPv4 or IPv6 address string | +| `get_ip() → str` | Local IPv4 address (without sending packets) | +| `get_external_ip() → str` | Public IPv4 address via HTTP | +| `is_connected_dns(host, port, timeout) → bool` | Check connectivity by TCP-connecting to a DNS server (default: Cloudflare/Google) | +| `is_connected_http(host) → bool` | Check connectivity via HTTP HEAD request | +| `check_captive_portal(host, expected_text) → bool` | Detect captive portals by comparing HTTP response body | + +Default test URLs/IPs are read from `mycroft.conf["network_tests"]`. Built-in defaults: + +```python +{ + "ip_url": "https://api.ipify.org", + "dns_primary": "1.1.1.1", + "dns_secondary": "8.8.8.8", + "web_url": "http://nmcheck.gnome.org/check_network_status.txt", + "web_url_secondary": "https://checkonline.home-assistant.io/online.txt", + "captive_portal_url": "http://nmcheck.gnome.org/check_network_status.txt", + "captive_portal_text": "NetworkManager is online" +} +``` + +> `is_connected()` is deprecated — use `is_connected_http()` or `is_connected_dns()` directly. + +--- + +## Sound (`ovos_utils.sound`) + +```python +from ovos_utils.sound import play_audio, get_sound_duration +``` + +### `play_audio(uri, play_cmd=None, environment=None) → Optional[subprocess.Popen]` + +Play an audio file in a background subprocess. Returns the `Popen` object on success, `None` on failure. + +Player selection (if `play_cmd` not specified): +1. Check `mycroft.conf` for `play_ogg_cmdline` / `play_wav_cmdline` / `play_mp3_cmdline` +2. Auto-detect: `sox play` → `ogg123` → `pw-play` → `paplay` / `aplay` → `mpg123` + +Supports PulseAudio ducking: if `mycroft.conf["tts"]["pulse_duck"]` is `True`, sets `PULSE_PROP=media.role=phone` in the subprocess environment. + +### `get_sound_duration(path, base_dir) → float` + +Return sound duration in seconds. Supports WAV natively (via `wave` stdlib). For other formats, requires `ffprobe` or `mediainfo` on `PATH`. + +--- + +## Thread Utilities (`ovos_utils.thread_utils`) + +```python +from ovos_utils.thread_utils import create_daemon, wait_for_exit_signal +``` + +### `create_daemon(target, args, kwargs, autostart) → Thread` + +Create and optionally start a daemon `threading.Thread`. + +### `create_killable_daemon(target, args, kwargs, autostart) → kthread.KThread` + +Create and optionally start a killable daemon thread (`kthread.KThread`). Supports forceful termination. + +### `wait_for_exit_signal()` + +Block the calling thread until `SIGTERM` or `SIGINT` is received. Used by OVOS service entry points to keep the main thread alive: + +```python +from ovos_utils.thread_utils import wait_for_exit_signal + +# Start services in daemon threads, then block: +wait_for_exit_signal() +``` + +### `@threaded_timeout(timeout)` + +Decorator that runs the decorated function in a background thread with a timeout. Raises `Exception` if the timeout is exceeded: + +```python +from ovos_utils.thread_utils import threaded_timeout + +@threaded_timeout(timeout=10) +def slow_operation(): + ... +``` + +--- + +## XDG Utilities (`ovos_utils.xdg_utils`) + +XDG Base Directory Specification helpers. All functions return `Path` objects (or `None` for `xdg_runtime_dir()`). + +```python +from ovos_utils.xdg_utils import xdg_config_home, xdg_data_home, xdg_cache_home + +config_dir = xdg_config_home() / "mycroft" # ~/.config/mycroft +data_dir = xdg_data_home() / "mycroft" # ~/.local/share/mycroft +cache_dir = xdg_cache_home() / "mycroft" # ~/.cache/mycroft +``` + +| Function | Env var | Default | +|---|---|---| +| `xdg_config_home()` | `XDG_CONFIG_HOME` | `~/.config` | +| `xdg_data_home()` | `XDG_DATA_HOME` | `~/.local/share` | +| `xdg_cache_home()` | `XDG_CACHE_HOME` | `~/.cache` | +| `xdg_state_home()` | `XDG_STATE_HOME` | `~/.local/state` | +| `xdg_runtime_dir()` | `XDG_RUNTIME_DIR` | `None` | +| `xdg_config_dirs()` | `XDG_CONFIG_DIRS` | `[/etc/xdg]` | +| `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. diff --git a/ovos_utils/device_input.py b/ovos_utils/device_input.py index 6ab4bae5..9c7a008a 100644 --- a/ovos_utils/device_input.py +++ b/ovos_utils/device_input.py @@ -71,7 +71,7 @@ def _get_libinput_devices_list(self): if find_executable("libinput"): try: self._build_linput_devices_list() - except: + except Exception: self.libinput_devices_list.clear() LOG.exception("Failed to query libinput for devices") return self.libinput_devices_list @@ -101,7 +101,7 @@ def _get_xinput_devices_list(self): if find_executable("xinput"): try: self._build_xinput_devices_list() - except: + except Exception: self.xinput_devices_list.clear() LOG.exception("Failed to query xinput for devices") return self.xinput_devices_list diff --git a/ovos_utils/fakebus.py b/ovos_utils/fakebus.py index f2891af3..c0287274 100644 --- a/ovos_utils/fakebus.py +++ b/ovos_utils/fakebus.py @@ -24,7 +24,7 @@ def __init__(self, *args, **kwargs): self.on_open() try: self.session_id = kwargs["session"].session_id - except: + except Exception: pass # don't care self.on("ovos.session.update_default", @@ -137,7 +137,7 @@ def rcv(m): def remove(self, msg_type, handler): try: self.ee.remove_listener(msg_type, handler) - except: + except Exception: pass def remove_all_listeners(self, event_name): @@ -205,7 +205,7 @@ def __eq__(self, other): return other.msg_type == self.msg_type and \ other.data == self.data and \ other.context == self.context - except: + except Exception: return False def serialize(self): diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py index 6bc79f30..55bb8fa8 100644 --- a/ovos_utils/file_utils.py +++ b/ovos_utils/file_utils.py @@ -413,7 +413,7 @@ def __init__(self, file_path: str, callback: callable, self._events = ('modified') else: self._events = ('created', 'modified') - self._changed_files = [] + self._changed_files = set() self._lock = RLock() def on_any_event(self, event): @@ -422,17 +422,17 @@ def on_any_event(self, event): with self._lock: if event.event_type == "closed": if event.src_path in self._changed_files: - self._changed_files.remove(event.src_path) + self._changed_files.discard(event.src_path) # fire event, it is now safe try: self._callback(event.src_path) - except: + except Exception: LOG.exception("An error occurred handling file " "change event callback") elif event.event_type in self._events: if event.src_path not in self._changed_files: - self._changed_files.append(event.src_path) + self._changed_files.add(event.src_path) except ImportError: LOG.error("Failed to import watchdog module. FileWatcher will not be available. " diff --git a/ovos_utils/geolocation.py b/ovos_utils/geolocation.py index 27336a60..b9b20494 100644 --- a/ovos_utils/geolocation.py +++ b/ovos_utils/geolocation.py @@ -84,7 +84,7 @@ def get_geolocation(location: str, lang: str = "en", timeout: int = 5) -> Dict[s lat = data.get("lat") lon = data.get("lon") - if lat and lon: + if lat is not None and lon is not None: return get_reverse_geolocation(lat, lon, lang) url = "https://nominatim.openstreetmap.org/details.php" diff --git a/ovos_utils/lang/__init__.py b/ovos_utils/lang/__init__.py index 41659016..25c63471 100644 --- a/ovos_utils/lang/__init__.py +++ b/ovos_utils/lang/__init__.py @@ -10,11 +10,11 @@ def standardize_lang_tag(lang_code: str, macro=True) -> str: try: from langcodes import standardize_tag as std return str(std(lang_code, macro=macro)) - except: + except Exception: if macro: return lang_code.split("-")[0].lower() if "-" in lang_code: - a, b = lang_code.split("-", 2) + a, b = lang_code.split("-", 1) return f"{a.lower()}-{b.upper()}" return lang_code.lower() @@ -29,7 +29,7 @@ def get_language_dir(base_path: str, lang: str ="en-US") -> Optional[str]: try: from langcodes import tag_distance score = tag_distance(lang, f) - except: # not a valid language code + 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. diff --git a/ovos_utils/log.py b/ovos_utils/log.py index 4ce6c585..f5941cf6 100644 --- a/ovos_utils/log.py +++ b/ovos_utils/log.py @@ -401,5 +401,5 @@ def get_available_logs(directories: Optional[List[str]] = None) -> List[str]: list of log file basenames (i.e. "audio", "skills") """ directories = directories or get_log_paths() - return [Path(f).stem for path in directories + return [Path(f).stem for path in directories if os.path.isdir(path) for f in os.listdir(path) if Path(f).suffix == ".log"] diff --git a/ovos_utils/log_parser.py b/ovos_utils/log_parser.py index f7de2f5d..562ba356 100644 --- a/ovos_utils/log_parser.py +++ b/ovos_utils/log_parser.py @@ -141,9 +141,9 @@ class OVOSLogParser: LOG_PATTERN = r'(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{1,6}) - (?P.+?) - (?P.+?) - (?P\w+) - (?P.*)' @classmethod - def parse(self, log_line, last_timestamp=None) -> LogLine: - log_line.rstrip("\n") - match = re.match(self.LOG_PATTERN, log_line) + def parse(cls, log_line, last_timestamp=None) -> LogLine: + log_line = log_line.rstrip("\n") + match = re.match(cls.LOG_PATTERN, log_line) data = {} if match: data = match.groupdict() @@ -155,7 +155,7 @@ def parse(self, log_line, last_timestamp=None) -> LogLine: return LogLine(**data) @classmethod - def parse_file(self, source) -> Generator[Union[LogLine, Traceback], None, None]: + def parse_file(cls, source) -> Generator[Union[LogLine, Traceback], None, None]: if not os.path.exists(source): raise FileNotFoundError(f"File {source} does not exist") @@ -177,13 +177,18 @@ def parse_file(self, source) -> Generator[Union[LogLine, Traceback], None, None] elif trace: trace.append(line) else: - log = self.parse(line, last_timestamp) - if log.message == "\n": + log = cls.parse(line, last_timestamp) + if not log.message.strip(): continue timestamp = log.timestamp if timestamp: last_timestamp = timestamp yield log + # Flush any traceback that ends at EOF without a trailing blank line + if trace: + traceback = Traceback.from_list(trace) + traceback.timestamp = last_timestamp + yield traceback console = Console() @@ -383,9 +388,9 @@ def slice(start, until, logs, paths, file): if file is not None: # test if file is writable try: - with open(file, 'w') as f: + with open(file, 'a') as f: pass - except: + except Exception: return console.print(f"File [{file}] is not writable. Aborted") else: console.print(f"Log slice saved to [bold]{file}[/bold]") @@ -521,9 +526,9 @@ def list(error, warning, exception, debug, start, until, logs, paths, file): if file is not None: # test if file is writable try: - with open(file, 'w') as f: + with open(file, 'a') as f: pass - except: + except Exception: return console.print(f"File [{file}] is not writable. Aborted") else: console.print(f"Log list saved to [bold]{file}[/bold]") diff --git a/ovos_utils/network_utils.py b/ovos_utils/network_utils.py index f712d3ed..6ad1ecbd 100644 --- a/ovos_utils/network_utils.py +++ b/ovos_utils/network_utils.py @@ -91,8 +91,8 @@ def is_connected_dns(host: Optional[str] = None, port: int = 53, if host is None: cfg = get_network_tests_config() - 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'])) + 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']) try: # connect to the host -- tells us if the host is actually reachable @@ -121,7 +121,7 @@ def is_connected_http(host: Optional[str] = None) -> bool: try: status = requests.head(host).status_code return True - except: + except Exception: pass return False diff --git a/ovos_utils/oauth.py b/ovos_utils/oauth.py index 21d10cb5..ddb4c9c8 100644 --- a/ovos_utils/oauth.py +++ b/ovos_utils/oauth.py @@ -117,7 +117,7 @@ def refresh_oauth_token(token_id): new_token_data = refresh_result.json() # Make sure 'expires_at' entry exists in token if 'expires_at' not in new_token_data: - new_token_data['expires_at'] = time.time() + token_data['expires_in'] + new_token_data['expires_at'] = time.time() + new_token_data['expires_in'] # Store token with OAuthTokenDatabase() as db: token_data.update(new_token_data) @@ -141,9 +141,9 @@ def get_oauth_token(token_id, auto_refresh=True): expired = False with OAuthTokenDatabase() as db: token_data = db.get(token_id) - if "expires_at" not in token_data: + if token_data is None or "expires_at" not in token_data: expired = True - elif token_data["expires_at"] >= time.time(): + elif token_data["expires_at"] <= time.time(): expired = True if expired: return refresh_oauth_token(token_id) diff --git a/ovos_utils/ocp.py b/ovos_utils/ocp.py index 22cec8dd..3e88448f 100644 --- a/ovos_utils/ocp.py +++ b/ovos_utils/ocp.py @@ -8,6 +8,23 @@ from ovos_utils.log import LOG, deprecated +__all__ = [ + "OCP_ID", + "MatchConfidence", + "TrackState", + "MediaState", + "PlayerState", + "LoopState", + "PlaybackType", + "PlaybackMode", + "MediaType", + "MediaEntry", + "PluginStream", + "Playlist", + "available_extractors", + "dict2entry" +] + OCP_ID = "ovos.common_play" @@ -105,7 +122,7 @@ class MediaType(IntEnum): MUSIC = 2 VIDEO = 3 # eg, youtube videos AUDIOBOOK = 4 - GAME = 5 # because it shares the verb "play", mostly for disambguation + GAME = 5 # because it shares the verb "play", mostly for disambiguation PODCAST = 6 RADIO = 7 # live radio NEWS = 8 # news reports @@ -499,10 +516,10 @@ def add_entry(self, entry: Union[MediaEntry, PluginStream], index: int = -1) -> if index == -1: index = len(self) - if index < self.position: - self.set_position(self.position + 1) - + had_current_track = len(self) > 0 self.insert(index, entry) + if had_current_track and index <= self.position: + self.set_position(self.position + 1) def remove_entry(self, entry: Union[int, dict, MediaEntry, PluginStream]) -> None: """ diff --git a/ovos_utils/security.py b/ovos_utils/security.py index 9b78bebf..b9b74312 100644 --- a/ovos_utils/security.py +++ b/ovos_utils/security.py @@ -61,10 +61,10 @@ def create_self_signed_cert(cert_dir, name="jarbas"): cert.sign(k, 'sha1') if not exists(cert_dir): makedirs(cert_dir) - open(cert_path, "wt").write( - crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) - open(join(cert_dir, KEY_FILE), "wt").write( - crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) + with open(cert_path, "wb") as f: + f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + with open(join(cert_dir, KEY_FILE), "wb") as f: + f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) return cert_path, key_path diff --git a/ovos_utils/signal.py b/ovos_utils/signal.py deleted file mode 100644 index ddecaa6d..00000000 --- a/ovos_utils/signal.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -import os.path -import tempfile -import time -import warnings -from ovos_utils.log import LOG, log_deprecation, deprecated - -warnings.warn( - "ovos_utils.signal module has been deprecated without replacement`", - DeprecationWarning, - stacklevel=2, -) - - -@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") -def get_ipc_directory(domain=None, config=None): - """Get the directory used for Inter Process Communication - - Files in this folder can be accessed by different processes on the - machine. Useful for communication. This is often a small RAM disk. - - Args: - domain (str): The IPC domain. Basically a subdirectory to prevent - overlapping signal filenames. - config (dict): mycroft.conf, to read ipc directory from - - Returns: - str: a path to the IPC directory - """ - if config is None: - log_deprecation(f"Expected a dict config and got None.", "0.1.0") - try: - from ovos_config.config import Configuration - config = Configuration() - except ImportError: - LOG.warning("Config not provided and ovos_config not available") - config = dict() - path = config.get("ipc_path") - if not path: - # If not defined, use /tmp/mycroft/ipc - path = os.path.join(tempfile.gettempdir(), "mycroft", "ipc") - return ensure_directory_exists(path, domain) - - -@deprecated("use 'from ovos_utils.file_utils import ensure_directory_exists' instead", "0.2.0") -def ensure_directory_exists(directory, domain=None): - """ Create a directory and give access rights to all - - Args: - domain (str): The IPC domain. Basically a subdirectory to prevent - overlapping signal filenames. - - Returns: - str: a path to the directory - """ - from ovos_utils.file_utils import ensure_directory_exists as _ede - return _ede(directory, domain) - - -@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") -def create_file(filename): - """ Create the file filename and create any directories needed - - Args: - filename: Path to the file to be created - """ - try: - os.makedirs(os.path.dirname(filename)) - except OSError: - pass - with open(filename, 'w') as f: - f.write('') - - -@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") -def create_signal(signal_name, config=None): - """Create a named signal - - Args: - signal_name (str): The signal's name. Must only contain characters - valid in filenames. - config (dict): mycroft.conf, to read ipc directory from - """ - try: - path = os.path.join(get_ipc_directory(config=config), - "signal", signal_name) - create_file(path) - return os.path.isfile(path) - except IOError: - return False - - -@deprecated("ovos_utils.signal module has been deprecated!", "0.2.0") -def check_for_signal(signal_name, sec_lifetime=0, config=None): - """See if a named signal exists - - Args: - signal_name (str): The signal's name. Must only contain characters - valid in filenames. - sec_lifetime (int, optional): How many seconds the signal should - remain valid. If 0 or not specified, it is a single-use signal. - If -1, it never expires. - config (dict): mycroft.conf, to read ipc directory from - - Returns: - bool: True if the signal is defined, False otherwise - """ - path = os.path.join(get_ipc_directory(config=config), - "signal", signal_name) - if os.path.isfile(path): - if sec_lifetime == 0: - # consume this single-use signal - os.remove(path) - elif sec_lifetime == -1: - return True - elif int(os.path.getctime(path) + sec_lifetime) < int(time.time()): - # remove once expired - os.remove(path) - return False - return True - - # No such signal exists - return False diff --git a/ovos_utils/skill_installer.py b/ovos_utils/skill_installer.py new file mode 100644 index 00000000..24a6dc74 --- /dev/null +++ b/ovos_utils/skill_installer.py @@ -0,0 +1,458 @@ +# Copyright 2025 OpenVoiceOS +# +# 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. +"""Service-level pip installer. + +Provides :class:`ServiceInstaller`, a lightweight class that listens on the +MessageBus for pip install/uninstall requests and executes them in the calling +process's Python environment. + +Each OVOS core service (ovos-core, ovos-audio, ovos-gui, ovos-messagebus) runs +in its own process — and potentially its own container. This module makes it +possible to install or update Python packages in *each* service independently +by targeting messages at a specific service name. + +Message protocol +---------------- +Broadcast (all services respond):: + + ovos.pip.install data: {"packages": ["pkg-name"]} + ovos.pip.uninstall data: {"packages": ["pkg-name"]} + +Targeted (only the named service responds):: + + ovos.pip.install. data: {"packages": ["pkg-name"]} + ovos.pip.uninstall. data: {"packages": ["pkg-name"]} + +Response messages emitted by each handler:: + + ovos.pip.install.complete + ovos.pip.install.failed data: {"error": } + ovos.pip.uninstall.complete + ovos.pip.uninstall.failed data: {"error": } + +GGWave opcodes (audio-QR) +------------------------- +``PIP:`` → broadcast install (all services) +``RMPIP:`` → broadcast uninstall (all services) +``SPIP::`` → targeted install to one service +``RMSPIP::`` → targeted uninstall from one service +""" +import enum +import shutil +import sys +from os.path import exists +from subprocess import Popen, PIPE +from typing import List, Optional + +import requests +from combo_lock import NamedLock +from ovos_bus_client import Message +from ovos_config.config import Configuration +from ovos_utils.log import LOG + +try: + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name +except ImportError: + Requirement = None + canonicalize_name = None + + +class InstallError(str, enum.Enum): + """Error codes returned in ``ovos.pip.install.failed`` messages.""" + + DISABLED = "pip disabled in mycroft.conf" + PIP_ERROR = "error in pip subprocess" + BAD_URL = "skill url validation failed" + NO_PKGS = "no packages to install" + + +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. + + Args: + bus: Connected ``MessageBusClient`` (or compatible FakeBus). + service_name: Logical name of the owning service, used to construct + the targeted message topic. Should match the container/process + name without spaces, e.g. ``"ovos_audio"``, ``"ovos_gui"``, + ``"ovos_messagebus"``, ``"ovos_core"``. + config: Optional installer configuration dict. Falls back to the + ``skills.installer`` section of ``mycroft.conf`` when omitted. + """ + + # Default constraints URL — same as SkillsStore so all services share + # the same protection list. + DEFAULT_CONSTRAINTS: str = ( + "https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases" + "/refs/heads/main/constraints-stable.txt" + ) + PIP_LOCK: NamedLock = NamedLock("ovos_pip.lock") + UV: Optional[str] = shutil.which("uv") # prefer uv when available + + @staticmethod + def _get_canonical_name(pkg: str) -> str: + """Robustly extract and canonicalize package name from a requirement.""" + if Requirement and canonicalize_name: + try: + return str(canonicalize_name(Requirement(pkg).name)) + except Exception: + pass + # Fallback for when packaging is not installed or parsing fails + return pkg.split("~")[0].split("<")[0].split(">")[0].split("=")[0] \ + .split("[")[0].split("!")[0].split(";")[0].strip() \ + .replace("_", "-").lower() + + def __init__( + self, + bus, + service_name: str, + config: Optional[dict] = None, + ) -> None: + self.service_name: str = service_name + self.config: dict = config or Configuration().get("skills", {}).get( + "installer", {} + ) + self.bus = bus + + # Broadcast topics — every service with an installer will respond. + 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. + self.bus.on( + f"ovos.pip.install.{self.service_name}", + self.handle_install_python, + ) + self.bus.on( + f"ovos.pip.uninstall.{self.service_name}", + self.handle_uninstall_python, + ) + + LOG.info( + f"ServiceInstaller registered for service '{self.service_name}'" + ) + + def shutdown(self) -> None: + """Unregister all message bus event handlers.""" + self.bus.remove("ovos.pip.install", self.handle_install_python) + 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.bus.remove( + f"ovos.pip.uninstall.{self.service_name}", + self.handle_uninstall_python, + ) + + # ------------------------------------------------------------------ + # Audio feedback helpers + # ------------------------------------------------------------------ + + def play_error_sound(self) -> None: + """Emit a message to play the configured error sound.""" + snd = self.config.get("sounds", {}).get("pip_error", "snd/error.mp3") + self.bus.emit(Message("mycroft.audio.play_sound", {"uri": snd})) + + def play_success_sound(self) -> None: + """Emit a message to play the configured success sound.""" + snd = self.config.get("sounds", {}).get( + "pip_success", "snd/acknowledge.mp3" + ) + self.bus.emit(Message("mycroft.audio.play_sound", {"uri": snd})) + + # ------------------------------------------------------------------ + # Constraints validation + # ------------------------------------------------------------------ + + @staticmethod + def validate_constraints(constraints: str) -> bool: + """Return *True* if the constraints file/URL is accessible. + + Args: + constraints: Local file path or HTTP URL to a pip constraints file. + """ + if constraints.startswith("http"): + LOG.debug(f"Constraints url: {constraints}") + try: + response = requests.head(constraints) + if response.status_code != 200: + LOG.error( + f"Remote constraints file not accessible: {response.status_code}" + ) + return False + return True + except Exception as e: + LOG.error(f"Error accessing remote constraints: {e}") + return False + + if not exists(constraints): + LOG.error("Couldn't find the constraints file") + return False + return True + + # ------------------------------------------------------------------ + # Core pip operations + # ------------------------------------------------------------------ + + def pip_install( + self, + packages: List[str], + constraints: Optional[str] = None, + print_logs: bool = True, + ) -> bool: + """Install Python packages via pip or uv. + + Args: + packages: Package specifiers to install. + constraints: Optional constraints file path or URL. + print_logs: Whether to print pip output to stdout. + + Returns: + ``True`` if every package was installed successfully. + """ + if not packages: + LOG.error("no package list provided to install") + self.play_error_sound() + return False + + constraints = constraints or self.config.get( + "constraints", self.DEFAULT_CONSTRAINTS + ) + + if not self.validate_constraints(constraints): + self.play_error_sound() + return False + + if self.UV is not None: + pip_args = [self.UV, "pip", "install"] + else: + pip_args = [sys.executable, "-m", "pip", "install"] + + if constraints: + pip_args += ["-c", constraints] + if self.config.get("break_system_packages", False): + pip_args += ["--break-system-packages"] + if self.config.get("allow_alphas", False): + pip_args += ["--pre"] + + with self.PIP_LOCK: + for package in packages: + LOG.info(f"[{self.service_name}] (pip) Installing {package}") + pip_command = pip_args + [package] + LOG.debug(" ".join(pip_command)) + proc = ( + Popen(pip_command) + if print_logs + else Popen(pip_command, stdout=PIPE, stderr=PIPE) + ) + if proc.wait() != 0: + if proc.stderr: + stderr = proc.stderr.read().decode() + else: + stderr = f"pip install failed for {package} (exit code {proc.returncode})" + self.play_error_sound() + raise RuntimeError(stderr) + + self._on_install_complete() + self.play_success_sound() + return True + + def pip_uninstall( + self, + packages: List[str], + constraints: Optional[str] = None, + print_logs: bool = True, + ) -> bool: + """Uninstall Python packages via pip or uv. + + Protected packages listed in the constraints file cannot be removed. + + Args: + packages: Package names to uninstall. + constraints: Optional constraints file path or URL. + print_logs: Whether to print pip output to stdout. + + Returns: + ``True`` if every package was uninstalled successfully. + """ + if not packages: + LOG.error("no package list provided to uninstall") + self.play_error_sound() + return False + + constraints = constraints or self.config.get( + "constraints", self.DEFAULT_CONSTRAINTS + ) + + if not self.validate_constraints(constraints): + self.play_error_sound() + return False + + # Resolve protected package list from constraints. + if constraints.startswith("http"): + cpkgs = requests.get(constraints).text.split("\n") + elif exists(constraints): + with open(constraints) as fh: + cpkgs = fh.read().split("\n") + else: + cpkgs = [ + "ovos-core", + "ovos-utils", + "ovos-plugin-manager", + "ovos-config", + "ovos-bus-client", + "ovos-workshop", + ] + + cpkgs = [ + self._get_canonical_name(p) + for p in cpkgs + if p.strip() and not p.strip().startswith("#") + ] + + if any(self._get_canonical_name(p) in cpkgs for p in packages): + LOG.error(f"tried to uninstall a protected package: {packages}") + self.play_error_sound() + return False + + if self.UV is not None: + pip_args = [self.UV, "pip", "uninstall", "-y"] + else: + pip_args = [sys.executable, "-m", "pip", "uninstall", "-y"] + + if self.config.get("break_system_packages", False): + pip_args += ["--break-system-packages"] + + with self.PIP_LOCK: + for package in packages: + LOG.info( + f"[{self.service_name}] (pip) Uninstalling {package}" + ) + pip_command = pip_args + [package] + LOG.debug(" ".join(pip_command)) + proc = ( + Popen(pip_command) + if print_logs + else Popen(pip_command, stdout=PIPE, stderr=PIPE) + ) + if proc.wait() != 0: + if proc.stderr: + stderr = proc.stderr.read().decode() + else: + stderr = f"pip uninstall failed for {package} (exit code {proc.returncode})" + self.play_error_sound() + raise RuntimeError(stderr) + + self._on_uninstall_complete() + self.play_success_sound() + return True + + # ------------------------------------------------------------------ + # Extension hooks for subclasses + # ------------------------------------------------------------------ + + def _on_install_complete(self) -> None: + """Called after a successful pip install. + + Override in subclasses to perform post-install actions such as + reloading plugin entry-points (ovos-core does this via + ``importlib.reload(ovos_plugin_manager)``). + """ + + def _on_uninstall_complete(self) -> None: + """Called after a successful pip uninstall. + + Override in subclasses to perform post-uninstall actions. + """ + + # ------------------------------------------------------------------ + # Message bus handlers + # ------------------------------------------------------------------ + + def handle_install_python(self, message: Message) -> None: + """Handle ``ovos.pip.install`` or ``ovos.pip.install.``.""" + if not self.config.get("allow_pip"): + LOG.error(InstallError.DISABLED.value) + self.play_error_sound() + self.bus.emit( + message.reply( + "ovos.pip.install.failed", + {"error": InstallError.DISABLED.value}, + ) + ) + return + + pkgs = message.data.get("packages") + if pkgs: + try: + success = self.pip_install(pkgs) + except RuntimeError: + success = False + if success: + self.bus.emit(message.reply("ovos.pip.install.complete")) + else: + self.bus.emit( + message.reply( + "ovos.pip.install.failed", + {"error": InstallError.PIP_ERROR.value}, + ) + ) + else: + self.bus.emit( + message.reply( + "ovos.pip.install.failed", + {"error": InstallError.NO_PKGS.value}, + ) + ) + + def handle_uninstall_python(self, message: Message) -> None: + """Handle ``ovos.pip.uninstall`` or ``ovos.pip.uninstall.``.""" + if not self.config.get("allow_pip"): + LOG.error(InstallError.DISABLED.value) + self.play_error_sound() + self.bus.emit( + message.reply( + "ovos.pip.uninstall.failed", + {"error": InstallError.DISABLED.value}, + ) + ) + return + + pkgs = message.data.get("packages") + if pkgs: + try: + success = self.pip_uninstall(pkgs) + except RuntimeError: + success = False + if success: + self.bus.emit(message.reply("ovos.pip.uninstall.complete")) + else: + self.bus.emit( + message.reply( + "ovos.pip.uninstall.failed", + {"error": InstallError.PIP_ERROR.value}, + ) + ) + else: + self.bus.emit( + message.reply( + "ovos.pip.uninstall.failed", + {"error": InstallError.NO_PKGS.value}, + ) + ) diff --git a/ovos_utils/skills.py b/ovos_utils/skills.py index 51b34087..e7e67f7b 100644 --- a/ovos_utils/skills.py +++ b/ovos_utils/skills.py @@ -1,7 +1,9 @@ +from typing import Any, Set + from ovos_bus_client.util import wait_for_reply -def get_non_properties(obj): +def get_non_properties(obj: Any) -> Set[str]: """Get attributes that are not properties from object. Will return members of object class along with bases down to MycroftSkill. diff --git a/ovos_utils/smtp_utils.py b/ovos_utils/smtp_utils.py index cd61b43c..b87bcacb 100644 --- a/ovos_utils/smtp_utils.py +++ b/ovos_utils/smtp_utils.py @@ -1,13 +1,33 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from smtplib import SMTP_SSL +from typing import Optional from ovos_utils.log import LOG -def send_smtp(user, pswd, sender, - destinatary, subject, contents, - host, port=465): +def send_smtp( + user: str, + pswd: str, + sender: str, + destinatary: str, + subject: str, + contents: str, + host: str, + port: int = 465, +) -> None: + """Send an e-mail via SMTP over SSL. + + Args: + user: SMTP login username. + pswd: SMTP login password. + sender: From address shown in the message. + destinatary: Recipient e-mail address. + subject: Message subject line. + contents: Plain-text message body. + host: SMTP server hostname. + port: SMTP server port (default 465). + """ with SMTP_SSL(host=host, port=port) as server: server.login(user, pswd) msg = MIMEMultipart() @@ -18,7 +38,18 @@ def send_smtp(user, pswd, sender, server.sendmail(sender, destinatary, msg.as_string()) -def send_email(subject, body, recipient=None): +def send_email(subject: str, body: str, recipient: Optional[str] = None) -> None: + """Send an e-mail using SMTP settings from the OVOS configuration. + + Args: + subject: Message subject line. + body: Plain-text message body. + recipient: Recipient address; falls back to the configured recipient or + the SMTP username when not provided. + + Raises: + KeyError: When the email configuration section is missing. + """ try: from ovos_config.config import read_mycroft_config config = read_mycroft_config() diff --git a/ovos_utils/thread_utils.py b/ovos_utils/thread_utils.py index e3339535..322769bc 100644 --- a/ovos_utils/thread_utils.py +++ b/ovos_utils/thread_utils.py @@ -1,9 +1,10 @@ import signal from functools import wraps from threading import Thread, Event -from typing import Callable, Optional, Any +from typing import Callable, Optional, Any, TYPE_CHECKING -import kthread +if TYPE_CHECKING: + import kthread from ovos_utils.log import LOG @@ -55,7 +56,7 @@ def func_wrapped(): def create_killable_daemon(target: Callable, args: tuple = (), kwargs: Optional[dict] = None, - autostart: bool = True) -> kthread.KThread: + autostart: bool = True) -> "kthread.KThread": """ Helper to create and start a killable daemon thread. @@ -71,6 +72,7 @@ def create_killable_daemon(target: Callable, args: tuple = (), kwargs: Optional[ Example: create_killable_daemon(target=my_function, args=(arg1, arg2)) """ + import kthread t = kthread.KThread(target=target, args=args, kwargs=kwargs) t.daemon = True if autostart: diff --git a/ovos_utils/version.py b/ovos_utils/version.py index 58cb32dd..38fe5473 100644 --- a/ovos_utils/version.py +++ b/ovos_utils/version.py @@ -1,6 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 8 -VERSION_BUILD = 6 -VERSION_ALPHA = 2 +VERSION_BUILD = 5 +VERSION_ALPHA = 3 # END_VERSION_BLOCK + +__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") diff --git a/ovos_utils/xml_helper.py b/ovos_utils/xml_helper.py index 75f72360..113d4a30 100644 --- a/ovos_utils/xml_helper.py +++ b/ovos_utils/xml_helper.py @@ -30,7 +30,7 @@ def xml2dict(xml_string): e = ET.XML(xml_string) d = etree2dict(e) return d - except: + except Exception: return {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..d4534048 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ovos-utils" +dynamic = ["version"] +description = "collection of simple utilities for use across the openvoiceos ecosystem" +readme = "README.md" +license = {text = "Apache-2.0"} +authors = [{name = "jarbasAI", email = "jarbas@openvoiceos.com"}] +requires-python = ">=3.9" +dependencies = [ + "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", + "python-dateutil", +] + +[project.urls] +Homepage = "https://github.com/OpenVoiceOS/ovos_utils" +Repository = "https://github.com/OpenVoiceOS/ovos_utils" + +[project.optional-dependencies] +extras = [ + "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", + "packaging", +] + +[project.scripts] +ovos-logs = "ovos_utils.log_parser:ovos_logs" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["ovos_utils*"] + +[tool.setuptools.dynamic] +version = {attr = "ovos_utils.version.__version__"} diff --git a/setup.py b/setup.py deleted file mode 100644 index 85204f8c..00000000 --- a/setup.py +++ /dev/null @@ -1,80 +0,0 @@ -import os -from setuptools import setup - -BASEDIR = os.path.abspath(os.path.dirname(__file__)) - - -def get_version(): - """ Find the version of the package""" - version = None - version_file = os.path.join(BASEDIR, 'ovos_utils', 'version.py') - major, minor, build, alpha, post = (None, None, None, None, None) - with open(version_file) as f: - for line in f: - if 'VERSION_MAJOR' in line: - major = line.split('=')[1].strip() - elif 'VERSION_MINOR' in line: - minor = line.split('=')[1].strip() - elif 'VERSION_BUILD' in line: - build = line.split('=')[1].strip() - elif 'VERSION_ALPHA' in line: - alpha = line.split('=')[1].strip() - elif 'VERSION_POST' in line: - post = line.split('=')[1].strip() - - if ((major and minor and build and alpha) or - '# END_VERSION_BLOCK' in line): - break - version = f"{major}.{minor}.{build}" - if alpha and int(alpha) > 0: - version += f"a{alpha}" - elif post and int(post) > 0: - version += f"post{post}" - return version - - -def package_files(directory): - paths = [] - for (path, directories, filenames) in os.walk(directory): - for filename in filenames: - paths.append(os.path.join('..', path, filename)) - return paths - - -def required(requirements_file): - """ Read requirements file and remove comments and empty lines. """ - with open(os.path.join(BASEDIR, requirements_file), 'r') as f: - requirements = f.read().splitlines() - if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: - print('USING LOOSE REQUIREMENTS!') - requirements = [r.replace('==', '>=').replace('~=', '>=') for r in requirements] - return [pkg for pkg in requirements - if pkg.strip() and not pkg.startswith("#")] - -with open(BASEDIR + "/README.md", "r") as f: - long_description = f.read() - -setup( - name='ovos_utils', - version=get_version(), - packages=['ovos_utils', - 'ovos_utils.lang'], - url='https://github.com/OpenVoiceOS/ovos_utils', - install_requires=required("requirements/requirements.txt"), - extras_require={ - "extras": required("requirements/extras.txt") - }, - package_data={'': package_files('ovos_utils')}, - include_package_data=True, - license='Apache', - author='jarbasAI', - author_email='jarbas@openvoiceos.com', - description='collection of simple utilities for use across the openvoiceos ecosystem', - long_description=long_description, - long_description_content_type="text/markdown", - entry_points={ - 'console_scripts': [ - 'ovos-logs=ovos_utils.log_parser:ovos_logs' - ] - } -) diff --git a/test/unittests/log_test/configured.log b/test/unittests/log_test/configured.log new file mode 100644 index 00000000..c889d3fa --- /dev/null +++ b/test/unittests/log_test/configured.log @@ -0,0 +1,126 @@ +2026-03-11 04:12:11.787 - configured - ovos_utils.network_utils:get_external_ip:78 - ERROR - Got resp=503: +2026-03-11 04:12:11.789 - configured - ovos_utils.network_utils:get_external_ip:80 - ERROR - Unable to get external IP Address: network error +2026-03-11 04:12:11.796 - configured - ovos_utils.network_utils:check_captive_portal:162 - ERROR - Error checking for captive portal +Traceback (most recent call last): + File "/home/miro/PycharmProjects/OpenVoiceOS Workspace/ovos-utils/ovos_utils/network_utils.py", line 156, in check_captive_portal + html_doc = requests.get(host).text + ~~~~~~~~~~~~^^^^^^ + File "/home/miro/.local/share/uv/python/cpython-3.13.12-linux-x86_64-gnu/lib/python3.13/unittest/mock.py", line 1169, in __call__ + return self._mock_call(*args, **kwargs) + ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ + File "/home/miro/.local/share/uv/python/cpython-3.13.12-linux-x86_64-gnu/lib/python3.13/unittest/mock.py", line 1173, in _mock_call + return self._execute_mock_call(*args, **kwargs) + ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ + File "/home/miro/.local/share/uv/python/cpython-3.13.12-linux-x86_64-gnu/lib/python3.13/unittest/mock.py", line 1228, in _execute_mock_call + raise effect +Exception: timeout +2026-03-11 04:12:11.823 - configured - ovos_utils.ocp:from_dict:254 - ERROR - track dictionary does not contain 'uri', it is not a valid MediaEntry +2026-03-11 04:12:11.824 - configured - ovos_utils.ocp:from_dict:256 - WARNING - DEPRECATED: use dict2entry() for Playlists and PluginStreams, MediaEntry.from_dict is only for regular media, will start throwing ValueError in 0.1.0 +2026-03-11 04:12:11.837 - configured - ovos_utils.ocp:available_extractors:165 - ERROR - please install/update ovos_plugin_manager +2026-03-11 04:12:11.841 - configured - ovos_utils.ocp:goto_track:586 - DEBUG - New playlist position: 1 +2026-03-11 04:12:11.842 - configured - ovos_utils.ocp:goto_track:586 - DEBUG - New playlist position: 0 +2026-03-11 04:12:11.842 - configured - ovos_utils.ocp:goto_track:586 - DEBUG - New playlist position: 0 +2026-03-11 04:12:11.843 - configured - ovos_utils.ocp:goto_track:588 - ERROR - requested track not in the playlist: MediaEntry(uri='http://missing.com/x.mp3', title='', artist='', match_confidence=0, skill_id='ovos.common_play', playback=, status=, media_type=, length=0, image='', skill_icon='', javascript='') +2026-03-11 04:12:11.852 - configured - ovos_utils.ocp:goto_track:586 - DEBUG - New playlist position: 2 +2026-03-11 04:12:11.853 - configured - ovos_utils.ocp:goto_track:586 - DEBUG - New playlist position: 1 +2026-03-11 04:12:11.854 - configured - ovos_utils.ocp:goto_track:588 - ERROR - requested track not in the playlist: MediaEntry(uri='nonexistent', title='nonexistent', artist='', match_confidence=0, skill_id='ovos.common_play', playback=, status=, media_type=, length=0, image='', skill_icon='', javascript='') +2026-03-11 04:12:11.857 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (3! Going to start of playlist +2026-03-11 04:12:11.858 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (-1! Going to start of playlist +2026-03-11 04:12:11.859 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (-1! Going to start of playlist +2026-03-11 04:12:11.860 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (100! Going to start of playlist +2026-03-11 04:12:11.877 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (0! Going to start of playlist +2026-03-11 04:12:11.878 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (-1! Going to start of playlist +2026-03-11 04:12:11.878 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (1! Going to start of playlist +2026-03-11 04:12:11.879 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (1! Going to start of playlist +2026-03-11 04:12:11.880 - configured - ovos_utils.ocp:_validate_position:607 - ERROR - Playlist pointer is in an invalid position (10! Going to start of playlist +2026-03-11 04:12:11.882 - configured - ovos_utils.parse:_validate_matching_strategy:27 - ERROR - rapidfuzz is not installed, falling back to SequenceMatcher for all match strategies +2026-03-11 04:12:11.882 - configured - ovos_utils.parse:_validate_matching_strategy:29 - WARNING - pip install rapidfuzz +2026-03-11 04:12:11.904 - configured - ovos_utils.security:decrypt:93 - ERROR - run pip install pycryptodomex +2026-03-11 04:12:11.906 - configured - ovos_utils.security:decrypt:103 - ERROR - decryption failed, invalid key? +2026-03-11 04:12:11.910 - configured - ovos_utils.security:encrypt:80 - ERROR - run pip install pycryptodomex +2026-03-11 04:12:11.912 - configured - ovos_utils.security:create_self_signed_cert:34 - ERROR - run pip install pyopenssl +2026-03-11 04:12:11.917 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.918 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'my_service' +2026-03-11 04:12:11.918 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.919 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.920 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.921 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.923 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.924 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.924 - configured - ovos_utils.skill_installer:handle_install_python:391 - ERROR - pip disabled in mycroft.conf +2026-03-11 04:12:11.925 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.926 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.927 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.928 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.929 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.931 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.932 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.932 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.933 - configured - ovos_utils.skill_installer:handle_uninstall_python:427 - ERROR - pip disabled in mycroft.conf +2026-03-11 04:12:11.934 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.934 - configured - ovos_utils.skill_installer:pip_install:230 - ERROR - no package list provided to install +2026-03-11 04:12:11.935 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.937 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.938 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [ovos_test] (pip) Installing my-pkg +2026-03-11 04:12:11.938 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /usr/bin/uv pip install -c http://example.com/c.txt my-pkg +2026-03-11 04:12:11.939 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.940 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [ovos_test] (pip) Installing my-pkg +2026-03-11 04:12:11.940 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/PycharmProjects/OpenVoiceOS Workspace/ovos-utils/.venv/bin/python3 -m pip install -c http://example.com/c.txt my-pkg +2026-03-11 04:12:11.941 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.942 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [ovos_test] (pip) Installing bad-pkg +2026-03-11 04:12:11.943 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-stable.txt bad-pkg +2026-03-11 04:12:11.944 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.945 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [ovos_test] (pip) Installing pkg +2026-03-11 04:12:11.945 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt pkg +2026-03-11 04:12:11.947 - configured - ovos_utils.skill_installer:validate_constraints:205 - ERROR - Couldn't find the constraints file +2026-03-11 04:12:11.948 - configured - ovos_utils.skill_installer:validate_constraints:191 - DEBUG - Constraints url: http://example.com/c.txt +2026-03-11 04:12:11.949 - configured - ovos_utils.skill_installer:validate_constraints:191 - DEBUG - Constraints url: http://example.com/missing.txt +2026-03-11 04:12:11.949 - configured - ovos_utils.skill_installer:validate_constraints:195 - ERROR - Remote constraints file not accessible: 404 +2026-03-11 04:12:11.950 - configured - ovos_utils.skill_installer:validate_constraints:191 - DEBUG - Constraints url: http://example.com/c.txt +2026-03-11 04:12:11.951 - configured - ovos_utils.skill_installer:validate_constraints:201 - ERROR - Error accessing remote constraints: timeout +2026-03-11 04:12:11.952 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.953 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.954 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.954 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [svc] (pip) Installing pkg +2026-03-11 04:12:11.955 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt pkg +2026-03-11 04:12:11.956 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.956 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [svc] (pip) Installing pkg +2026-03-11 04:12:11.957 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt --break-system-packages pkg +2026-03-11 04:12:11.958 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.959 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [svc] (pip) Installing pkg +2026-03-11 04:12:11.959 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt --pre pkg +2026-03-11 04:12:11.960 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.961 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [svc] (pip) Installing pkg +2026-03-11 04:12:11.961 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt pkg +2026-03-11 04:12:11.962 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:11.963 - configured - ovos_utils.skill_installer:pip_install:256 - INFO - [svc] (pip) Installing pkg +2026-03-11 04:12:11.963 - configured - ovos_utils.skill_installer:pip_install:258 - DEBUG - /home/miro/.local/bin/uv pip install -c http://x.com/c.txt pkg +2026-03-11 04:12:11.964 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.965 - configured - ovos_utils.skill_installer:pip_uninstall:295 - ERROR - no package list provided to uninstall +2026-03-11 04:12:11.966 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:11.967 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'ovos_test' +2026-03-11 04:12:12.298 - configured - ovos_utils.skill_installer:pip_uninstall:330 - ERROR - tried to uninstall a protected package: ['ovos-core'] +2026-03-11 04:12:12.299 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:12.461 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:12.462 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /usr/bin/uv pip uninstall -y custom-pkg +2026-03-11 04:12:12.464 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:12.925 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:12.926 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/PycharmProjects/OpenVoiceOS Workspace/ovos-utils/.venv/bin/python3 -m pip uninstall -y custom-pkg +2026-03-11 04:12:12.929 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:13.262 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:13.263 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/.local/bin/uv pip uninstall -y custom-pkg +2026-03-11 04:12:13.267 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:13.269 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:13.294 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/.local/bin/uv pip uninstall -y custom-pkg +2026-03-11 04:12:13.296 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:13.296 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:13.297 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/.local/bin/uv pip uninstall -y custom-pkg +2026-03-11 04:12:13.298 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:13.569 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:13.570 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/.local/bin/uv pip uninstall -y --break-system-packages custom-pkg +2026-03-11 04:12:13.571 - configured - ovos_utils.skill_installer:__init__:146 - INFO - ServiceInstaller registered for service 'svc' +2026-03-11 04:12:13.859 - configured - ovos_utils.skill_installer:pip_uninstall:344 - INFO - [svc] (pip) Uninstalling custom-pkg +2026-03-11 04:12:13.859 - configured - ovos_utils.skill_installer:pip_uninstall:348 - DEBUG - /home/miro/.local/bin/uv pip uninstall -y custom-pkg +2026-03-11 04:12:13.913 - configured - ovos_utils.system:system_reboot:83 - DEBUG - sudo systemctl reboot -i +2026-03-11 04:12:13.915 - configured - ovos_utils.system:system_shutdown:66 - DEBUG - sudo systemctl poweroff -i +2026-03-11 04:12:13.916 - configured - ovos_utils.system:system_shutdown:66 - DEBUG - systemctl poweroff -i diff --git a/test/unittests/log_test/rotate.log b/test/unittests/log_test/rotate.log new file mode 100644 index 00000000..dd7d75aa --- /dev/null +++ b/test/unittests/log_test/rotate.log @@ -0,0 +1 @@ +2026-03-11 04:12:13.924 - rotate - ovos_utils.system:ssh_enable - WARNING - Deprecation version=0.2.0. Caller=test_system:165. DEPRECATED: use ovos-PHAL-plugin-system diff --git a/test/unittests/log_test/rotate.log.1 b/test/unittests/log_test/rotate.log.1 new file mode 100644 index 00000000..8f4286ae --- /dev/null +++ b/test/unittests/log_test/rotate.log.1 @@ -0,0 +1 @@ +2026-03-11 04:12:13.923 - rotate - ovos_utils.system:ssh_disable - WARNING - Deprecation version=0.2.0. Caller=test_system:175. DEPRECATED: use ovos-PHAL-plugin-system diff --git a/test/unittests/test_bracket_extra.py b/test/unittests/test_bracket_extra.py new file mode 100644 index 00000000..f38f948c --- /dev/null +++ b/test/unittests/test_bracket_extra.py @@ -0,0 +1,181 @@ +# Copyright 2024, OpenVoiceOS +# Licensed under the Apache License, Version 2.0 + +import unittest +import warnings + + +class TestExpandParentheses(unittest.TestCase): + """Tests for the deprecated expand_parentheses function.""" + + def test_basic_expansion(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_parentheses + result = expand_parentheses(["hello", "(", "world", "|", "there", ")"]) + self.assertIsInstance(result, list) + self.assertGreater(len(result), 0) + + def test_no_parentheses(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_parentheses + result = expand_parentheses(["hello", "world"]) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0], ["hello", "world"]) + + def test_emits_deprecation_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ovos_utils.bracket_expansion import expand_parentheses + expand_parentheses(["a"]) + # Check that a DeprecationWarning was issued + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_returns_list_of_lists(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_parentheses + result = expand_parentheses(["(", "a", "|", "b", ")"]) + self.assertIsInstance(result, list) + for item in result: + self.assertIsInstance(item, list) + + +class TestExpandOptions(unittest.TestCase): + """Tests for the deprecated expand_options function.""" + + def test_basic_expansion(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_options + result = expand_options("test (a|b)") + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertIn("test a", result) + self.assertIn("test b", result) + + def test_no_parentheses(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_options + result = expand_options("hello world") + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0], "hello world") + + def test_emits_deprecation_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ovos_utils.bracket_expansion import expand_options + expand_options("test (x|y)") + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_multiple_options(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_options + result = expand_options("(a|b|c)") + self.assertEqual(len(result), 3) + + def test_returns_strings(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import expand_options + result = expand_options("test (x|y)") + for item in result: + self.assertIsInstance(item, str) + + +class TestSentenceTreeParser(unittest.TestCase): + """Tests for the deprecated SentenceTreeParser class.""" + + def _get_parser(self, tokens): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import SentenceTreeParser + return SentenceTreeParser(tokens) + + def test_instantiation_emits_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ovos_utils.bracket_expansion import SentenceTreeParser + SentenceTreeParser(["hello"]) + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_expand_parentheses_simple(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import SentenceTreeParser + parser = SentenceTreeParser(["hello", "world"]) + result = parser.expand_parentheses() + self.assertIsInstance(result, list) + + def test_expand_parentheses_with_options(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import SentenceTreeParser + parser = SentenceTreeParser(["(", "a", "|", "b", ")"]) + result = parser.expand_parentheses() + self.assertIsInstance(result, list) + self.assertGreaterEqual(len(result), 2) + + +class TestDeprecatedClasses(unittest.TestCase): + """Tests for Fragment, Word, Sentence, Options deprecated classes.""" + + def test_fragment_emits_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ovos_utils.bracket_expansion import Fragment + Fragment("test") + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_word_emits_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from ovos_utils.bracket_expansion import Word + Word("hello") + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_word_expand(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import Word + w = Word("hello") + result = w.expand() + self.assertIsInstance(result, list) + self.assertEqual(result, [["hello"]]) + + def test_fragment_expand(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from ovos_utils.bracket_expansion import Fragment + f = Fragment("test") + result = f.expand() + self.assertEqual(result, [[]]) + + def test_sentence_emits_warning(self): + from ovos_utils.bracket_expansion import Sentence, Word + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + w_obj = Word("hello") + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + Sentence([w_obj]) + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + def test_options_emits_warning(self): + from ovos_utils.bracket_expansion import Options, Word + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + w_obj = Word("hello") + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + Options([w_obj]) + self.assertTrue(any(issubclass(warning.category, DeprecationWarning) for warning in w)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_decorators.py b/test/unittests/test_decorators.py new file mode 100644 index 00000000..cff7937f --- /dev/null +++ b/test/unittests/test_decorators.py @@ -0,0 +1,134 @@ +# 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. +# +import time +import unittest + +from ovos_utils.decorators import classproperty, timed_lru_cache + + +class TestClassProperty(unittest.TestCase): + def test_basic_classproperty(self) -> None: + class MyClass: + _value = 42 + + @classproperty + def value(cls) -> int: + return cls._value + + self.assertEqual(MyClass.value, 42) + + def test_classproperty_from_instance(self) -> None: + class MyClass: + @classproperty + def name(cls) -> str: + return "MyClass" + + obj = MyClass() + self.assertEqual(obj.name, "MyClass") + + def test_classproperty_subclass(self) -> None: + class Base: + _x = 10 + + @classproperty + def x(cls) -> int: + return cls._x + + class Child(Base): + _x = 20 + + self.assertEqual(Base.x, 10) + self.assertEqual(Child.x, 20) + + +class TestTimedLruCache(unittest.TestCase): + def test_basic_caching(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def expensive(n: int) -> int: + nonlocal call_count + call_count += 1 + return n * 2 + + self.assertEqual(expensive(5), 10) + self.assertEqual(expensive(5), 10) + self.assertEqual(call_count, 1) # cached second call + + def test_different_args_not_cached(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + fn(2) + self.assertEqual(call_count, 2) + + def test_cache_expiry(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=0) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + time.sleep(0.01) + fn(1) + self.assertGreaterEqual(call_count, 2) + + def test_cache_info_available(self) -> None: + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + return n + + fn(1) + info = fn.cache_info() + self.assertIsNotNone(info) + + def test_cache_clear(self) -> None: + call_count = 0 + + @timed_lru_cache(seconds=60) + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n + + fn(1) + fn.cache_clear() + fn(1) + self.assertEqual(call_count, 2) + + def test_decorator_without_args(self) -> None: + """Calling @timed_lru_cache without parentheses.""" + call_count = 0 + + @timed_lru_cache + def fn(n: int) -> int: + nonlocal call_count + call_count += 1 + return n * 3 + + self.assertEqual(fn(4), 12) + self.assertEqual(fn(4), 12) + self.assertEqual(call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_device_input.py b/test/unittests/test_device_input.py index 96d06a57..00e22c17 100644 --- a/test/unittests/test_device_input.py +++ b/test/unittests/test_device_input.py @@ -1,15 +1,144 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.device_input module.""" + +import sys +import types import unittest from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, MagicMock, patch + +# distutils was removed in Python 3.12+; provide a minimal stub if missing +try: + import distutils.spawn +except ImportError: + distutils_stub = types.ModuleType("distutils") + spawn_stub = types.ModuleType("distutils.spawn") + spawn_stub.find_executable = lambda x: None + distutils_stub.spawn = spawn_stub + sys.modules["distutils"] = distutils_stub + sys.modules["distutils.spawn"] = spawn_stub + + +class TestInputDeviceHelper(unittest.TestCase): + """Tests for InputDeviceHelper class.""" + + @patch("ovos_utils.device_input.find_executable", 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 + helper = InputDeviceHelper() + self.assertEqual(helper.libinput_devices_list, []) + self.assertEqual(helper.xinput_devices_list, []) + + @patch("ovos_utils.device_input.find_executable") + @patch("subprocess.check_output") + def test_build_libinput_devices_list(self, mock_output: MagicMock, + mock_find: MagicMock) -> None: + """_build_linput_devices_list should parse libinput output correctly.""" + mock_find.return_value = "/usr/bin/libinput" + mock_output.return_value = ( + b"Device: My Keyboard\n" + b"Kernel: /dev/input/event0\n" + b"Group: 1\n" + b"Capabilities: keyboard\n" + ) + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + helper._build_linput_devices_list() + self.assertEqual(len(helper.libinput_devices_list), 1) + dev = helper.libinput_devices_list[0] + self.assertEqual(dev["Device"], "My Keyboard") + self.assertIn("keyboard", dev["Capabilities"]) + + @patch("ovos_utils.device_input.find_executable") + @patch("subprocess.check_output") + def test_build_libinput_multiple_capabilities(self, mock_output: MagicMock, + mock_find: MagicMock) -> None: + """_build_linput_devices_list should handle space-separated capabilities.""" + mock_find.return_value = "/usr/bin/libinput" + mock_output.return_value = ( + b"Device: Touchpad\n" + b"Kernel: /dev/input/event1\n" + b"Group: 2\n" + b"Capabilities: pointer gesture touch\n" + ) + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + helper._build_linput_devices_list() + caps = helper.libinput_devices_list[0]["Capabilities"] + self.assertIsInstance(caps, list) + self.assertGreater(len(caps), 1) + + @patch("ovos_utils.device_input.find_executable") + @patch("subprocess.check_output", side_effect=Exception("libinput failed")) + def test_get_libinput_devices_exception(self, mock_output: MagicMock, + mock_find: MagicMock) -> None: + """_get_libinput_devices_list should clear list and log on exception.""" + mock_find.side_effect = lambda x: "/usr/bin/libinput" if x == "libinput" else None + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + result = helper._get_libinput_devices_list() + self.assertEqual(result, []) + @patch("ovos_utils.device_input.find_executable", 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 + helper = InputDeviceHelper() + result = helper._get_libinput_devices_list() + self.assertEqual(result, []) -class TestDeviceInput(unittest.TestCase): - def test_input_device_helper(self): - # TODO - pass + @patch("ovos_utils.device_input.find_executable") + @patch("subprocess.check_output") + def test_build_xinput_devices_list(self, mock_output: MagicMock, + mock_find: MagicMock) -> None: + """_build_xinput_devices_list should parse xinput output correctly.""" + mock_find.return_value = "/usr/bin/xinput" + mock_output.return_value = ( + b"Virtual core pointer\n" + b"\xe2\x86\xb3 SynPS/2 Synaptics TouchPad id=12 [slave pointer (2)]\n" + b"\xe2\x86\xb3 AT Translated Set 2 keyboard id=13 [slave keyboard (3)]\n" + ) + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + helper._build_xinput_devices_list() + self.assertGreater(len(helper.xinput_devices_list), 0) - @mock.patch('distutils.spawn.find_executable') - def test_can_use_touch_mouse(self, find_exec): + @patch("ovos_utils.device_input.find_executable") + @patch("subprocess.check_output", side_effect=Exception("xinput failed")) + def test_get_xinput_devices_exception(self, mock_output: MagicMock, + mock_find: MagicMock) -> None: + """_get_xinput_devices_list should clear list on exception.""" + mock_find.side_effect = lambda x: "/usr/bin/xinput" if x == "xinput" else None + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + result = helper._get_xinput_devices_list() + self.assertEqual(result, []) + + @patch("ovos_utils.device_input.find_executable", 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 + helper = InputDeviceHelper() + result = helper._get_xinput_devices_list() + self.assertEqual(result, []) + + @mock.patch("ovos_utils.device_input.find_executable") + 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 find_exec.return_value = True dev_input = InputDeviceHelper() @@ -17,10 +146,10 @@ def test_can_use_touch_mouse(self, find_exec): dev_input._build_linput_devices_list = Mock() dev_input._build_xinput_devices_list = Mock() - dev_input.libinput_devices_list = [{'Device': 'Mock', - 'Capabilities': ['mouse']}, - {'Device': "Mock 1", - 'Capabilities': ['touch']} + dev_input.libinput_devices_list = [{"Device": "Mock", + "Capabilities": ["mouse"]}, + {"Device": "Mock 1", + "Capabilities": ["touch"]} ] self.assertTrue(dev_input.can_use_touch_mouse()) @@ -28,15 +157,16 @@ def test_can_use_touch_mouse(self, find_exec): self.assertTrue(dev_input.can_use_touch_mouse()) dev_input.libinput_devices_list.pop() self.assertFalse(dev_input.can_use_touch_mouse()) - dev_input.xinput_devices_list = [{'Device': 'xinput', - 'Capabilities': ['tablet'] + dev_input.xinput_devices_list = [{"Device": "xinput", + "Capabilities": ["tablet"] }] self.assertTrue(dev_input.can_use_touch_mouse()) dev_input.xinput_devices_list.pop() self.assertFalse(dev_input.can_use_touch_mouse()) - @mock.patch('distutils.spawn.find_executable') - def test_can_use_keyboard(self, find_exec): + @mock.patch("ovos_utils.device_input.find_executable") + def test_can_use_keyboard(self, find_exec: MagicMock) -> None: + """can_use_keyboard should detect keyboard devices.""" from ovos_utils.device_input import InputDeviceHelper find_exec.return_value = True dev_input = InputDeviceHelper() @@ -44,10 +174,10 @@ def test_can_use_keyboard(self, find_exec): dev_input._build_linput_devices_list = Mock() dev_input._build_xinput_devices_list = Mock() - dev_input.libinput_devices_list = [{'Device': 'Mock', - 'Capabilities': ['keyboard']}, - {'Device': "Mock 1", - 'Capabilities': ['touch']} + dev_input.libinput_devices_list = [{"Device": "Mock", + "Capabilities": ["keyboard"]}, + {"Device": "Mock 1", + "Capabilities": ["touch"]} ] self.assertTrue(dev_input.can_use_keyboard()) @@ -55,9 +185,66 @@ def test_can_use_keyboard(self, find_exec): self.assertTrue(dev_input.can_use_keyboard()) dev_input.libinput_devices_list.pop() self.assertFalse(dev_input.can_use_keyboard()) - dev_input.xinput_devices_list = [{'Device': 'xinput', - 'Capabilities': ['keyboard'] + dev_input.xinput_devices_list = [{"Device": "xinput", + "Capabilities": ["keyboard"] }] self.assertTrue(dev_input.can_use_keyboard()) 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.is_gui_installed", return_value=True) + def test_can_use_touch_mouse_no_executable_gui_installed( + self, mock_gui: MagicMock, mock_find: MagicMock) -> None: + """can_use_touch_mouse should return gui installed status when no executable.""" + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + result = helper.can_use_touch_mouse() + self.assertTrue(result) + + @patch("ovos_utils.device_input.find_executable", 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: + """can_use_touch_mouse should return False when no executable and no GUI.""" + from ovos_utils.device_input import InputDeviceHelper + helper = InputDeviceHelper() + result = helper.can_use_touch_mouse() + self.assertFalse(result) + + @patch("ovos_utils.device_input.find_executable") + 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 + mock_find.return_value = True + helper = InputDeviceHelper() + helper._build_linput_devices_list = Mock() + helper._build_xinput_devices_list = Mock() + helper.libinput_devices_list = [{"Device": "A", "Capabilities": ["mouse"]}] + helper.xinput_devices_list = [{"Device": "B", "Capabilities": ["keyboard"]}] + result = helper.get_input_device_list() + self.assertEqual(len(result), 2) + + +class TestModuleFunctions(unittest.TestCase): + """Tests for module-level can_use_touch_mouse and can_use_keyboard.""" + + @patch("ovos_utils.device_input.InputDeviceHelper") + def test_module_can_use_touch_mouse(self, mock_helper_cls: MagicMock) -> None: + """Module-level can_use_touch_mouse should delegate to InputDeviceHelper.""" + mock_helper_cls.return_value.can_use_touch_mouse.return_value = True + from ovos_utils.device_input import can_use_touch_mouse + result = can_use_touch_mouse() + self.assertTrue(result) + + @patch("ovos_utils.device_input.InputDeviceHelper") + def test_module_can_use_keyboard(self, mock_helper_cls: MagicMock) -> None: + """Module-level can_use_keyboard should delegate to InputDeviceHelper.""" + mock_helper_cls.return_value.can_use_keyboard.return_value = False + from ovos_utils.device_input import can_use_keyboard + result = can_use_keyboard() + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_dialog.py b/test/unittests/test_dialog.py index a29acbcc..bb823489 100644 --- a/test/unittests/test_dialog.py +++ b/test/unittests/test_dialog.py @@ -1,19 +1,301 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.dialog module.""" + +import os +import tempfile import unittest +from unittest.mock import patch, MagicMock + + +class TestMustacheDialogRenderer(unittest.TestCase): + """Tests for MustacheDialogRenderer class.""" + + def test_render_missing_template_returns_name(self) -> None: + """render should return the template name (with dots replaced) when not found.""" + from ovos_utils.dialog import MustacheDialogRenderer + r = MustacheDialogRenderer() + result = r.render("record.not.found") + self.assertEqual(result, "record not found") + def test_load_and_render_basic(self) -> None: + """load_template_file and render should work for a simple dialog file.""" + from ovos_utils.dialog import MustacheDialogRenderer + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("Hello world\n") + fname = f.name + try: + r = MustacheDialogRenderer() + r.load_template_file("greeting", fname) + result = r.render("greeting") + self.assertEqual(result, "Hello world") + finally: + os.unlink(fname) + + def test_load_skips_comments_and_blank_lines(self) -> None: + """load_template_file should skip lines starting with '#' and blank lines.""" + from ovos_utils.dialog import MustacheDialogRenderer + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("# comment\n\nHello\n") + fname = f.name + try: + r = MustacheDialogRenderer() + r.load_template_file("t", fname) + self.assertEqual(r.templates["t"], ["Hello"]) + finally: + os.unlink(fname) -class TestDialog(unittest.TestCase): - def test_mustache_dialog_renderer(self): + def test_render_with_context(self) -> None: + """render should substitute context variables using mustache syntax.""" from ovos_utils.dialog import MustacheDialogRenderer - # TODO + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("Hello {{name}}\n") + fname = f.name + try: + r = MustacheDialogRenderer() + r.load_template_file("greet", fname) + result = r.render("greet", context={"name": "OVOS"}) + self.assertEqual(result, "Hello OVOS") + finally: + os.unlink(fname) + + def test_render_with_index(self) -> None: + """render with explicit index should pick the correct template line.""" + from ovos_utils.dialog import MustacheDialogRenderer + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("Line A\nLine B\nLine C\n") + fname = f.name + try: + r = MustacheDialogRenderer() + r.load_template_file("multi", fname) + result = r.render("multi", index=1) + self.assertEqual(result, "Line B") + finally: + os.unlink(fname) + + def test_render_populates_recent_phrases(self) -> None: + """render should track recent phrases to avoid repetition.""" + from ovos_utils.dialog import MustacheDialogRenderer + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("A\nB\nC\nD\n") + fname = f.name + try: + r = MustacheDialogRenderer() + r.max_recent_phrases = 2 + r.load_template_file("phrases", fname) + + # Mock random.choice. + # render calls it twice: + # 1. line = random.choice(template_functions) + # 2. line = random.choice(expand_template(line)) + # So for each render call we need 2 values in side_effect. + with patch("random.choice", side_effect=["A", "A", "B", "B", "C", "C", "A", "A"]): + out1 = r.render("phrases") + self.assertEqual(out1, "A") + self.assertEqual(r.recent_phrases, ["A"]) + + out2 = r.render("phrases") + self.assertEqual(out2, "B") + self.assertEqual(r.recent_phrases, ["A", "B"]) + + out3 = r.render("phrases") + self.assertEqual(out3, "C") + # Window size is 2, so "A" should be dropped + self.assertEqual(r.recent_phrases, ["B", "C"]) + + out4 = r.render("phrases") + self.assertEqual(out4, "A") + self.assertEqual(r.recent_phrases, ["C", "A"]) + + # recent_phrases should not grow beyond max_recent_phrases + self.assertLessEqual(len(r.recent_phrases), r.max_recent_phrases) + finally: + os.unlink(fname) + + def test_render_all_lines_fail(self) -> None: + """render should raise KeyError when context variables are missing.""" + from ovos_utils.dialog import MustacheDialogRenderer + r = MustacheDialogRenderer() + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("Hello {name}\n") + fname = f.name + try: + r.load_template_file("fail", fname) + # missing "name" in context raises KeyError from string.format + with self.assertRaises(KeyError): + r.render("fail", context={}) + finally: + os.unlink(fname) + + def test_render_partial_failure(self) -> None: + """render should skip lines that fail to expand and pick a valid one.""" + from ovos_utils.dialog import MustacheDialogRenderer + r = MustacheDialogRenderer() + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + # We want two lines: one that works and one that would fail if picked + # (but render() picks one line first, THEN formats it) + f.write("VALID LINE\nHello {name}\n") + fname = f.name + try: + r.load_template_file("partial", fname) + # 1. pick the first line "VALID LINE" + # 2. pick from its expansion results (just itself) + with patch("random.choice", side_effect=["VALID LINE", "VALID LINE"]): + result = r.render("partial") + self.assertEqual(result, "VALID LINE") + + # Now test that if second line is picked without context, it raises KeyError + with patch("random.choice", side_effect=["Hello {name}", "Hello {name}"]): + with self.assertRaises(KeyError): + r.render("partial", context={}) + finally: + os.unlink(fname) - def test_load_dialogs(self): + +class TestLoadDialogs(unittest.TestCase): + """Tests for load_dialogs function.""" + + def test_load_from_directory(self) -> None: + """load_dialogs should load all .dialog files from a directory.""" + from ovos_utils.dialog import load_dialogs, MustacheDialogRenderer + with tempfile.TemporaryDirectory() as tmpdir: + dialog_file = os.path.join(tmpdir, "greeting.dialog") + with open(dialog_file, "w") as f: + f.write("Hi there\n") + renderer = load_dialogs(tmpdir) + self.assertIsInstance(renderer, MustacheDialogRenderer) + self.assertIn("greeting", renderer.templates) + + def test_load_missing_directory_returns_renderer(self) -> None: + """load_dialogs should return an empty renderer for a non-existent dir.""" + from ovos_utils.dialog import load_dialogs, MustacheDialogRenderer + renderer = load_dialogs("/nonexistent/path/dialog") + self.assertIsInstance(renderer, MustacheDialogRenderer) + self.assertEqual(renderer.templates, {}) + + def test_load_with_existing_renderer(self) -> None: + """load_dialogs should populate an existing renderer when passed.""" + from ovos_utils.dialog import load_dialogs, MustacheDialogRenderer + with tempfile.TemporaryDirectory() as tmpdir: + dialog_file = os.path.join(tmpdir, "bye.dialog") + with open(dialog_file, "w") as f: + f.write("Goodbye\n") + existing = MustacheDialogRenderer() + result = load_dialogs(tmpdir, renderer=existing) + self.assertIs(result, existing) + self.assertIn("bye", result.templates) + + def test_load_ignores_non_dialog_files(self) -> None: + """load_dialogs should ignore files that do not end with .dialog.""" from ovos_utils.dialog import load_dialogs - # TODO + with tempfile.TemporaryDirectory() as tmpdir: + with open(os.path.join(tmpdir, "readme.txt"), "w") as f: + f.write("not a dialog\n") + renderer = load_dialogs(tmpdir) + self.assertEqual(renderer.templates, {}) + - def test_get_dialog(self): +class TestGetDialog(unittest.TestCase): + """Tests for get_dialog function.""" + + def test_get_dialog_returns_phrase_when_no_file(self) -> None: + """get_dialog should return the phrase itself when no resource file found.""" + from ovos_utils.dialog import get_dialog + with patch("ovos_utils.dialog.resolve_resource_file", return_value=None): + result = get_dialog("hello.world", lang="en-us") + self.assertEqual(result, "hello.world") + + def test_get_dialog_returns_rendered_content(self) -> None: + """get_dialog should load and render template when resource file found.""" from ovos_utils.dialog import get_dialog - # TODO + with tempfile.NamedTemporaryFile(mode="w", suffix=".dialog", + delete=False) as f: + f.write("Hello from dialog\n") + fname = f.name + try: + with patch("ovos_utils.dialog.resolve_resource_file", + return_value=fname): + result = get_dialog("greeting", lang="en-us") + self.assertEqual(result, "Hello from dialog") + finally: + os.unlink(fname) - def test_join_list(self): + def test_get_dialog_none_lang_uses_config(self) -> None: + """get_dialog with lang=None should try to read config for language.""" + from ovos_utils.dialog import get_dialog + with patch("ovos_utils.dialog.resolve_resource_file", return_value=None) as mock_resolve: + with patch("ovos_utils.dialog.log_deprecation"): + # Should not raise even when ovos_config is absent + result = get_dialog("my.phrase", lang=None) + self.assertEqual(result, "my.phrase") + # Check that it tried to resolve with some lang (fallback or config) + self.assertTrue(mock_resolve.called) + call_args = mock_resolve.call_args[0][0] + # By default get_dialog falls back to "en-us" if config fails + self.assertIn("text/en-us/", call_args) + + def test_get_dialog_none_lang_config_import_error(self) -> None: + """get_dialog with lang=None should fall back to en-us on ImportError.""" + from ovos_utils.dialog import get_dialog + with patch("ovos_utils.dialog.resolve_resource_file", return_value=None) as mock_resolve: + with patch("ovos_utils.dialog.log_deprecation"): + with patch.dict("sys.modules", {"ovos_config": None}): + # ImportError path — falls back gracefully + result = get_dialog("test", lang=None) + self.assertEqual(result, "test") + mock_resolve.assert_called_once() + self.assertIn("text/en-us/test.dialog", mock_resolve.call_args[0][0]) + + +class TestJoinList(unittest.TestCase): + """Tests for join_list function.""" + + def test_empty_list(self) -> None: + """join_list with empty list should return empty string.""" from ovos_utils.dialog import join_list - # TODO + result = join_list([], "and") + self.assertEqual(result, "") + + def test_single_item(self) -> None: + """join_list with one item should return that item as string.""" + from ovos_utils.dialog import join_list + result = join_list(["apple"], "and") + self.assertEqual(result, "apple") + + @patch("ovos_utils.dialog.translate_word", return_value="and") + def test_multiple_items(self, mock_translate: MagicMock) -> None: + """join_list with multiple items should join with comma and connector.""" + from ovos_utils.dialog import join_list + result = join_list(["a", "b", "c"], "and", lang="en-us") + self.assertEqual(result, "a, b and c") + mock_translate.assert_called_once_with("and", "en-us") + + @patch("ovos_utils.dialog.translate_word", return_value="or") + def test_custom_separator(self, mock_translate: MagicMock) -> None: + """join_list with custom sep should use it between items.""" + from ovos_utils.dialog import join_list + result = join_list(["a", "b", "c"], "or", sep=";", lang="en-us") + self.assertEqual(result, "a; b or c") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_events.py b/test/unittests/test_events.py index b21a24f6..6a1366b0 100644 --- a/test/unittests/test_events.py +++ b/test/unittests/test_events.py @@ -358,3 +358,242 @@ def test_shutdown(self): self.interface.cancel_all_repeating_events = real_cancel_repeating self.interface.events.clear = real_clear + + +class TestCreateWrapper(unittest.TestCase): + """Tests for create_wrapper covering lines 71-92.""" + + def test_create_wrapper_calls_handler_no_args(self) -> None: + """create_wrapper should call a zero-argument handler.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + calls = [] + + def handler(): + calls.append(True) + + wrapped = create_wrapper(handler, "skill_id", None, None, None) + wrapped(FakeMessage("test")) + self.assertEqual(len(calls), 1) + + def test_create_wrapper_calls_handler_with_message(self) -> None: + """create_wrapper should pass message to a handler that accepts one.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + received = [] + + def handler(msg): + received.append(msg) + + msg = FakeMessage("test.msg") + wrapped = create_wrapper(handler, "skill_id", None, None, None) + wrapped(msg) + self.assertEqual(len(received), 1) + + def test_create_wrapper_calls_on_start(self) -> None: + """create_wrapper should call on_start before calling handler.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + order = [] + + def on_start(msg): + order.append("start") + + def handler(msg): + order.append("handler") + + wrapped = create_wrapper(handler, "skill_id", on_start, None, None) + wrapped(FakeMessage("test")) + self.assertEqual(order, ["start", "handler"]) + + def test_create_wrapper_calls_on_end(self) -> None: + """create_wrapper should call on_end in finally block.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + ended = [] + + def on_end(msg): + ended.append(True) + + def handler(msg): + pass + + wrapped = create_wrapper(handler, "skill_id", None, on_end, None) + wrapped(FakeMessage("test")) + self.assertTrue(ended) + + def test_create_wrapper_calls_on_error_one_arg(self) -> None: + """create_wrapper should call on_error(e) for single-arg error callback.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + errors = [] + + def handler(msg): + raise RuntimeError("boom") + + def on_error(e): + errors.append(e) + + wrapped = create_wrapper(handler, "skill_id", None, None, on_error) + wrapped(FakeMessage("test")) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], RuntimeError) + + def test_create_wrapper_calls_on_error_two_args(self) -> None: + """create_wrapper should call on_error(e, msg) for two-arg error callback.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + errors = [] + + def handler(msg): + raise ValueError("bad") + + def on_error(e, msg): + errors.append((e, msg)) + + wrapped = create_wrapper(handler, "skill_id", None, None, on_error) + wrapped(FakeMessage("test")) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0][0], ValueError) + + def test_create_wrapper_on_end_called_even_on_error(self) -> None: + """create_wrapper on_end should be called even when handler raises.""" + from ovos_utils.events import create_wrapper + from ovos_utils.fakebus import FakeMessage + ended = [] + + def handler(msg): + raise Exception("error") + + def on_end(msg): + ended.append(True) + + wrapped = create_wrapper(handler, "skill_id", None, on_end, None) + wrapped(FakeMessage("test")) + self.assertTrue(ended) + + +class TestEventContainerOnce(unittest.TestCase): + """Tests for EventContainer once-handler path (lines 154-155).""" + + def test_once_handler_invokes_and_removes(self) -> None: + """once_wrapper should invoke the handler and remove the event.""" + from ovos_utils.events import EventContainer + from ovos_utils.fakebus import FakeBus, FakeMessage + bus = FakeBus() + container = EventContainer(bus) + called = [] + + def handler(msg): + called.append(msg) + + container.add("once.event", handler, once=True) + bus.emit(FakeMessage("once.event")) + self.assertEqual(len(called), 1) + # After once fires, the event should be removed + self.assertEqual(container.events, []) + + def test_remove_error_logged(self) -> None: + """EventContainer.remove should handle ValueError gracefully.""" + from ovos_utils.events import EventContainer + from ovos_utils.fakebus import FakeBus + + # Subclass list to override remove so it always raises ValueError + class BrokenList(list): + def remove(self, item): + raise ValueError("forced") + + bus = FakeBus() + container = EventContainer(bus) + broken = BrokenList() + broken.append(("bad.event", lambda m: None)) + container.events = broken + # Should not raise despite the ValueError + result = container.remove("bad.event") + self.assertTrue(result) + + +class TestEventSchedulerInterfaceExtended(unittest.TestCase): + """Additional tests for EventSchedulerInterface uncovered methods.""" + + from ovos_utils.events import EventSchedulerInterface + bus = None + + def setUp(self) -> None: + """Set up a fresh bus and interface for each test.""" + import warnings + from ovos_utils.fakebus import FakeBus + from ovos_utils.events import EventSchedulerInterface + self.bus = FakeBus() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.interface = EventSchedulerInterface(bus=self.bus, + skill_id="test_ext") + + def test_schedule_event_with_naive_datetime(self) -> None: + """_schedule_event should attach timezone to naive datetime objects.""" + import datetime + from unittest.mock import Mock + cb = Mock() + cb.__name__ = "naive_test" + naive_dt = datetime.datetime.now() + # Should not raise + scheduled_msgs = [] + self.bus.on("mycroft.scheduler.schedule_event", + lambda m: scheduled_msgs.append(m)) + self.interface._schedule_event(cb, naive_dt, None, "naive_test") + self.assertEqual(len(scheduled_msgs), 1) + + def test_update_scheduled_event(self) -> None: + """update_scheduled_event should emit mycroft.scheduler.update_event.""" + emitted = [] + self.bus.on("mycroft.scheduler.update_event", + lambda m: emitted.append(m)) + self.interface.update_scheduled_event("my_event", {"key": "value"}) + self.assertEqual(len(emitted), 1) + self.assertIn("event", emitted[0].data) + + def test_cancel_scheduled_event_removes_repeat(self) -> None: + """cancel_scheduled_event should remove event from scheduled_repeats.""" + self.interface.scheduled_repeats.append("repeat_event") + self.interface.events.remove = lambda name: True + emitted = [] + self.bus.on("mycroft.scheduler.remove_event", + lambda m: emitted.append(m)) + self.interface.cancel_scheduled_event("repeat_event") + self.assertNotIn("repeat_event", self.interface.scheduled_repeats) + + def test_get_scheduled_event_status_raises_on_timeout(self) -> None: + """get_scheduled_event_status should raise Exception on no response.""" + with self.assertRaises(Exception) as ctx: + self.interface.get_scheduled_event_status("nonexistent_event") + self.assertIn("Timeout", str(ctx.exception)) + + def test_get_scheduled_event_status_returns_time_left(self) -> None: + """get_scheduled_event_status should return time left when status received.""" + import time + from unittest.mock import patch, MagicMock as MM + future_timestamp = int(time.time()) + 3600 + # Build a mock with .data attribute shaped like [[timestamp, ...], ...] + mock_status = MM() + mock_status.data = [[future_timestamp, None]] + + with patch.object(self.bus, "wait_for_response", + return_value=mock_status): + result = self.interface.get_scheduled_event_status("my_event") + self.assertGreater(result, 0) + + def test_cancel_all_repeating_events(self) -> None: + """cancel_all_repeating_events should cancel all scheduled repeats.""" + self.interface.scheduled_repeats = ["e1", "e2"] + cancelled = [] + + def mock_cancel(name: str) -> None: + cancelled.append(name) + if name in self.interface.scheduled_repeats: + self.interface.scheduled_repeats.remove(name) + + self.interface.cancel_scheduled_event = mock_cancel + self.interface.cancel_all_repeating_events() + self.assertIn("e1", cancelled) + self.assertIn("e2", cancelled) diff --git a/test/unittests/test_fakebus.py b/test/unittests/test_fakebus.py new file mode 100644 index 00000000..918fa4ff --- /dev/null +++ b/test/unittests/test_fakebus.py @@ -0,0 +1,195 @@ +# 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. +# +import unittest +import warnings + +from ovos_utils.fakebus import FakeBus, FakeMessage + + +class TestFakeMessage(unittest.TestCase): + def _make_message(self, msg_type: str, data: dict = None, + context: dict = None) -> FakeMessage: + # FakeMessage may return a real Message object if ovos_bus_client is installed + return FakeMessage(msg_type, data, context) + + def test_basic_construction(self) -> None: + msg = self._make_message("test.type", {"key": "value"}, {"ctx": 1}) + self.assertEqual(msg.msg_type, "test.type") + self.assertEqual(msg.data["key"], "value") + self.assertEqual(msg.context["ctx"], 1) + + def test_defaults(self) -> None: + msg = self._make_message("test.type") + self.assertEqual(msg.data, {}) + self.assertEqual(msg.context, {}) + + def test_serialize_deserialize(self) -> None: + msg = self._make_message("test.roundtrip", {"x": 42}) + serialized = msg.serialize() + restored = FakeMessage.deserialize(serialized) + self.assertEqual(restored.msg_type, "test.roundtrip") + self.assertEqual(restored.data["x"], 42) + + def test_forward(self) -> None: + msg = self._make_message("original.type", {}, {"source": "skill"}) + fwd = msg.forward("forwarded.type", {"new": "data"}) + self.assertEqual(fwd.msg_type, "forwarded.type") + self.assertEqual(fwd.context["source"], "skill") + + def test_reply(self) -> None: + msg = self._make_message("request.type", {}, {"source": "a", "destination": "b"}) + reply = msg.reply("reply.type", {"answer": 1}) + self.assertEqual(reply.msg_type, "reply.type") + + def test_response(self) -> None: + msg = self._make_message("my.request") + resp = msg.response({"result": "ok"}) + self.assertEqual(resp.msg_type, "my.request.response") + self.assertEqual(resp.data["result"], "ok") + + def test_publish(self) -> None: + msg = self._make_message("pub.type", {}, {"target": "skill", "source": "x"}) + published = msg.publish("new.type", {"payload": 1}) + self.assertNotIn("target", published.context) + self.assertEqual(published.data["payload"], 1) + + def test_equality(self) -> None: + m1 = self._make_message("same.type", {"a": 1}, {}) + m2 = self._make_message("same.type", {"a": 1}, {}) + self.assertEqual(m1, m2) + + def test_inequality(self) -> None: + m1 = self._make_message("type.a", {"x": 1}) + m2 = self._make_message("type.b", {"x": 1}) + self.assertNotEqual(m1, m2) + + def test_deserialize_empty_type(self) -> None: + import json + raw = json.dumps({"type": "", "data": {}, "context": {}}) + msg = FakeMessage.deserialize(raw) + self.assertEqual(msg.msg_type, "") + + +class TestFakeBus(unittest.TestCase): + def test_construction(self) -> None: + bus = FakeBus() + self.assertIsNotNone(bus) + self.assertEqual(bus.session_id, "default") + + def test_run_forever(self) -> None: + bus = FakeBus() + self.assertFalse(bus.started_running) + bus.run_forever() + self.assertTrue(bus.started_running) + + def test_run_in_thread(self) -> None: + bus = FakeBus() + bus.run_in_thread() + self.assertTrue(bus.started_running) + + def test_on_and_emit(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.on("test.event", handler) + msg = FakeMessage("test.event", {"hello": "world"}) + bus.emit(msg) + self.assertEqual(len(received), 1) + self.assertEqual(received[0].data["hello"], "world") + + def test_once(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.once("once.event", handler) + msg = FakeMessage("once.event") + bus.emit(msg) + bus.emit(msg) + self.assertEqual(len(received), 1) + + def test_remove_listener(self) -> None: + bus = FakeBus() + received = [] + + def handler(msg): + received.append(msg) + + bus.on("test.remove", handler) + bus.remove("test.remove", handler) + bus.emit(FakeMessage("test.remove")) + self.assertEqual(len(received), 0) + + def test_remove_all_listeners(self) -> None: + bus = FakeBus() + received = [] + + bus.on("event", lambda m: received.append(m)) + bus.on("event", lambda m: received.append(m)) + bus.remove_all_listeners("event") + bus.emit(FakeMessage("event")) + self.assertEqual(len(received), 0) + + def test_wait_for_message(self) -> None: + bus = FakeBus() + + import threading + def send_later(): + import time + time.sleep(0.05) + bus.emit(FakeMessage("delayed.event", {"val": 99})) + + t = threading.Thread(target=send_later) + t.start() + msg = bus.wait_for_message("delayed.event", timeout=2.0) + t.join() + self.assertIsNotNone(msg) + self.assertEqual(msg.data["val"], 99) + + def test_wait_for_message_timeout(self) -> None: + bus = FakeBus() + msg = bus.wait_for_message("never.comes", timeout=0.05) + self.assertIsNone(msg) + + def test_wait_for_response(self) -> None: + bus = FakeBus() + + def auto_reply(msg): + bus.emit(msg.response({"answer": 42})) + + bus.on("question", auto_reply) + request = FakeMessage("question", {"q": "?"}) + response = bus.wait_for_response(request, timeout=2.0) + self.assertIsNotNone(response) + self.assertEqual(response.data["answer"], 42) + + def test_create_client_returns_self(self) -> None: + bus = FakeBus() + self.assertIs(bus.create_client(), bus) + + def test_close(self) -> None: + bus = FakeBus() + bus.close() # Should not raise + + def test_on_error_does_not_raise(self) -> None: + bus = FakeBus() + bus.on_error(Exception("test error")) # Should not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_file_utils_extra.py b/test/unittests/test_file_utils_extra.py new file mode 100644 index 00000000..8718ca5c --- /dev/null +++ b/test/unittests/test_file_utils_extra.py @@ -0,0 +1,136 @@ +# Copyright 2024, OpenVoiceOS +# Licensed under the Apache License, Version 2.0 + +import os +import tempfile +import unittest +from unittest.mock import patch + + +class TestEnsureDirectoryExists(unittest.TestCase): + def test_creates_directory(self): + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + target = os.path.join(base, "new_dir") + result = ensure_directory_exists(target) + self.assertTrue(os.path.isdir(result)) + self.assertEqual(os.path.normpath(result), os.path.normpath(target)) + + def test_existing_directory_ok(self): + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + result = ensure_directory_exists(base) + self.assertTrue(os.path.isdir(result)) + + def test_with_domain(self): + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + result = ensure_directory_exists(base, domain="test_domain") + expected = os.path.join(base, "test_domain") + self.assertTrue(os.path.isdir(result)) + self.assertEqual(os.path.normpath(result), os.path.normpath(expected)) + + def test_expands_home(self): + from ovos_utils.file_utils import ensure_directory_exists + with patch("os.makedirs") as mock_makedirs, \ + patch("os.path.isdir", return_value=True): + result = ensure_directory_exists("~/testdir") + self.assertNotIn("~", result) + + def test_returns_string(self): + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + result = ensure_directory_exists(base) + self.assertIsInstance(result, str) + + +class TestToAlnum(unittest.TestCase): + def test_alphanumeric_unchanged(self): + from ovos_utils.file_utils import to_alnum + self.assertEqual(to_alnum("hello123"), "hello123") + + def test_hyphens_replaced(self): + from ovos_utils.file_utils import to_alnum + result = to_alnum("my-skill-id") + self.assertEqual(result, "my_skill_id") + + def test_dots_replaced(self): + from ovos_utils.file_utils import to_alnum + result = to_alnum("my.skill.id") + self.assertEqual(result, "my_skill_id") + + def test_spaces_replaced(self): + from ovos_utils.file_utils import to_alnum + result = to_alnum("my skill id") + self.assertEqual(result, "my_skill_id") + + def test_empty_string(self): + from ovos_utils.file_utils import to_alnum + self.assertEqual(to_alnum(""), "") + + def test_all_special_chars(self): + from ovos_utils.file_utils import to_alnum + result = to_alnum("!@#$%") + self.assertEqual(result, "_____") + + def test_converts_to_str(self): + from ovos_utils.file_utils import to_alnum + result = to_alnum(12345) + self.assertEqual(result, "12345") + + +class TestGetTempPath(unittest.TestCase): + def test_no_args_returns_tmp(self): + from ovos_utils.file_utils import get_temp_path + result = get_temp_path() + self.assertIsInstance(result, str) + self.assertTrue(os.path.isdir(result)) + + def test_single_arg(self): + from ovos_utils.file_utils import get_temp_path + result = get_temp_path("mydir") + self.assertIsInstance(result, str) + self.assertIn("mydir", result) + + def test_multiple_args(self): + from ovos_utils.file_utils import get_temp_path + result = get_temp_path("mydir", "subdir", "file.wav") + self.assertIn("mydir", result) + self.assertIn("subdir", result) + self.assertIn("file.wav", result) + + def test_invalid_arg_raises_type_error(self): + from ovos_utils.file_utils import get_temp_path + with self.assertRaises(TypeError): + get_temp_path(None) + + def test_result_is_under_tmpdir(self): + from ovos_utils.file_utils import get_temp_path + result = get_temp_path("testfolder") + self.assertTrue(result.startswith(tempfile.gettempdir())) + + +class TestGetCacheDirectory(unittest.TestCase): + def test_returns_string(self): + from ovos_utils.file_utils import get_cache_directory + result = get_cache_directory("test_cache") + self.assertIsInstance(result, str) + + def test_directory_created(self): + from ovos_utils.file_utils import get_cache_directory + result = get_cache_directory("test_cache_unique_xyz") + self.assertTrue(os.path.isdir(result)) + + def test_subdirectory(self): + from ovos_utils.file_utils import get_cache_directory + result = get_cache_directory("test/sub/dir") + self.assertTrue(os.path.isdir(result)) + + def test_folder_name_in_path(self): + from ovos_utils.file_utils import get_cache_directory + result = get_cache_directory("mycache") + self.assertIn("mycache", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_file_utils_new.py b/test/unittests/test_file_utils_new.py new file mode 100644 index 00000000..d0a7bf8b --- /dev/null +++ b/test/unittests/test_file_utils_new.py @@ -0,0 +1,270 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Additional unit tests for ovos_utils.file_utils module (coverage boost).""" + +import collections +import os +import tempfile +import unittest +from unittest.mock import patch + + +class TestEnsureDirectoryExists(unittest.TestCase): + """Tests for ensure_directory_exists.""" + + def test_creates_new_directory(self) -> None: + """ensure_directory_exists should create a directory if it doesn't exist.""" + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + new_dir = os.path.join(base, "new_subdir") + result = ensure_directory_exists(new_dir) + self.assertTrue(os.path.isdir(result)) + + def test_with_domain(self) -> None: + """ensure_directory_exists with domain should create base/domain path.""" + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + result = ensure_directory_exists(base, domain="mydomain") + self.assertIn("mydomain", result) + self.assertTrue(os.path.isdir(result)) + + def test_existing_directory_returns_path(self) -> None: + """ensure_directory_exists on an existing dir should return its path.""" + from ovos_utils.file_utils import ensure_directory_exists + with tempfile.TemporaryDirectory() as base: + result = ensure_directory_exists(base) + self.assertEqual(os.path.normpath(result), os.path.normpath(base)) + + +class TestToAlnum(unittest.TestCase): + """Tests for to_alnum.""" + + def test_keeps_alphanumeric(self) -> None: + """to_alnum should keep alphanumeric characters unchanged.""" + from ovos_utils.file_utils import to_alnum + self.assertEqual(to_alnum("abc123"), "abc123") + + def test_replaces_special_chars(self) -> None: + """to_alnum should replace non-alphanumeric chars with underscore.""" + from ovos_utils.file_utils import to_alnum + result = to_alnum("my-skill.v1") + self.assertNotIn("-", result) + self.assertNotIn(".", result) + self.assertIn("_", result) + + +class TestGetTempPath(unittest.TestCase): + """Tests for get_temp_path.""" + + def test_returns_string(self) -> None: + """get_temp_path should return a string.""" + from ovos_utils.file_utils import get_temp_path + self.assertIsInstance(get_temp_path("test"), str) + + def test_raises_type_error_on_bad_arg(self) -> None: + """get_temp_path with non-string args should raise TypeError.""" + from ovos_utils.file_utils import get_temp_path + with self.assertRaises(TypeError): + get_temp_path(123) + + +class TestResolveOvosResourceFile(unittest.TestCase): + """Tests for resolve_ovos_resource_file.""" + + def test_returns_none_for_nonexistent(self) -> None: + """Should return None when the resource doesn't exist anywhere.""" + from ovos_utils.file_utils import resolve_ovos_resource_file + result = resolve_ovos_resource_file("definitely_not_a_real_resource.xyz") + self.assertIsNone(result) + + def test_returns_path_for_fully_qualified_file(self) -> None: + """Should return the path when given an existing absolute path.""" + from ovos_utils.file_utils import resolve_ovos_resource_file + with tempfile.NamedTemporaryFile(delete=False) as f: + fname = f.name + try: + result = resolve_ovos_resource_file(fname) + self.assertEqual(result, fname) + finally: + os.unlink(fname) + + def test_checks_extra_res_dirs(self) -> None: + """Should find a resource in extra_res_dirs.""" + from ovos_utils.file_utils import resolve_ovos_resource_file + with tempfile.TemporaryDirectory() as tmpdir: + resource = "my_resource.txt" + fpath = os.path.join(tmpdir, resource) + with open(fpath, "w") as f: + f.write("data") + result = resolve_ovos_resource_file(resource, extra_res_dirs=[tmpdir]) + self.assertEqual(result, fpath) + + +class TestResolveResourceFile(unittest.TestCase): + """Tests for resolve_resource_file.""" + + def test_returns_none_when_not_found(self) -> None: + """Should return None when resource is not found anywhere.""" + from ovos_utils.file_utils import resolve_resource_file + with patch("ovos_utils.file_utils.log_deprecation"): + result = resolve_resource_file( + "this_resource_does_not_exist.xyz", config={} + ) + self.assertIsNone(result) + + def test_returns_path_for_existing_file(self) -> None: + """Should return the absolute path for an existing file.""" + from ovos_utils.file_utils import resolve_resource_file + with tempfile.NamedTemporaryFile(delete=False) as f: + fname = f.name + try: + result = resolve_resource_file(fname, config={}) + self.assertEqual(result, fname) + finally: + os.unlink(fname) + + +class TestReadVocabFile(unittest.TestCase): + """Tests for read_vocab_file.""" + + def test_reads_plain_lines(self) -> None: + """read_vocab_file should return lists of alternatives for each line.""" + from ovos_utils.file_utils import read_vocab_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".voc", delete=False) as f: + f.write("hello\n") + f.write("hi\n") + fname = f.name + try: + result = read_vocab_file(fname) + self.assertEqual(len(result), 2) + finally: + os.unlink(fname) + + def test_skips_comments_and_blanks(self) -> None: + """read_vocab_file should skip comment lines and blank lines.""" + from ovos_utils.file_utils import read_vocab_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".voc", delete=False) as f: + f.write("# comment\n") + f.write("\n") + f.write("hello\n") + fname = f.name + try: + result = read_vocab_file(fname) + self.assertEqual(len(result), 1) + finally: + os.unlink(fname) + + +class TestReadValueFile(unittest.TestCase): + """Tests for read_value_file.""" + + def test_reads_csv_pairs(self) -> None: + """read_value_file should return an OrderedDict with key-value pairs.""" + from ovos_utils.file_utils import read_value_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + f.write("key1,value1\n") + f.write("key2,value2\n") + fname = f.name + try: + result = read_value_file(fname, ",") + self.assertIsInstance(result, collections.OrderedDict) + self.assertEqual(result["key1"], "value1") + self.assertEqual(result["key2"], "value2") + finally: + os.unlink(fname) + + def test_skips_comments(self) -> None: + """read_value_file should skip rows starting with #.""" + from ovos_utils.file_utils import read_value_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + f.write("# comment\n") + f.write("key,value\n") + fname = f.name + try: + result = read_value_file(fname, ",") + self.assertNotIn("# comment", result) + self.assertIn("key", result) + finally: + os.unlink(fname) + + def test_skips_wrong_column_count(self) -> None: + """read_value_file should skip rows not having exactly 2 columns.""" + from ovos_utils.file_utils import read_value_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + f.write("only_one_column\n") + f.write("too,many,columns,here\n") + f.write("good,row\n") + fname = f.name + try: + result = read_value_file(fname, ",") + self.assertEqual(len(result), 1) + self.assertIn("good", result) + finally: + os.unlink(fname) + + def test_returns_empty_when_filename_is_none(self) -> None: + """read_value_file with None filename should return an empty OrderedDict.""" + from ovos_utils.file_utils import read_value_file + result = read_value_file(None, ",") + self.assertEqual(len(result), 0) + + +class TestReadTranslatedFile(unittest.TestCase): + """Tests for read_translated_file.""" + + def test_basic_substitution(self) -> None: + """read_translated_file should substitute template variables.""" + from ovos_utils.file_utils import read_translated_file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("Hello {{name}}!\n") + fname = f.name + try: + result = read_translated_file(fname, {"name": "World"}) + self.assertIsInstance(result, list) + self.assertIn("Hello World!", result) + finally: + os.unlink(fname) + + def test_returns_none_when_filename_is_none(self) -> None: + """read_translated_file with None filename should return None.""" + from ovos_utils.file_utils import read_translated_file + result = read_translated_file(None, {}) + self.assertIsNone(result) + + +class TestLoadVocabulary(unittest.TestCase): + """Tests for load_vocabulary.""" + + def test_loads_voc_files(self) -> None: + """load_vocabulary should load all .voc files in a directory.""" + from ovos_utils.file_utils import load_vocabulary + with tempfile.TemporaryDirectory() as tmpdir: + voc_path = os.path.join(tmpdir, "greet.voc") + with open(voc_path, "w") as f: + f.write("hello\n") + f.write("hi\n") + result = load_vocabulary(tmpdir, "my_skill") + self.assertTrue(any("greet" in k for k in result)) + + def test_empty_directory_returns_empty_dict(self) -> None: + """load_vocabulary in an empty directory should return {}.""" + from ovos_utils.file_utils import load_vocabulary + with tempfile.TemporaryDirectory() as tmpdir: + result = load_vocabulary(tmpdir, "skill_id") + self.assertEqual(result, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_geolocation.py b/test/unittests/test_geolocation.py new file mode 100644 index 00000000..47c4c0c8 --- /dev/null +++ b/test/unittests/test_geolocation.py @@ -0,0 +1,278 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.geolocation module.""" + +import unittest +from unittest.mock import MagicMock, patch + + +def _make_response(status_code: int, json_data: object) -> MagicMock: + """Build a mock requests.Response object.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data + return resp + + +class TestGetTimezone(unittest.TestCase): + """Tests for get_timezone function.""" + + def test_valid_coordinates(self) -> None: + """get_timezone should return name and code for valid coords.""" + mock_finder = MagicMock() + mock_finder.timezone_at.return_value = "America/New_York" + + with patch("ovos_utils.geolocation._tz_finder", mock_finder): + from ovos_utils.geolocation import get_timezone + result = get_timezone(40.7128, -74.0060) + + self.assertEqual(result["code"], "America/New_York") + self.assertIn("America", result["name"]) + + def test_invalid_latitude_raises(self) -> None: + """get_timezone should raise ValueError for out-of-range latitude.""" + mock_finder = MagicMock() + with patch("ovos_utils.geolocation._tz_finder", mock_finder): + from ovos_utils.geolocation import get_timezone + with self.assertRaises(ValueError): + get_timezone(999, 0) + + def test_invalid_longitude_raises(self) -> None: + """get_timezone should raise ValueError for out-of-range longitude.""" + mock_finder = MagicMock() + with patch("ovos_utils.geolocation._tz_finder", mock_finder): + from ovos_utils.geolocation import get_timezone + with self.assertRaises(ValueError): + get_timezone(0, 999) + + def test_timezone_not_found_raises_runtime_error(self) -> None: + """get_timezone should raise RuntimeError when timezone_at returns None.""" + mock_finder = MagicMock() + mock_finder.timezone_at.return_value = None + with patch("ovos_utils.geolocation._tz_finder", mock_finder): + from ovos_utils.geolocation import get_timezone + with self.assertRaises(RuntimeError): + get_timezone(0, 0) + + +class TestGetReverseGeolocation(unittest.TestCase): + """Tests for get_reverse_geolocation function.""" + + def _make_reverse_response(self) -> dict: + """Create a sample nominatim reverse geocoding response.""" + return { + "display_name": "New York, US", + "lat": "40.7128", + "lon": "-74.006", + "address": { + "city": "New York", + "state": "New York", + "state_code": "NY", + "country": "United States", + "country_code": "us", + "postcode": "10001", + } + } + + @patch("ovos_utils.geolocation.get_timezone") + @patch("ovos_utils.geolocation.requests.get") + def test_successful_reverse_geolocation( + self, mock_get: MagicMock, mock_tz: MagicMock + ) -> None: + """get_reverse_geolocation should parse a valid response and return location dict.""" + mock_get.return_value = _make_response(200, self._make_reverse_response()) + mock_tz.return_value = {"code": "America/New_York", "name": "America New_York"} + + # Clear LRU cache before calling + from ovos_utils.geolocation import get_reverse_geolocation + get_reverse_geolocation.cache_clear() + result = get_reverse_geolocation(40.7128, -74.006) + + self.assertIn("city", result) + self.assertIn("coordinate", result) + self.assertEqual(result["city"]["name"], "New York") + + @patch("ovos_utils.geolocation.requests.get") + def test_http_error_raises_connection_error(self, mock_get: MagicMock) -> None: + """get_reverse_geolocation should raise ConnectionError on non-200 status.""" + mock_get.return_value = _make_response(500, {}) + from ovos_utils.geolocation import get_reverse_geolocation + get_reverse_geolocation.cache_clear() + with self.assertRaises(ConnectionError): + get_reverse_geolocation(0.0, 0.0) + + @patch("ovos_utils.geolocation.requests.get") + def test_empty_address_raises_value_error(self, mock_get: MagicMock) -> None: + """get_reverse_geolocation should raise ValueError when address is empty.""" + mock_get.return_value = _make_response(200, { + "display_name": "Somewhere", + "lat": "0", + "lon": "0", + "address": {} + }) + from ovos_utils.geolocation import get_reverse_geolocation + get_reverse_geolocation.cache_clear() + # Empty address dict is falsy + # The function checks `if not address` + # Empty dict is falsy, so this should raise ValueError + # But address is `{}` which is falsy, triggering the ValueError + with self.assertRaises(ValueError): + get_reverse_geolocation(1.0, 1.0) + + @patch("ovos_utils.geolocation.requests.get") + def test_request_exception_raises_connection_error(self, mock_get: MagicMock) -> None: + """get_reverse_geolocation should raise ConnectionError on RequestException.""" + from requests.exceptions import RequestException + mock_get.side_effect = RequestException("timeout") + from ovos_utils.geolocation import get_reverse_geolocation + get_reverse_geolocation.cache_clear() + with self.assertRaises(ConnectionError): + get_reverse_geolocation(2.0, 2.0) + + +class TestGetGeolocation(unittest.TestCase): + """Tests for get_geolocation function.""" + + @patch("ovos_utils.geolocation.get_reverse_geolocation") + @patch("ovos_utils.geolocation.requests.get") + def test_successful_geolocation( + self, mock_get: MagicMock, mock_rev: MagicMock + ) -> None: + """get_geolocation should call reverse geolocation when lat/lon present.""" + nominatim_result = [{"lat": "40.7", "lon": "-74.0", "display_name": "New York"}] + mock_get.return_value = _make_response(200, nominatim_result) + mock_rev.return_value = {"city": {"name": "New York"}} + + from ovos_utils.geolocation import get_geolocation + get_geolocation.cache_clear() + result = get_geolocation("New York") + mock_rev.assert_called_once() + self.assertEqual(result["city"]["name"], "New York") + + @patch("ovos_utils.geolocation.requests.get") + def test_empty_result_raises_value_error(self, mock_get: MagicMock) -> None: + """get_geolocation should raise ValueError when result list is empty.""" + mock_get.return_value = _make_response(200, []) + from ovos_utils.geolocation import get_geolocation + get_geolocation.cache_clear() + with self.assertRaises(ValueError): + get_geolocation("NonExistentPlaceXYZ") + + @patch("ovos_utils.geolocation.requests.get") + def test_http_error_raises_connection_error(self, mock_get: MagicMock) -> None: + """get_geolocation should raise ConnectionError on non-200 status.""" + mock_get.return_value = _make_response(503, []) + from ovos_utils.geolocation import get_geolocation + get_geolocation.cache_clear() + with self.assertRaises(ConnectionError): + get_geolocation("AnyCity") + + @patch("ovos_utils.geolocation.requests.get") + def test_request_exception_raises_connection_error(self, mock_get: MagicMock) -> None: + """get_geolocation should raise ConnectionError on network failure.""" + from requests.exceptions import RequestException + mock_get.side_effect = RequestException("network error") + from ovos_utils.geolocation import get_geolocation + get_geolocation.cache_clear() + with self.assertRaises(ConnectionError): + get_geolocation("SomeCity") + + +class TestGetIpGeolocation(unittest.TestCase): + """Tests for get_ip_geolocation function.""" + + def _make_ip_response(self) -> dict: + """Build a sample ip-api.com success response.""" + return { + "status": "success", + "country": "United States", + "countryCode": "US", + "region": "NY", + "regionName": "New York", + "city": "New York City", + "lat": 40.7128, + "lon": -74.006, + "timezone": "America/New_York", + "query": "8.8.8.8", + } + + @patch("ovos_utils.geolocation.requests.get") + def test_successful_ip_geolocation(self, mock_get: MagicMock) -> None: + """get_ip_geolocation should parse a valid ip-api.com response.""" + mock_get.return_value = _make_response(200, self._make_ip_response()) + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + result = get_ip_geolocation(ip="8.8.8.8") + self.assertIn("city", result) + self.assertIn("coordinate", result) + self.assertIn("timezone", result) + self.assertEqual(result["city"]["name"], "New York City") + + @patch("ovos_utils.geolocation.requests.get") + def test_invalid_ip_raises_value_error(self, mock_get: MagicMock) -> None: + """get_ip_geolocation should raise ValueError for an invalid IP address.""" + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + with self.assertRaises(ValueError): + get_ip_geolocation(ip="not_an_ip") + + @patch("ovos_utils.geolocation.requests.get") + def test_http_error_raises_connection_error(self, mock_get: MagicMock) -> None: + """get_ip_geolocation should raise ConnectionError on non-200 response.""" + mock_get.return_value = _make_response(503, {}) + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + with self.assertRaises(ConnectionError): + get_ip_geolocation(ip="8.8.8.8") + + @patch("ovos_utils.geolocation.requests.get") + def test_api_fail_status_raises_value_error(self, mock_get: MagicMock) -> None: + """get_ip_geolocation should raise ValueError when api returns fail status.""" + mock_get.return_value = _make_response(200, {"status": "fail", "message": "reserved range"}) + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + with self.assertRaises(ValueError): + get_ip_geolocation(ip="8.8.8.8") + + @patch("ovos_utils.geolocation.requests.get") + def test_unsupported_lang_defaults_to_english(self, mock_get: MagicMock) -> None: + """get_ip_geolocation should fall back to 'en' for unsupported languages.""" + mock_get.return_value = _make_response(200, self._make_ip_response()) + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + # 'fi' (Finnish) is unsupported by ip-api.com + result = get_ip_geolocation(ip="8.8.8.8", lang="fi") + self.assertIsNotNone(result) + # Verify that the request used lang=en + call_kwargs = mock_get.call_args[1] + self.assertEqual(call_kwargs["params"]["lang"], "en") + + @patch("ovos_utils.geolocation.get_external_ip") + @patch("ovos_utils.geolocation.requests.get") + def test_localhost_ip_fetches_external( + self, mock_get: MagicMock, mock_ext_ip: MagicMock + ) -> None: + """get_ip_geolocation should resolve external IP when given localhost.""" + mock_ext_ip.return_value = "8.8.8.8" + mock_get.return_value = _make_response(200, self._make_ip_response()) + from ovos_utils.geolocation import get_ip_geolocation + get_ip_geolocation.cache_clear() + result = get_ip_geolocation(ip="127.0.0.1") + mock_ext_ip.assert_called_once() + self.assertIsNotNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_gui.py b/test/unittests/test_gui.py new file mode 100644 index 00000000..de82f404 --- /dev/null +++ b/test/unittests/test_gui.py @@ -0,0 +1,162 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.gui module.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + + +class TestCanDisplay(unittest.TestCase): + """Tests for can_display.""" + + @patch("ovos_utils.gui.has_screen", return_value=True) + def test_returns_true_when_screen_present(self, _: MagicMock) -> None: + """can_display should return True when has_screen() is True.""" + from ovos_utils.gui import can_display + self.assertTrue(can_display()) + + @patch("ovos_utils.gui.has_screen", return_value=False) + def test_returns_false_when_no_screen(self, _: MagicMock) -> None: + """can_display should return False when has_screen() is False.""" + from ovos_utils.gui import can_display + self.assertFalse(can_display()) + + +class TestIsGuiInstalled(unittest.TestCase): + """Tests for is_gui_installed.""" + + @patch("ovos_utils.gui.is_installed", return_value=True) + def test_returns_true_when_app_installed(self, _: MagicMock) -> None: + """is_gui_installed should return True when at least one GUI app is installed.""" + from ovos_utils.gui import is_gui_installed + self.assertTrue(is_gui_installed()) + + @patch("ovos_utils.gui.is_installed", return_value=False) + def test_returns_false_when_no_app_installed(self, _: MagicMock) -> None: + """is_gui_installed should return False when no GUI app is installed.""" + from ovos_utils.gui import is_gui_installed + self.assertFalse(is_gui_installed()) + + +class TestIsGuiRunning(unittest.TestCase): + """Tests for is_gui_running.""" + + @patch("ovos_utils.gui.is_process_running", return_value=True) + def test_returns_true_when_running(self, _: MagicMock) -> None: + """is_gui_running should return True when a GUI process is detected.""" + from ovos_utils.gui import is_gui_running + self.assertTrue(is_gui_running()) + + @patch("ovos_utils.gui.is_process_running", return_value=False) + def test_returns_false_when_not_running(self, _: MagicMock) -> None: + """is_gui_running should return False when no GUI process is running.""" + from ovos_utils.gui import is_gui_running + self.assertFalse(is_gui_running()) + + +class TestIsGuiConnected(unittest.TestCase): + """Tests for is_gui_connected.""" + + @patch("ovos_utils.gui.wait_for_reply") + def test_returns_true_when_connected(self, mock_reply: MagicMock) -> None: + """is_gui_connected should return True when response says connected.""" + mock_msg = MagicMock() + mock_msg.data = {"connected": True} + mock_reply.return_value = mock_msg + from ovos_utils.gui import is_gui_connected + result = is_gui_connected(bus=MagicMock()) + self.assertTrue(result) + + @patch("ovos_utils.gui.wait_for_reply") + def test_returns_false_when_no_reply(self, mock_reply: MagicMock) -> None: + """is_gui_connected should return False when no reply is received.""" + mock_reply.return_value = None + from ovos_utils.gui import is_gui_connected + result = is_gui_connected(bus=MagicMock()) + self.assertFalse(result) + + +class TestCanUseLocalGui(unittest.TestCase): + """Tests for can_use_local_gui.""" + + @patch("ovos_utils.gui.can_display", return_value=True) + @patch("ovos_utils.gui.is_gui_installed", return_value=True) + @patch("ovos_utils.gui.is_gui_running", return_value=True) + def test_all_conditions_met(self, *_) -> None: + """can_use_local_gui should return True when display, installed, and running.""" + from ovos_utils.gui import can_use_local_gui + self.assertTrue(can_use_local_gui()) + + @patch("ovos_utils.gui.can_display", return_value=False) + @patch("ovos_utils.gui.is_gui_installed", return_value=True) + @patch("ovos_utils.gui.is_gui_running", return_value=True) + def test_no_display_returns_false(self, *_) -> None: + """can_use_local_gui should return False when no display is available.""" + from ovos_utils.gui import can_use_local_gui + self.assertFalse(can_use_local_gui()) + + +class TestCanUseGui(unittest.TestCase): + """Tests for can_use_gui.""" + + @patch("ovos_utils.gui.can_use_local_gui", return_value=True) + def test_local_flag_delegates_to_local(self, _: MagicMock) -> None: + """can_use_gui with local=True should only check local GUI.""" + from ovos_utils.gui import can_use_gui + result = can_use_gui(local=True) + self.assertTrue(result) + + @patch("ovos_utils.gui.can_use_local_gui", return_value=False) + @patch("ovos_utils.gui.is_gui_connected", return_value=True) + def test_falls_back_to_connected(self, *_) -> None: + """can_use_gui should return True when GUI is connected (even if not local).""" + from ovos_utils.gui import can_use_gui + result = can_use_gui(bus=MagicMock(), local=False) + self.assertTrue(result) + + +class TestGetUiDirectories(unittest.TestCase): + """Tests for get_ui_directories.""" + + def test_legacy_ui_dir(self) -> None: + """get_ui_directories should map qt5 for legacy 'ui' directory.""" + from ovos_utils.gui import get_ui_directories + with tempfile.TemporaryDirectory() as root: + os.makedirs(os.path.join(root, "ui")) + result = get_ui_directories(root) + self.assertIn("qt5", result) + + def test_modern_gui_dir(self) -> None: + """get_ui_directories should discover framework subdirs under 'gui'.""" + from ovos_utils.gui import get_ui_directories + with tempfile.TemporaryDirectory() as root: + os.makedirs(os.path.join(root, "gui", "qt5")) + os.makedirs(os.path.join(root, "gui", "kivy")) + result = get_ui_directories(root) + self.assertIn("qt5", result) + self.assertIn("kivy", result) + + def test_no_ui_dirs(self) -> None: + """get_ui_directories should return an empty dict when no UI directories exist.""" + from ovos_utils.gui import get_ui_directories + with tempfile.TemporaryDirectory() as root: + result = get_ui_directories(root) + self.assertEqual(result, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_init.py b/test/unittests/test_init.py new file mode 100644 index 00000000..0e7a1e31 --- /dev/null +++ b/test/unittests/test_init.py @@ -0,0 +1,114 @@ +# 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. +# +import dataclasses +import unittest +import warnings + +from ovos_utils import json_dumps, json_loads, datestr2ts + + +class TestJsonDumps(unittest.TestCase): + """Tests for json_dumps / json_loads in ovos_utils/__init__.py""" + + def test_dumps_dict(self) -> None: + result = json_dumps({"key": "value", "num": 42}) + self.assertIn('"key"', result) + self.assertIn('"value"', result) + self.assertIn("42", result) + + def test_dumps_list(self) -> None: + result = json_dumps([1, 2, 3]) + self.assertEqual(json_loads(result), [1, 2, 3]) + + def test_dumps_string(self) -> None: + result = json_dumps("hello") + self.assertIn("hello", result) + + def test_dumps_none(self) -> None: + result = json_dumps(None) + self.assertEqual(result, "null") + + def test_dumps_bool(self) -> None: + self.assertIn("true", json_dumps(True).lower()) + self.assertIn("false", json_dumps(False).lower()) + + def test_dumps_nested(self) -> None: + payload = {"a": {"b": [1, 2, 3]}} + result = json_dumps(payload) + self.assertEqual(json_loads(result), payload) + + def test_dumps_unicode(self) -> None: + payload = {"greeting": "héllo wörld"} + result = json_dumps(payload) + self.assertIn("héllo", result) + + def test_dumps_dataclass(self) -> None: + @dataclasses.dataclass + class Point: + x: int + y: int + + p = Point(x=3, y=7) + result = json_dumps(p) + loaded = json_loads(result) + self.assertEqual(loaded["x"], 3) + self.assertEqual(loaded["y"], 7) + + def test_roundtrip(self) -> None: + original = {"nested": {"list": [1, "two", 3.0], "bool": True}} + self.assertEqual(json_loads(json_dumps(original)), original) + + +class TestJsonLoads(unittest.TestCase): + """Tests for json_loads in ovos_utils/__init__.py""" + + def test_loads_dict(self) -> None: + result = json_loads('{"a": 1}') + self.assertEqual(result, {"a": 1}) + + def test_loads_list(self) -> None: + result = json_loads('[1, 2, 3]') + self.assertEqual(result, [1, 2, 3]) + + def test_loads_null(self) -> None: + result = json_loads("null") + self.assertIsNone(result) + + def test_loads_unicode(self) -> None: + result = json_loads('{"word": "héllo"}') + self.assertEqual(result["word"], "héllo") + + +class TestDeprecatedHelpers(unittest.TestCase): + """Tests for deprecated functions in ovos_utils/__init__.py""" + + def test_datestr2ts_warns(self) -> None: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + ts = datestr2ts("20230101") + self.assertEqual(len(w), 1) + self.assertIn("deprecated", str(w[0].message).lower()) + # 2023-01-01 should return a positive timestamp + self.assertGreater(ts, 0) + + def test_datestr2ts_value(self) -> None: + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + ts = datestr2ts("20000101") + import datetime + expected = datetime.datetime(2000, 1, 1).timestamp() + self.assertAlmostEqual(ts, expected, places=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_lang.py b/test/unittests/test_lang.py index 356c1fb8..19cbeb20 100644 --- a/test/unittests/test_lang.py +++ b/test/unittests/test_lang.py @@ -1,24 +1,159 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.lang module.""" + +import os +import tempfile import unittest +import unittest.mock +from unittest.mock import patch + + +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.""" + 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") + + def test_non_macro_preserves_region(self) -> None: + """standardize_lang_tag(macro=False) should keep the region part.""" + 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") + + def test_no_region_tag(self) -> None: + """standardize_lang_tag with no '-' should return lowercased 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" + + 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) -class TestLang(unittest.TestCase): - def test_get_language_dir(self): +class TestGetLanguageDir(unittest.TestCase): + """Tests for get_language_dir.""" + + def test_returns_none_when_no_match(self) -> None: + """get_language_dir should return None when no matching lang dir exists.""" + from ovos_utils.lang import get_language_dir + with tempfile.TemporaryDirectory() as tmpdir: + result = get_language_dir(tmpdir, lang="en-US") + self.assertIsNone(result) + + def test_finds_matching_directory(self) -> None: + """get_language_dir should return the best matching directory path.""" from ovos_utils.lang import get_language_dir - # TODO - def test_translate_word(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Create a language directory + lang_dir = os.path.join(tmpdir, "en-US") + os.makedirs(lang_dir) + + # Mock tag_distance to return a good score + mock_langcodes = unittest.mock.MagicMock() + mock_langcodes.standardize_tag.return_value = "en-US" + mock_langcodes.tag_distance.return_value = 0 + + with patch.dict("sys.modules", {"langcodes": mock_langcodes}): + result = get_language_dir(tmpdir, lang="en-US") + + self.assertIsNotNone(result) + + +class TestTranslateWord(unittest.TestCase): + """Tests for translate_word.""" + + def test_returns_name_when_no_file(self) -> None: + """translate_word should return the word name when no translation file exists.""" + from ovos_utils.lang import translate_word + with patch("ovos_utils.lang.resolve_resource_file", return_value=None): + result = translate_word("hello", lang="en-US") + self.assertEqual(result, "hello") + + def test_reads_translation_from_file(self) -> None: + """translate_word should return the first non-comment line from the word file.""" + from ovos_utils.lang import translate_word + + with tempfile.NamedTemporaryFile(mode="w", suffix=".word", delete=False) as f: + f.write("# this is a comment\n") + f.write("hola\n") + fname = f.name + + try: + with patch("ovos_utils.lang.resolve_resource_file", return_value=fname): + result = translate_word("hello", lang="es-ES") + self.assertEqual(result, "hola") + finally: + os.unlink(fname) + + def test_skips_comment_lines(self) -> None: + """translate_word should skip lines starting with #.""" from ovos_utils.lang import translate_word - # TODO - def test_phonemes(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".word", delete=False) as f: + f.write("# comment\n") + f.write("# another comment\n") + f.write("bonjour\n") + fname = f.name + + try: + with patch("ovos_utils.lang.resolve_resource_file", return_value=fname): + result = translate_word("hello", lang="fr-FR") + self.assertEqual(result, "bonjour") + finally: + os.unlink(fname) + + +class TestPhonemes(unittest.TestCase): + """Tests for phoneme lookup tables.""" + + def test_arpabet_to_ipa_mapping(self) -> None: + """arpabet2ipa and ipa2arpabet should be inverse mappings.""" from ovos_utils.lang.phonemes import arpabet2ipa, ipa2arpabet for key, val in arpabet2ipa.items(): self.assertIsInstance(key, str) self.assertIsInstance(val, str) self.assertEqual(ipa2arpabet[val], key) - def test_visemes(self): + +class TestVisemes(unittest.TestCase): + """Tests for the VISIMES lookup table.""" + + def test_visimes_keys_and_values_are_strings(self) -> None: + """All keys and values in VISIMES should be strings.""" from ovos_utils.lang.visimes import VISIMES for key, val in VISIMES.items(): self.assertIsInstance(key, str) - self.assertIsInstance(val, str) \ No newline at end of file + self.assertIsInstance(val, str) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_list_utils.py b/test/unittests/test_list_utils.py new file mode 100644 index 00000000..ed05f4dc --- /dev/null +++ b/test/unittests/test_list_utils.py @@ -0,0 +1,91 @@ +# 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. +# +import unittest + +from ovos_utils.list_utils import rotate_list, flatten_list, deduplicate_list + + +class TestRotateList(unittest.TestCase): + def test_rotate_by_one(self) -> None: + self.assertEqual(rotate_list([1, 2, 3], 1), [2, 3, 1]) + + def test_rotate_by_two(self) -> None: + self.assertEqual(rotate_list([1, 2, 3, 4], 2), [3, 4, 1, 2]) + + def test_rotate_empty(self) -> None: + self.assertEqual(rotate_list([], 1), []) + + def test_rotate_default(self) -> None: + self.assertEqual(rotate_list([1, 2, 3]), [2, 3, 1]) + + def test_rotate_full_cycle(self) -> None: + lst = [1, 2, 3] + self.assertEqual(rotate_list(lst, len(lst)), lst) + + def test_rotate_zero(self) -> None: + self.assertEqual(rotate_list([1, 2, 3], 0), [1, 2, 3]) + + +class TestFlattenList(unittest.TestCase): + def test_basic_nested(self) -> None: + self.assertEqual(flatten_list([[1, 2], [3, 4]]), [1, 2, 3, 4]) + + def test_deeply_nested(self) -> None: + # flatten_list flattens one level at a time for list-of-lists + result = flatten_list([[1, 2], [3, 4]]) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_with_tuples(self) -> None: + result = flatten_list([(1, 2), (3, 4)]) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_tuples_false(self) -> None: + # tuples=False: outer list is flattened, but tuples are kept as-is + result = flatten_list([[1, 2], [3, 4]], tuples=False) + self.assertEqual(result, [1, 2, 3, 4]) + + def test_already_flat(self) -> None: + self.assertEqual(flatten_list([[1, 2, 3]]), [1, 2, 3]) + + def test_empty(self) -> None: + self.assertEqual(flatten_list([[]]), []) + + +class TestDeduplicateList(unittest.TestCase): + def test_basic_dedup(self) -> None: + result = deduplicate_list(["a", "b", "a", "c"]) + self.assertEqual(result, ["a", "b", "c"]) + + def test_order_preserved(self) -> None: + result = deduplicate_list(["c", "a", "b", "a"]) + self.assertEqual(result[0], "c") + self.assertEqual(len(result), 3) + + def test_no_order(self) -> None: + result = deduplicate_list(["a", "b", "a", "c"], keep_order=False) + self.assertEqual(set(result), {"a", "b", "c"}) + self.assertEqual(len(result), 3) + + def test_no_duplicates(self) -> None: + lst = ["x", "y", "z"] + self.assertEqual(deduplicate_list(lst), lst) + + def test_empty(self) -> None: + self.assertEqual(deduplicate_list([]), []) + + def test_all_same(self) -> None: + self.assertEqual(deduplicate_list(["a", "a", "a"]), ["a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_log_parser.py b/test/unittests/test_log_parser.py new file mode 100644 index 00000000..48a02714 --- /dev/null +++ b/test/unittests/test_log_parser.py @@ -0,0 +1,416 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.log_parser module.""" + +import os +import tempfile +import unittest +import unittest.mock +from datetime import datetime + + +class TestLogLine(unittest.TestCase): + """Tests for the LogLine dataclass.""" + + def test_str_with_all_fields(self) -> None: + """__str__ should format a full log line correctly.""" + from ovos_utils.log_parser import LogLine, TIME_FORMAT + ts = datetime(2024, 1, 1, 12, 0, 0, 123456) + ll = LogLine(timestamp=ts, source="skills", location="my_skill:handler:10", + level="INFO", message="Hello") + s = str(ll) + self.assertIn("skills", s) + self.assertIn("INFO", s) + self.assertIn("Hello", s) + + def test_str_without_source(self) -> None: + """__str__ for a line with no source should return just the message.""" + from ovos_utils.log_parser import LogLine + ll = LogLine(message="bare message") + self.assertEqual(str(ll), "bare message") + + def test_format_timestamp_with_timestamp(self) -> None: + """format_timestamp should return formatted string when timestamp is set.""" + from ovos_utils.log_parser import LogLine + ts = datetime(2024, 6, 15, 9, 30, 0, 0) + ll = LogLine(timestamp=ts) + result = ll.format_timestamp() + self.assertIn("2024-06-15", result) + + def test_format_timestamp_without_timestamp(self) -> None: + """format_timestamp should return empty string when timestamp is None.""" + from ovos_utils.log_parser import LogLine + ll = LogLine() + self.assertEqual(ll.format_timestamp(), "") + + +class TestFrame(unittest.TestCase): + """Tests for the Frame class.""" + + def test_as_dict(self) -> None: + """as_dict should return location, level, and message keys.""" + from ovos_utils.log_parser import Frame + frame = Frame( + filename="/usr/lib/python3.10/site-packages/my_package/module.py", + lineno=42, + name="my_function", + line=" raise ValueError('oops')" + ) + d = frame.as_dict() + self.assertIn("location", d) + self.assertIn("level", d) + self.assertIn("message", d) + self.assertEqual(d["level"], "TRACEBACK") + + def test_as_logline(self) -> None: + """as_logline should return a LogLine instance.""" + from ovos_utils.log_parser import Frame, LogLine + frame = Frame( + filename="/usr/lib/python3.10/site-packages/my_pkg/mod.py", + lineno=10, + name="func", + line=" x = 1" + ) + ll = frame.as_logline() + self.assertIsInstance(ll, LogLine) + + def test_format_location_site_packages(self) -> None: + """format_location should extract package from site-packages path.""" + from ovos_utils.log_parser import Frame + frame = Frame( + filename="/home/user/.venv/lib/python3.10/site-packages/my_pkg/module.py", + lineno=5, + name="do_thing", + line=" pass" + ) + loc = frame.format_location() + self.assertIn("my_pkg", loc) + self.assertIn("module", loc) + + def test_format_location_bin_path(self) -> None: + """format_location should handle /bin/ paths.""" + from ovos_utils.log_parser import Frame + frame = Frame( + filename="/usr/bin/my-script.py", + lineno=1, + name="main", + line=" pass" + ) + loc = frame.format_location() + self.assertIn("my_script", loc) + + def test_str_representation(self) -> None: + """__str__ should produce a traceback-style string.""" + from ovos_utils.log_parser import Frame + frame = Frame( + filename="/path/to/file.py", + lineno=99, + name="some_func", + line=" raise Exception" + ) + s = str(frame) + self.assertIn("99", s) + self.assertIn("some_func", s) + + +class TestTraceback(unittest.TestCase): + """Tests for the Traceback class.""" + + TRACEBACK_STR = ( + 'Traceback (most recent call last):\n' + ' File "/path/to/module.py", line 10, in handler\n' + ' raise RuntimeError("bad")\n' + 'RuntimeError: bad\n' + ) + + def test_from_string(self) -> None: + """from_string should parse frames and exception from a traceback string.""" + from ovos_utils.log_parser import Traceback + tb = Traceback.from_string(self.TRACEBACK_STR) + self.assertEqual(len(tb.frames), 1) + self.assertIn("RuntimeError", tb.exception) + + def test_from_list(self) -> None: + """from_list should work like from_string given a list of lines.""" + from ovos_utils.log_parser import Traceback + lines = self.TRACEBACK_STR.splitlines(keepends=True) + tb = Traceback.from_list(lines) + self.assertGreater(len(tb.frames), 0) + + def test_to_loglines(self) -> None: + """to_loglines should return a list of LogLine objects.""" + from ovos_utils.log_parser import Traceback, LogLine + tb = Traceback.from_string(self.TRACEBACK_STR) + ts = datetime(2024, 1, 1) + tb.timestamp = ts + log_lines = tb.to_loglines() + self.assertIsInstance(log_lines, list) + self.assertTrue(all(isinstance(ll, LogLine) for ll in log_lines)) + self.assertEqual(log_lines[0].level, "EXCEPTION") + + def test_exception_location(self) -> None: + """exception_location should return location of last frame.""" + from ovos_utils.log_parser import Traceback + tb = Traceback.from_string(self.TRACEBACK_STR) + loc = tb.exception_location + self.assertIsNotNone(loc) + + def test_str(self) -> None: + """__str__ should produce a readable traceback string.""" + from ovos_utils.log_parser import Traceback + tb = Traceback.from_string(self.TRACEBACK_STR) + s = str(tb) + self.assertIn("Traceback", s) + self.assertIn("RuntimeError", s) + + def test_timestamp_property(self) -> None: + """Traceback timestamp getter/setter should work correctly.""" + from ovos_utils.log_parser import Traceback + tb = Traceback.from_string(self.TRACEBACK_STR) + ts = datetime(2024, 3, 15, 10, 0) + tb.timestamp = ts + self.assertEqual(tb.timestamp, ts) + + +class TestOVOSLogParser(unittest.TestCase): + """Tests for OVOSLogParser class methods.""" + + VALID_LOG_LINE = ( + "2024-01-15 09:30:00.123456 - skills - my_skill:handler:42 - INFO - Skill started\n" + ) + + def test_parse_valid_line(self) -> None: + """parse should correctly parse a standard OVOS log line.""" + from ovos_utils.log_parser import OVOSLogParser, LogLine + result = OVOSLogParser.parse(self.VALID_LOG_LINE) + self.assertIsInstance(result, LogLine) + self.assertEqual(result.source, "skills") + self.assertEqual(result.level, "INFO") + self.assertIn("Skill started", result.message) + + def test_parse_invalid_line(self) -> None: + """parse should return a LogLine with just the message for non-matching lines.""" + from ovos_utils.log_parser import OVOSLogParser, LogLine + result = OVOSLogParser.parse("random non-log line\n") + self.assertIsInstance(result, LogLine) + self.assertIn("random non-log line", result.message) + + def test_parse_invalid_line_with_last_timestamp(self) -> None: + """parse should propagate last_timestamp to non-matching lines.""" + from ovos_utils.log_parser import OVOSLogParser + ts = datetime(2024, 5, 5) + result = OVOSLogParser.parse("some system message\n", last_timestamp=ts) + self.assertEqual(result.timestamp, ts) + + 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 + + content = ( + "2024-01-15 09:30:00.123456 - skills - my_skill:h:1 - INFO - Starting\n" + "2024-01-15 09:30:01.000000 - skills - my_skill:h:2 - DEBUG - Running\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)) + self.assertGreater(len(results), 0) + for item in results: + self.assertIsInstance(item, LogLine) + finally: + os.unlink(fname) + + def test_parse_file_with_traceback(self) -> None: + """parse_file should yield Traceback objects when tracebacks are present.""" + from ovos_utils.log_parser import OVOSLogParser, Traceback + + content = ( + "2024-01-15 09:30:00.123456 - skills - sk:h:1 - ERROR - Error occurred\n" + "Traceback (most recent call last):\n" + ' File "/path/to/file.py", line 5, in run\n' + " raise ValueError('oops')\n" + "ValueError: oops\n" + "\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)) + tb_items = [r for r in results if isinstance(r, Traceback)] + self.assertGreater(len(tb_items), 0) + finally: + os.unlink(fname) + + def test_parse_file_not_found(self) -> None: + """parse_file should raise FileNotFoundError for missing files.""" + from ovos_utils.log_parser import OVOSLogParser + with self.assertRaises(FileNotFoundError): + list(OVOSLogParser.parse_file("/nonexistent/path/file.log")) + + def test_parse_file_skips_blank_lines(self) -> None: + """parse_file should skip lines that are just newlines.""" + from ovos_utils.log_parser import OVOSLogParser, LogLine + + content = ( + "2024-01-15 09:30:00.123456 - skills - sk:h:1 - INFO - Line one\n" + "\n" + "2024-01-15 09:30:01.000000 - skills - sk:h:2 - INFO - Line two\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)) + log_lines = [r for r in results if isinstance(r, LogLine)] + self.assertEqual(len(log_lines), 2) + finally: + os.unlink(fname) + + +class TestParseTime(unittest.TestCase): + """Tests for the parse_time helper.""" + + def test_valid_time_string(self) -> None: + """parse_time should return a datetime for a valid string.""" + from ovos_utils.log_parser import parse_time + result = parse_time("2024-01-15 09:30:00") + self.assertIsInstance(result, datetime) + + def test_invalid_time_string(self) -> None: + """parse_time should return None for an invalid string.""" + from ovos_utils.log_parser import parse_time + result = parse_time("not_a_date") + self.assertIsNone(result) + + +class TestGetTimestampedFilename(unittest.TestCase): + """Tests for get_timestamped_filename helper.""" + + def test_returns_string(self) -> None: + """get_timestamped_filename should return a string path.""" + from ovos_utils.log_parser import get_timestamped_filename + result = get_timestamped_filename("test", "log") + self.assertIsInstance(result, str) + self.assertIn("test_", result) + self.assertTrue(result.endswith(".log")) + + def test_custom_basedir(self) -> None: + """get_timestamped_filename should honour a custom basedir.""" + from ovos_utils.log_parser import get_timestamped_filename + result = get_timestamped_filename("myfile", "txt", basedir="/tmp") + self.assertTrue(result.startswith("/tmp")) + + def test_tilde_expanded(self) -> None: + """get_timestamped_filename should expand ~ to the home directory.""" + from ovos_utils.log_parser import get_timestamped_filename + result = get_timestamped_filename("file", "log", basedir="~") + self.assertNotIn("~", result) + + +class TestValidLog(unittest.TestCase): + """Tests for valid_log helper.""" + + def test_valid_log_true(self) -> None: + """valid_log should return True when all requested logs are available.""" + from ovos_utils.log_parser import valid_log + with unittest.mock.patch( + "ovos_utils.log_parser.get_available_logs", return_value=["bus", "skills"] + ): + self.assertTrue(valid_log(["bus"], ["/tmp"])) + + def test_valid_log_false(self) -> None: + """valid_log should return False when a requested log is not available.""" + from ovos_utils.log_parser import valid_log + with unittest.mock.patch( + "ovos_utils.log_parser.get_available_logs", return_value=["bus"] + ): + self.assertFalse(valid_log(["nonexistent"], ["/tmp"])) + + +class TestParseTimeframe(unittest.TestCase): + """Tests for parse_timeframe utility.""" + + def test_start_none_uses_last_load_time(self) -> None: + """parse_timeframe with start=None should call get_last_load_time.""" + from ovos_utils.log_parser import parse_timeframe + fixed_dt = datetime(2024, 1, 1) + with unittest.mock.patch("ovos_utils.log_parser.get_last_load_time", + return_value=fixed_dt): + start, end = parse_timeframe(None, None) + self.assertEqual(start, fixed_dt) + self.assertIsNotNone(end) + + def test_explicit_start_and_end(self) -> None: + """parse_timeframe with explicit strings should parse both.""" + from ovos_utils.log_parser import parse_timeframe + start, end = parse_timeframe("2024-01-01 00:00:00", "2024-01-01 12:00:00") + self.assertIsNotNone(start) + self.assertIsNotNone(end) + self.assertLess(start, end) + + def test_end_none_uses_now(self) -> None: + """parse_timeframe with end=None should use datetime.now().""" + from ovos_utils.log_parser import parse_timeframe + fixed_start = datetime(2024, 1, 1) + with unittest.mock.patch("ovos_utils.log_parser.get_last_load_time", + return_value=fixed_start): + start, end = parse_timeframe(None, None) + self.assertGreater(end, fixed_start) + + def test_invalid_start_returns_none(self) -> None: + """parse_timeframe with invalid start string should return None for start.""" + from ovos_utils.log_parser import parse_timeframe + start, end = parse_timeframe("not_a_date", "2024-01-01") + self.assertIsNone(start) + + +class TestGetLastLoadTime(unittest.TestCase): + """Tests for get_last_load_time utility.""" + + def test_returns_epoch_when_no_directory(self) -> None: + """get_last_load_time should return epoch datetime when no log directory found.""" + from ovos_utils.log_parser import get_last_load_time + with unittest.mock.patch("ovos_utils.log_parser.get_log_path", return_value=None): + result = get_last_load_time() + self.assertEqual(result, datetime.fromtimestamp(0)) + + def test_reads_log_for_last_load(self) -> None: + """get_last_load_time should parse skills.log to find last load time.""" + import tempfile + import os + from ovos_utils.log_parser import get_last_load_time + + log_content = ( + "2024-03-01 10:00:00.000000 - skills - sk:h:1 - INFO - Loading message bus configs\n" + "2024-03-01 10:01:00.000000 - skills - sk:h:2 - INFO - Skills loaded\n" + ) + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "skills.log") + with open(log_path, "w") as f: + f.write(log_content) + with unittest.mock.patch("ovos_utils.log_parser.get_log_path", + return_value=tmpdir): + result = get_last_load_time() + self.assertIsInstance(result, datetime) + self.assertGreater(result, datetime.fromtimestamp(0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_messagebus.py b/test/unittests/test_messagebus.py index 8a01676c..48ce6297 100644 --- a/test/unittests/test_messagebus.py +++ b/test/unittests/test_messagebus.py @@ -1,6 +1,39 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for the deprecated ovos_utils.messagebus module.""" + +import sys import unittest +import warnings class TestMessagebus(unittest.TestCase): - # TODO: Implement tests or move utils to `ovos-bus-client` package - pass \ No newline at end of file + """Tests for deprecated messagebus module imports.""" + + def test_import_emits_deprecation_warning(self) -> None: + """Importing ovos_utils.messagebus should emit a DeprecationWarning.""" + # Remove cached module to force a fresh import + sys.modules.pop("ovos_utils.messagebus", None) + with self.assertWarns(DeprecationWarning): + import ovos_utils.messagebus # noqa: F401 + + def test_fakebus_symbols_re_exported(self) -> None: + """The messagebus module should re-export FakeBus and Message.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + sys.modules.pop("ovos_utils.messagebus", None) + import ovos_utils.messagebus as mb + self.assertTrue(hasattr(mb, "FakeBus")) + self.assertTrue(hasattr(mb, "Message")) diff --git a/test/unittests/test_metrics.py b/test/unittests/test_metrics.py index 6a676347..3cf85431 100644 --- a/test/unittests/test_metrics.py +++ b/test/unittests/test_metrics.py @@ -1,6 +1,8 @@ +# Copyright 2024, OpenVoiceOS +# Licensed under the Apache License, Version 2.0 +import time import unittest -from time import sleep from ovos_utils.metrics import Stopwatch @@ -10,25 +12,113 @@ def test_stopwatch_simple(self): sleep_time = 1.00 stopwatch = Stopwatch() with stopwatch: - sleep(sleep_time) - self.assertLess(abs(stopwatch.time - sleep_time), 2) + time.sleep(sleep_time) + self.assertEqual(round(stopwatch.time, 2), sleep_time) def test_stopwatch_reuse(self): sleep_time = 0.5 stopwatch = Stopwatch() with stopwatch: - sleep(sleep_time) - self.assertLess(abs(stopwatch.time - sleep_time), 0.01) + time.sleep(sleep_time) + self.assertEqual(round(stopwatch.time, 2), sleep_time) with stopwatch: - sleep(sleep_time) - self.assertLess(abs(stopwatch.time - sleep_time), 0.01) + time.sleep(sleep_time) + self.assertEqual(round(stopwatch.time, 2), sleep_time) with stopwatch: - sleep(sleep_time) - self.assertLess(abs(stopwatch.time - sleep_time), 0.01) + time.sleep(sleep_time) + self.assertEqual(round(stopwatch.time, 2), sleep_time) def test_stopwatch_no_start(self): stopwatch = Stopwatch() - time = stopwatch.stop() - self.assertEqual(time, 0.0) + elapsed = stopwatch.stop() + self.assertEqual(elapsed, 0.0) + + def test_start_and_stop(self): + sw = Stopwatch() + sw.start() + time.sleep(0.1) + elapsed = sw.stop() + self.assertGreater(elapsed, 0.0) + self.assertIsNone(sw.timestamp) # stopped + self.assertAlmostEqual(elapsed, 0.1, delta=0.05) + + def test_lap(self): + sw = Stopwatch() + sw.start() + time.sleep(0.1) + lap1 = sw.lap() + time.sleep(0.1) + lap2 = sw.lap() + self.assertGreater(lap1, 0.0) + self.assertGreater(lap2, 0.0) + + def test_delta_when_running(self): + sw = Stopwatch() + sw.start() + time.sleep(0.05) + delta = sw.delta + self.assertGreater(delta, 0.0) + + def test_delta_when_stopped(self): + sw = Stopwatch() + # Not started — delta should be 0 + self.assertEqual(sw.delta, 0) + + def test_delta_after_stop(self): + sw = Stopwatch() + sw.start() + time.sleep(0.05) + sw.stop() + delta = sw.delta + # After stop, timestamp is None, so delta returns self.time + self.assertGreater(delta, 0.0) + + def test_str_not_started(self): + sw = Stopwatch() + self.assertEqual(str(sw), "Not started") + + def test_str_running(self): + sw = Stopwatch() + sw.start() + s = str(sw) + # Should be a numeric string (time elapsed) + self.assertNotEqual(s, "Not started") + float(s) # Should be parseable as float + + def test_str_stopped(self): + sw = Stopwatch() + sw.start() + time.sleep(0.05) + sw.stop() + # After stop, timestamp is None, str returns "Not started" unless time set + # Based on implementation: if timestamp is set returns time, else "Not started" + # After stop: timestamp = None, so "Not started" + s = str(sw) + self.assertEqual(s, "Not started") + + def test_context_manager_sets_time(self): + sw = Stopwatch() + with sw: + time.sleep(0.05) + self.assertIsNotNone(sw.time) + self.assertGreater(sw.time, 0.0) + + def test_initial_state(self): + sw = Stopwatch() + self.assertIsNone(sw.timestamp) + self.assertIsNone(sw.time) + + def test_start_resets_time(self): + sw = Stopwatch() + sw.start() + time.sleep(0.05) + sw.stop() + old_time = sw.time + sw.start() + self.assertIsNone(sw.time) # reset on start + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_network_utils_extra.py b/test/unittests/test_network_utils_extra.py new file mode 100644 index 00000000..11eec09d --- /dev/null +++ b/test/unittests/test_network_utils_extra.py @@ -0,0 +1,165 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Additional unit tests for ovos_utils.network_utils module.""" + +import unittest +from unittest.mock import MagicMock, patch + + +class TestIsValidIp(unittest.TestCase): + """Tests for is_valid_ip.""" + + def test_valid_ipv4(self) -> None: + """is_valid_ip should return True for a valid IPv4 address.""" + from ovos_utils.network_utils import is_valid_ip + self.assertTrue(is_valid_ip("8.8.8.8")) + + def test_valid_ipv6(self) -> None: + """is_valid_ip should return True for a valid IPv6 address.""" + from ovos_utils.network_utils import is_valid_ip + self.assertTrue(is_valid_ip("2001:db8::1")) + + def test_invalid_ip(self) -> None: + """is_valid_ip should return False for an invalid address string.""" + from ovos_utils.network_utils import is_valid_ip + self.assertFalse(is_valid_ip("not_an_ip")) + self.assertFalse(is_valid_ip("999.999.999.999")) + + +class TestGetExternalIp(unittest.TestCase): + """Tests for get_external_ip with mocked HTTP.""" + + @patch("ovos_utils.network_utils.requests.get") + def test_returns_ip_from_service(self, mock_get: MagicMock) -> None: + """get_external_ip should return IP text from ipify.org.""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.text = "1.2.3.4" + mock_get.return_value = mock_resp + from ovos_utils.network_utils import get_external_ip + result = get_external_ip() + self.assertEqual(result, "1.2.3.4") + + @patch("ovos_utils.network_utils.requests.get") + def test_returns_fallback_on_failure(self, mock_get: MagicMock) -> None: + """get_external_ip should return '0.0.0.0' on request failure.""" + mock_get.side_effect = Exception("network error") + from ovos_utils.network_utils import get_external_ip + result = get_external_ip() + self.assertEqual(result, "0.0.0.0") + + @patch("ovos_utils.network_utils.requests.get") + def test_returns_fallback_on_bad_status(self, mock_get: MagicMock) -> None: + """get_external_ip should return '0.0.0.0' when response is not OK.""" + mock_resp = MagicMock() + mock_resp.ok = False + mock_resp.status_code = 503 + mock_resp.text = "" + mock_get.return_value = mock_resp + from ovos_utils.network_utils import get_external_ip + result = get_external_ip() + self.assertEqual(result, "0.0.0.0") + + +class TestIsConnectedDns(unittest.TestCase): + """Tests for is_connected_dns with mocked socket.""" + + @patch("ovos_utils.network_utils.socket.socket") + def test_returns_true_when_connection_succeeds( + self, mock_socket_cls: MagicMock + ) -> None: + """is_connected_dns should return True when socket connect succeeds.""" + mock_sock = MagicMock() + mock_socket_cls.return_value = mock_sock + from ovos_utils.network_utils import is_connected_dns + result = is_connected_dns("1.1.1.1") + self.assertTrue(result) + + @patch("ovos_utils.network_utils.socket.socket") + def test_returns_false_when_connection_fails( + self, mock_socket_cls: MagicMock + ) -> None: + """is_connected_dns should return False when socket connect raises OSError.""" + mock_sock = MagicMock() + mock_sock.connect.side_effect = OSError("refused") + mock_socket_cls.return_value = mock_sock + from ovos_utils.network_utils import is_connected_dns + result = is_connected_dns("1.1.1.1") + self.assertFalse(result) + + +class TestIsConnectedHttp(unittest.TestCase): + """Tests for is_connected_http.""" + + @patch("ovos_utils.network_utils.requests.head") + def test_returns_true_on_success(self, mock_head: MagicMock) -> None: + """is_connected_http should return True when HTTP head succeeds.""" + mock_head.return_value = MagicMock(status_code=200) + from ovos_utils.network_utils import is_connected_http + result = is_connected_http("http://example.com") + self.assertTrue(result) + + @patch("ovos_utils.network_utils.requests.head") + def test_returns_false_on_exception(self, mock_head: MagicMock) -> None: + """is_connected_http should return False when request raises.""" + mock_head.side_effect = Exception("unreachable") + from ovos_utils.network_utils import is_connected_http + result = is_connected_http("http://example.com") + self.assertFalse(result) + + +class TestCheckCaptivePortal(unittest.TestCase): + """Tests for check_captive_portal.""" + + @patch("ovos_utils.network_utils.requests.get") + def test_no_captive_portal(self, mock_get: MagicMock) -> None: + """check_captive_portal should return False when expected text is found.""" + mock_resp = MagicMock() + mock_resp.text = "NetworkManager is online" + mock_get.return_value = mock_resp + from ovos_utils.network_utils import check_captive_portal + result = check_captive_portal( + host="http://nmcheck.gnome.org/check_network_status.txt", + expected_text="NetworkManager is online", + ) + self.assertFalse(result) + + @patch("ovos_utils.network_utils.requests.get") + def test_captive_portal_detected(self, mock_get: MagicMock) -> None: + """check_captive_portal should return True when text differs (redirect).""" + mock_resp = MagicMock() + mock_resp.text = "Login to network" + mock_get.return_value = mock_resp + from ovos_utils.network_utils import check_captive_portal + result = check_captive_portal( + host="http://nmcheck.gnome.org/check_network_status.txt", + expected_text="NetworkManager is online", + ) + self.assertTrue(result) + + @patch("ovos_utils.network_utils.requests.get") + def test_returns_false_on_exception(self, mock_get: MagicMock) -> None: + """check_captive_portal should return False when request raises.""" + mock_get.side_effect = Exception("timeout") + from ovos_utils.network_utils import check_captive_portal + result = check_captive_portal( + host="http://nmcheck.gnome.org/check_network_status.txt", + expected_text="NetworkManager is online", + ) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_oauth.py b/test/unittests/test_oauth.py new file mode 100644 index 00000000..8a996e73 --- /dev/null +++ b/test/unittests/test_oauth.py @@ -0,0 +1,354 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.oauth module.""" + +import time +import unittest +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch, call + +if TYPE_CHECKING: + from ovos_utils.oauth import OAuthTokenDatabase, OAuthApplicationDatabase + + +class TestOAuthTokenDatabase(unittest.TestCase): + """Tests for OAuthTokenDatabase CRUD methods.""" + + def _make_db(self) -> "OAuthTokenDatabase": + """Create an OAuthTokenDatabase backed by a temp file.""" + from ovos_utils.oauth import OAuthTokenDatabase + with patch("ovos_utils.oauth.get_xdg_cache_save_path", return_value="/tmp"): + db = OAuthTokenDatabase.__new__(OAuthTokenDatabase) + # Initialise as plain dict (bypass JsonStorageXDG file I/O) + dict.__init__(db) + return db + + def test_add_and_get_token(self) -> None: + """add_token should store a token retrievable by get_token.""" + db = self._make_db() + db.add_token("tok1", {"access_token": "abc"}) + result = db.get_token("tok1") + self.assertEqual(result["access_token"], "abc") + + def test_update_token(self) -> None: + """update_token should overwrite an existing token.""" + db = self._make_db() + db.add_token("tok1", {"access_token": "old"}) + db.update_token("tok1", {"access_token": "new"}) + self.assertEqual(db.get_token("tok1")["access_token"], "new") + + def test_delete_token_present(self) -> None: + """delete_token should return True and remove an existing token.""" + db = self._make_db() + db.add_token("tok1", {"access_token": "abc"}) + result = db.delete_token("tok1") + self.assertTrue(result) + self.assertIsNone(db.get_token("tok1")) + + def test_delete_token_absent(self) -> None: + """delete_token should return False for a token that does not exist.""" + db = self._make_db() + result = db.delete_token("nonexistent") + self.assertFalse(result) + + def test_total_tokens(self) -> None: + """total_tokens should return the number of stored tokens.""" + db = self._make_db() + db.add_token("t1", {}) + db.add_token("t2", {}) + self.assertEqual(db.total_tokens(), 2) + + +class TestOAuthApplicationDatabase(unittest.TestCase): + """Tests for OAuthApplicationDatabase CRUD methods.""" + + def _make_db(self) -> "OAuthApplicationDatabase": + """Create an OAuthApplicationDatabase as a plain dict subclass.""" + from ovos_utils.oauth import OAuthApplicationDatabase + with patch("ovos_utils.oauth.get_xdg_cache_save_path", return_value="/tmp"): + db = OAuthApplicationDatabase.__new__(OAuthApplicationDatabase) + dict.__init__(db) + return db + + def test_add_and_get_application(self) -> None: + """add_application should store data retrievable by get_application.""" + db = self._make_db() + db.add_application( + oauth_service="myapp", + client_id="id123", + client_secret="secret456", + auth_endpoint="https://auth.example.com", + token_endpoint="https://token.example.com", + callback_endpoint="https://callback.example.com", + scope="read write", + ) + result = db.get_application("myapp") + self.assertIsNotNone(result) + self.assertEqual(result["client_id"], "id123") + + def test_delete_application_present(self) -> None: + """delete_application should return True and remove an existing app.""" + db = self._make_db() + db.add_application("app1", "i", "s", "a", "t", "c", "scope") + result = db.delete_application("app1") + self.assertTrue(result) + self.assertIsNone(db.get_application("app1")) + + def test_delete_application_absent(self) -> None: + """delete_application should return False when app does not exist.""" + db = self._make_db() + result = db.delete_application("nope") + self.assertFalse(result) + + def test_total_apps(self) -> None: + """total_apps should return the number of registered applications.""" + db = self._make_db() + db.add_application("svc1", "i", "s", "a", "t", "c", "scope") + db.add_application("svc2", "i", "s", "a", "t", "c", "scope") + self.assertEqual(db.total_apps(), 2) + + def test_update_application(self) -> None: + """update_application should overwrite existing app data.""" + db = self._make_db() + db.add_application("svc", "old_id", "s", "a", "t", "c", "scope") + db.update_application("svc", "new_id", "s", "a", "t", "c", "scope") + self.assertEqual(db.get_application("svc")["client_id"], "new_id") + + +class TestRefreshOAuthToken(unittest.TestCase): + """Tests for refresh_oauth_token function.""" + + def test_returns_none_when_no_app_data(self) -> None: + """refresh_oauth_token should return None when app_data is missing.""" + from ovos_utils.oauth import refresh_oauth_token + + with patch("ovos_utils.oauth.OAuthApplicationDatabase") as mock_app_db_cls, \ + patch("ovos_utils.oauth.OAuthTokenDatabase") as mock_tok_db_cls: + + mock_app_ctx = MagicMock() + mock_app_ctx.__enter__ = MagicMock(return_value=mock_app_ctx) + mock_app_ctx.__exit__ = MagicMock(return_value=False) + mock_app_ctx.get.return_value = None + mock_app_db_cls.return_value = mock_app_ctx + + mock_tok_ctx = MagicMock() + mock_tok_ctx.__enter__ = MagicMock(return_value=mock_tok_ctx) + mock_tok_ctx.__exit__ = MagicMock(return_value=False) + mock_tok_ctx.get.return_value = {"refresh_token": "rr"} + mock_tok_db_cls.return_value = mock_tok_ctx + + result = refresh_oauth_token("nonexistent_id") + self.assertIsNone(result) + + def test_returns_none_when_no_refresh_token(self) -> None: + """refresh_oauth_token should return None when token has no refresh_token.""" + from ovos_utils.oauth import refresh_oauth_token + + with patch("ovos_utils.oauth.OAuthApplicationDatabase") as mock_app_db_cls, \ + patch("ovos_utils.oauth.OAuthTokenDatabase") as mock_tok_db_cls: + + mock_app_ctx = MagicMock() + mock_app_ctx.__enter__ = MagicMock(return_value=mock_app_ctx) + mock_app_ctx.__exit__ = MagicMock(return_value=False) + mock_app_ctx.get.return_value = {"token_endpoint": "https://t.example.com"} + mock_app_db_cls.return_value = mock_app_ctx + + mock_tok_ctx = MagicMock() + mock_tok_ctx.__enter__ = MagicMock(return_value=mock_tok_ctx) + mock_tok_ctx.__exit__ = MagicMock(return_value=False) + mock_tok_ctx.get.return_value = {} # no refresh_token + mock_tok_db_cls.return_value = mock_tok_ctx + + result = refresh_oauth_token("some_id") + self.assertIsNone(result) + + +class TestRefreshOAuthTokenWithMockedOauthLib(unittest.TestCase): + """Tests for refresh_oauth_token when app and token data are present.""" + + def test_successful_refresh_stores_new_token(self) -> None: + """refresh_oauth_token should update the DB when refresh POST succeeds.""" + from ovos_utils.oauth import refresh_oauth_token + + app_data = { + "token_endpoint": "https://token.example.com", + "client_id": "client_id", + "client_secret": "client_secret", + } + token_data = { + "refresh_token": "old_rt", + "expires_in": 3600, + } + new_token_data = { + "access_token": "new_at", + "refresh_token": "new_rt", + "expires_in": 3600, + } + + # Use side_effect to provide different context managers per instantiation order + call_count = [0] + + def make_ctx(data): + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.get.return_value = data + ctx.update_token = MagicMock() + return ctx + + app_ctx = make_ctx(app_data) + tok_ctx1 = make_ctx(token_data) + tok_ctx2 = make_ctx(token_data) + + app_db_instances = [app_ctx] + tok_db_instances = [tok_ctx1, tok_ctx2] + + def app_db_factory(*args, **kwargs): + return app_db_instances.pop(0) + + def tok_db_factory(*args, **kwargs): + return tok_db_instances.pop(0) + + mock_refresh_result = MagicMock() + mock_refresh_result.ok = True + mock_refresh_result.json.return_value = new_token_data + + mock_client = MagicMock() + mock_client.prepare_refresh_token_request.return_value = ( + "https://token.example.com", {}, "body" + ) + mock_wac = MagicMock(return_value=mock_client) + + with patch("ovos_utils.oauth.OAuthApplicationDatabase", + side_effect=app_db_factory), \ + patch("ovos_utils.oauth.OAuthTokenDatabase", + side_effect=tok_db_factory), \ + patch("ovos_utils.oauth.requests.post", + return_value=mock_refresh_result), \ + patch.dict("sys.modules", { + "oauthlib": MagicMock(), + "oauthlib.oauth2": MagicMock(WebApplicationClient=mock_wac), + }): + result = refresh_oauth_token("my_token_id") + + # Result should be the (updated) token_data dict + self.assertIsNotNone(result) + + def test_failed_refresh_still_returns_token_data(self) -> None: + """refresh_oauth_token should return token_data even on failed POST.""" + from ovos_utils.oauth import refresh_oauth_token + + app_data = { + "token_endpoint": "https://token.example.com", + "client_id": "id", + "client_secret": "secret", + } + token_data = { + "refresh_token": "rt", + "expires_in": 3600, + } + + mock_app_ctx = MagicMock() + mock_app_ctx.__enter__ = MagicMock(return_value=mock_app_ctx) + mock_app_ctx.__exit__ = MagicMock(return_value=False) + mock_app_ctx.get.return_value = app_data + + mock_tok_ctx = MagicMock() + mock_tok_ctx.__enter__ = MagicMock(return_value=mock_tok_ctx) + mock_tok_ctx.__exit__ = MagicMock(return_value=False) + mock_tok_ctx.get.return_value = token_data + + mock_refresh_result = MagicMock() + mock_refresh_result.ok = False + + mock_client = MagicMock() + mock_client.prepare_refresh_token_request.return_value = ("uri", {}, "body") + + with patch("ovos_utils.oauth.OAuthApplicationDatabase", + return_value=mock_app_ctx), \ + patch("ovos_utils.oauth.OAuthTokenDatabase", + return_value=mock_tok_ctx), \ + patch("ovos_utils.oauth.requests.post", + return_value=mock_refresh_result): + mock_wac = MagicMock(return_value=mock_client) + with patch.dict("sys.modules", { + "oauthlib": MagicMock(), + "oauthlib.oauth2": MagicMock(WebApplicationClient=mock_wac), + }): + result = refresh_oauth_token("tok_id") + + self.assertIsNotNone(result) + + +class TestGetOAuthToken(unittest.TestCase): + """Tests for get_oauth_token function.""" + + def test_no_auto_refresh_returns_token(self) -> None: + """get_oauth_token with auto_refresh=False should return stored token.""" + from ovos_utils.oauth import get_oauth_token + + mock_db = MagicMock() + mock_db.get_token.return_value = {"access_token": "xyz"} + + with patch("ovos_utils.oauth.OAuthTokenDatabase", return_value=mock_db): + result = get_oauth_token("tok_id", auto_refresh=False) + self.assertEqual(result["access_token"], "xyz") + + def test_auto_refresh_when_no_expires_at(self) -> None: + """get_oauth_token with auto_refresh=True should refresh if no expires_at.""" + from ovos_utils.oauth import get_oauth_token + + token_data = {"access_token": "old", "refresh_token": "rt"} + + mock_db_ctx = MagicMock() + mock_db_ctx.__enter__ = MagicMock(return_value=mock_db_ctx) + mock_db_ctx.__exit__ = MagicMock(return_value=False) + mock_db_ctx.get.return_value = token_data + + with patch("ovos_utils.oauth.OAuthTokenDatabase", + return_value=mock_db_ctx), \ + patch("ovos_utils.oauth.refresh_oauth_token", + return_value={"access_token": "new"}) as mock_refresh: + result = get_oauth_token("tok_id", auto_refresh=True) + + mock_refresh.assert_called_once_with("tok_id") + + def test_auto_refresh_when_expired(self) -> None: + """get_oauth_token should refresh when expires_at is in the past (<= now).""" + import time as _time + from ovos_utils.oauth import get_oauth_token + + # expires_at <= time.time() means token has expired and refresh is needed + token_data = { + "access_token": "old", + "expires_at": _time.time() - 1000, # past date → token expired + } + + mock_db_ctx = MagicMock() + mock_db_ctx.__enter__ = MagicMock(return_value=mock_db_ctx) + mock_db_ctx.__exit__ = MagicMock(return_value=False) + mock_db_ctx.get.return_value = token_data + + with patch("ovos_utils.oauth.OAuthTokenDatabase", + return_value=mock_db_ctx), \ + patch("ovos_utils.oauth.refresh_oauth_token", + return_value={"access_token": "new"}) as mock_refresh: + get_oauth_token("tok_id", auto_refresh=True) + + mock_refresh.assert_called_once_with("tok_id") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py new file mode 100644 index 00000000..e42a7fe5 --- /dev/null +++ b/test/unittests/test_ocp.py @@ -0,0 +1,601 @@ +# 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. +# +import unittest +import warnings + +# Suppress DeprecationWarning during test collection +warnings.filterwarnings("ignore", category=DeprecationWarning, module="ovos_utils.ocp") + +from ovos_utils.ocp import ( + MediaEntry, + MediaType, + PlaybackType, + TrackState, + MediaState, + PlayerState, + LoopState, + PlaybackMode, + MatchConfidence, + Playlist, + PluginStream, + find_mime, + OCP_ID, +) + + +class TestEnums(unittest.TestCase): + def test_media_type_values(self) -> None: + self.assertEqual(MediaType.GENERIC, 0) + self.assertEqual(MediaType.MUSIC, 2) + self.assertEqual(MediaType.MOVIE, 10) + self.assertEqual(MediaType.ADULT, 69) + + def test_playback_type_values(self) -> None: + self.assertEqual(PlaybackType.SKILL, 0) + self.assertEqual(PlaybackType.VIDEO, 1) + self.assertEqual(PlaybackType.UNDEFINED, 100) + + def test_track_state_values(self) -> None: + self.assertEqual(TrackState.DISAMBIGUATION, 1) + self.assertEqual(TrackState.PLAYING_AUDIO, 23) + + def test_media_state_values(self) -> None: + self.assertEqual(MediaState.UNKNOWN, 0) + self.assertEqual(MediaState.END_OF_MEDIA, 7) + + def test_player_state_values(self) -> None: + self.assertEqual(PlayerState.STOPPED, 0) + self.assertEqual(PlayerState.PLAYING, 1) + self.assertEqual(PlayerState.PAUSED, 2) + + def test_loop_state(self) -> None: + self.assertEqual(LoopState.NONE, 0) + self.assertEqual(LoopState.REPEAT, 1) + self.assertEqual(LoopState.REPEAT_TRACK, 2) + + def test_playback_mode(self) -> None: + self.assertEqual(PlaybackMode.AUTO, 0) + self.assertEqual(PlaybackMode.AUDIO_ONLY, 10) + self.assertEqual(PlaybackMode.EVENTS_ONLY, 50) + + def test_match_confidence(self) -> None: + self.assertEqual(MatchConfidence.EXACT, 95) + self.assertEqual(MatchConfidence.VERY_LOW, 1) + + +class TestFindMime(unittest.TestCase): + def test_mp3(self) -> None: + mime = find_mime("song.mp3") + self.assertIsNotNone(mime) + self.assertIn("audio", mime[0]) + + def test_mp4(self) -> None: + mime = find_mime("video.mp4") + self.assertIsNotNone(mime) + self.assertIn("video", mime[0]) + + def test_unknown(self) -> None: + mime = find_mime("http://example.com/stream") + # May return None or a tuple; no crash expected + # Just verify no exception is raised + self.assertTrue(mime is None or isinstance(mime, tuple)) + + +class TestMediaEntry(unittest.TestCase): + def test_defaults(self) -> None: + entry = MediaEntry(uri="http://example.com/audio.mp3") + self.assertEqual(entry.uri, "http://example.com/audio.mp3") + self.assertEqual(entry.skill_id, OCP_ID) + self.assertEqual(entry.media_type, MediaType.GENERIC) + self.assertEqual(entry.playback, PlaybackType.UNDEFINED) + self.assertEqual(entry.status, TrackState.DISAMBIGUATION) + + def test_as_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Test Song") + d = entry.as_dict + self.assertEqual(d["uri"], "http://example.com/a.mp3") + self.assertEqual(d["title"], "Test Song") + + def test_from_dict(self) -> None: + entry = MediaEntry.from_dict({ + "uri": "http://example.com/b.mp3", + "title": "Another Song", + "media_type": MediaType.MUSIC, + }) + self.assertEqual(entry.uri, "http://example.com/b.mp3") + self.assertEqual(entry.title, "Another Song") + self.assertEqual(entry.media_type, MediaType.MUSIC) + + def test_from_dict_missing_uri_raises(self) -> None: + # No 'uri' key → dict2entry raises ValueError (no uri/extractor_id/playlist) + with self.assertRaises(ValueError): + MediaEntry.from_dict({"title": "orphan"}) + + def test_infocard(self) -> None: + entry = MediaEntry(uri="http://example.com/c.mp3", title="Song C", + image="http://example.com/img.png", length=180) + card = entry.infocard + self.assertEqual(card["track"], "Song C") + self.assertEqual(card["duration"], 180) + self.assertEqual(card["uri"], "http://example.com/c.mp3") + + def test_mimetype_mp3(self) -> None: + entry = MediaEntry(uri="http://example.com/song.mp3") + mime = entry.mimetype + self.assertIsNotNone(mime) + self.assertIn("audio", mime[0]) + + def test_mimetype_no_uri(self) -> None: + entry = MediaEntry() + self.assertIsNone(entry.mimetype) + + def test_equality_same(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3", title="A") + e2 = MediaEntry(uri="http://example.com/a.mp3", title="A") + self.assertEqual(e1, e2) + + def test_equality_different(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3") + e2 = MediaEntry(uri="http://example.com/b.mp3") + self.assertNotEqual(e1, e2) + + def test_equality_with_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="A") + self.assertEqual(entry, entry.infocard) + + def test_update_from_dict(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3") + entry.update({"title": "Updated", "length": 120}) + self.assertEqual(entry.title, "Updated") + self.assertEqual(entry.length, 120) + + def test_update_skipkeys(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Original") + entry.update({"title": "New"}, skipkeys=["title"]) + self.assertEqual(entry.title, "Original") + + def test_update_newonly(self) -> None: + entry = MediaEntry(uri="http://example.com/a.mp3", title="Original") + entry.update({"title": "New", "artist": "Artist"}, newonly=True) + self.assertEqual(entry.title, "Original") # existing not replaced + self.assertEqual(entry.artist, "Artist") # new key added + + def test_update_from_media_entry(self) -> None: + e1 = MediaEntry(uri="http://example.com/a.mp3") + e2 = MediaEntry(uri="http://example.com/b.mp3", title="B") + e1.update(e2) + self.assertEqual(e1.uri, "http://example.com/b.mp3") + + +class TestPluginStream(unittest.TestCase): + def test_defaults(self) -> None: + ps = PluginStream(stream="abc123", extractor_id="youtube") + self.assertEqual(ps.stream, "abc123") + self.assertEqual(ps.extractor_id, "youtube") + self.assertEqual(ps.skill_id, OCP_ID) + + def test_as_dict(self) -> None: + ps = PluginStream(stream="vid", extractor_id="yt", title="My Video") + d = ps.as_dict + self.assertEqual(d["stream"], "vid") + self.assertEqual(d["extractor_id"], "yt") + self.assertEqual(d["title"], "My Video") + + def test_from_dict(self) -> None: + ps = PluginStream.from_dict({"stream": "s1", "extractor_id": "yt"}) + self.assertEqual(ps.stream, "s1") + self.assertEqual(ps.extractor_id, "yt") + + def test_from_dict_missing_extractor_raises(self) -> None: + with self.assertRaises(ValueError): + PluginStream.from_dict({"stream": "s1"}) + + def test_from_dict_missing_stream_raises(self) -> None: + with self.assertRaises(ValueError): + PluginStream.from_dict({"extractor_id": "yt"}) + + def test_infocard(self) -> None: + ps = PluginStream(stream="v", extractor_id="yt", title="Title", + image="http://img.png", length=60) + card = ps.infocard + self.assertEqual(card["track"], "Title") + self.assertEqual(card["duration"], 60) + self.assertIn("yt//v", card["uri"]) + + def test_as_media_entry(self) -> None: + ps = PluginStream(stream="vid", extractor_id="yt", title="T", + media_type=MediaType.VIDEO) + me = ps.as_media_entry + self.assertIsInstance(me, MediaEntry) + self.assertIn("yt//vid", me.uri) + + +class TestPlaylist(unittest.TestCase): + def _make_entry(self, uri: str, title: str = "", confidence: int = 0) -> MediaEntry: + return MediaEntry(uri=uri, title=title, match_confidence=confidence) + + def test_empty_playlist(self) -> None: + pl = Playlist() + self.assertEqual(len(pl), 0) + self.assertIsNone(pl.current_track) + self.assertTrue(pl.is_first_track) + self.assertTrue(pl.is_last_track) + + def test_add_entry(self) -> None: + pl = Playlist() + e = self._make_entry("http://example.com/a.mp3", "A") + pl.add_entry(e) + self.assertEqual(len(pl), 1) + + def test_add_multiple_entries(self) -> None: + pl = Playlist() + for i in range(3): + pl.add_entry(self._make_entry(f"http://example.com/{i}.mp3", str(i))) + self.assertEqual(len(pl), 3) + + def test_current_track(self) -> None: + pl = Playlist() + e = self._make_entry("http://example.com/a.mp3", "Track A") + pl.add_entry(e) + track = pl.current_track + self.assertEqual(track.title, "Track A") + + def test_is_first_track(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + self.assertTrue(pl.is_first_track) + pl.position = 1 + self.assertFalse(pl.is_first_track) + + def test_is_last_track(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + self.assertFalse(pl.is_last_track) + pl.position = 1 + self.assertTrue(pl.is_last_track) + + def test_goto_start(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + pl.position = 1 + pl.goto_start() + self.assertEqual(pl.position, 0) + + def test_clear(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.clear() + self.assertEqual(len(pl), 0) + self.assertEqual(pl.position, 0) + + def test_remove_entry_by_object(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3", "Track1") + pl.add_entry(e) + pl.remove_entry(e) + self.assertEqual(len(pl), 0) + + def test_remove_entry_by_index(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3")) + pl.add_entry(self._make_entry("http://a.com/2.mp3")) + pl.remove_entry(0) + self.assertEqual(len(pl), 1) + + def test_remove_entry_not_found(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3") + with self.assertRaises(ValueError): + pl.remove_entry(e) + + def test_sort_by_conf(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/low.mp3", confidence=10)) + pl.add_entry(self._make_entry("http://a.com/high.mp3", confidence=90)) + pl.sort_by_conf() + self.assertEqual(pl[0].match_confidence, 90) + + def test_infocard(self) -> None: + pl = Playlist(title="My List") + card = pl.infocard + self.assertEqual(card["track"], "My List") + self.assertEqual(card["uri"], "") + + def test_length(self) -> None: + pl = Playlist() + pl.add_entry(MediaEntry(uri="http://a.com/1.mp3", length=60)) + pl.add_entry(MediaEntry(uri="http://a.com/2.mp3", length=120)) + self.assertEqual(pl.length, 180) + + def test_as_dict(self) -> None: + pl = Playlist(title="Test PL") + pl.add_entry(self._make_entry("http://a.com/1.mp3", "Track1")) + d = pl.as_dict + self.assertEqual(d["title"], "Test PL") + self.assertEqual(len(d["playlist"]), 1) + + def test_replace(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/old.mp3")) + new_entries = [self._make_entry("http://a.com/new1.mp3"), + self._make_entry("http://a.com/new2.mp3")] + pl.replace(new_entries) + self.assertEqual(len(pl), 2) + + def test_init_from_list(self) -> None: + entries = [self._make_entry("http://a.com/1.mp3"), + self._make_entry("http://a.com/2.mp3")] + pl = Playlist(entries) + self.assertEqual(len(pl), 2) + + def test_add_entry_invalid_index(self) -> None: + pl = Playlist() + e = self._make_entry("http://a.com/1.mp3") + with self.assertRaises(ValueError): + pl.add_entry(e, index=99) + + def test_from_dict(self) -> None: + d = { + "title": "FromDict", + "playlist": [ + {"uri": "http://a.com/1.mp3", "title": "T1"}, + ] + } + pl = Playlist.from_dict(d) + self.assertEqual(pl.title, "FromDict") + + def test_from_dict_missing_playlist_raises(self) -> None: + with self.assertRaises(ValueError): + Playlist.from_dict({"title": "No entries"}) + + def test_entries_property(self) -> None: + pl = Playlist() + pl.add_entry(self._make_entry("http://a.com/1.mp3", "T1")) + entries = pl.entries + self.assertEqual(len(entries), 1) + self.assertIsInstance(entries[0], MediaEntry) + + +class TestFindMimeNone(unittest.TestCase): + """Test find_mime returning None for unknown type (line 170).""" + + def test_returns_none_for_no_mime(self) -> None: + """find_mime should return None when mimetypes.guess_type returns falsy tuple.""" + from unittest.mock import patch + # (None, None) is falsy only when checked as a plain value; mimetypes returns + # a 2-tuple which is truthy by default. Check the actual None branch: + with patch("mimetypes.guess_type", return_value=None): + result = find_mime("no_extension_file") + self.assertIsNone(result) + + +class TestAvailableExtractors(unittest.TestCase): + """Test available_extractors deprecation shim (lines 147-161).""" + + def test_available_extractors_import_error(self) -> None: + """available_extractors should raise ImportError when neither OPM nor OCP is installed.""" + import warnings + from unittest.mock import patch + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + with patch.dict("sys.modules", + {"ovos_plugin_manager": None, + "ovos_plugin_manager.ocp": None, + "ovos_plugin_common_play": None, + "ovos_plugin_common_play.ocp": None, + "ovos_plugin_common_play.ocp.utils": None}): + from ovos_utils.ocp import available_extractors + try: + available_extractors() + except (ImportError, ModuleNotFoundError): + pass # expected + + +class TestMprisMetadata(unittest.TestCase): + """Test mpris_metadata property (lines 225-235) - needs dbus_next mock.""" + + def test_mpris_metadata_with_dbus_next(self) -> None: + """mpris_metadata should build metadata dict using dbus_next Variant.""" + import sys + from unittest.mock import MagicMock + + # Provide a dbus_next stub + dbus_stub = MagicMock() + Variant = lambda sig, val: (sig, val) + dbus_stub.service.Variant = Variant + sys.modules["dbus_next"] = dbus_stub + sys.modules["dbus_next.service"] = dbus_stub.service + + try: + entry = MediaEntry( + uri="http://example.com/song.mp3", + title="Test Song", + artist="Test Artist", + image="http://img.example.com/art.jpg", + length=180, + ) + meta = entry.mpris_metadata + self.assertIn("xesam:url", meta) + self.assertIn("xesam:artist", meta) + self.assertIn("xesam:title", meta) + self.assertIn("mpris:artUrl", meta) + self.assertIn("mpris:length", meta) + finally: + sys.modules.pop("dbus_next", None) + sys.modules.pop("dbus_next.service", None) + + def test_mpris_metadata_minimal(self) -> None: + """mpris_metadata with only uri should include xesam:url only.""" + import sys + from unittest.mock import MagicMock + + dbus_stub = MagicMock() + Variant = lambda sig, val: (sig, val) + dbus_stub.service.Variant = Variant + sys.modules["dbus_next"] = dbus_stub + sys.modules["dbus_next.service"] = dbus_stub.service + + try: + entry = MediaEntry(uri="http://example.com/song.mp3") + meta = entry.mpris_metadata + self.assertIn("xesam:url", meta) + self.assertNotIn("xesam:artist", meta) + finally: + sys.modules.pop("dbus_next", None) + sys.modules.pop("dbus_next.service", None) + + +class TestPlaylistCurrentTrackDict(unittest.TestCase): + """Test Playlist.current_track with dict entry (line 450).""" + + def _make_entry(self, uri: str, title: str = "") -> MediaEntry: + """Create a basic MediaEntry for testing.""" + return MediaEntry(uri=uri, title=title) + + def test_current_track_from_dict(self) -> None: + """current_track should convert dict entries to MediaEntry.""" + pl = Playlist() + entry = self._make_entry("http://a.com/1.mp3", "Track 1") + pl.append(entry.as_dict) + track = pl.current_track + self.assertIsInstance(track, (MediaEntry, type(None))) + + def test_current_track_empty_playlist(self) -> None: + """current_track should return None for empty playlist.""" + pl = Playlist() + self.assertIsNone(pl.current_track) + + +class TestPlaylistAddEntryAtPosition(unittest.TestCase): + """Test Playlist.add_entry position adjustment (line 514).""" + + def test_add_entry_before_current_position_shifts_pointer(self) -> None: + """Adding entry at index < position should call set_position(position + 1).""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3", title="T1") + e2 = MediaEntry(uri="http://a.com/2.mp3", title="T2") + e3 = MediaEntry(uri="http://a.com/3.mp3", title="T3") + pl.add_entry(e1) + pl.add_entry(e2) + pl.add_entry(e3) + # With 3 entries, set position to 2 (last valid index) + pl.position = 2 + # Insert a new entry at index 0 (before current position) + e4 = MediaEntry(uri="http://a.com/4.mp3", title="T4") + pl.add_entry(e4, index=0) + # Position was incremented to 3 by add_entry + self.assertEqual(pl.position, 3) + self.assertEqual(pl.current_track, e3) + + +class TestPlaylistRemoveEntryByIndex(unittest.TestCase): + """Test Playlist.remove_entry with int index (line 527).""" + + def test_remove_by_index(self) -> None: + """remove_entry with int index should pop the entry at that index.""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3", title="T1") + e2 = MediaEntry(uri="http://a.com/2.mp3", title="T2") + pl.add_entry(e1) + pl.add_entry(e2) + self.assertEqual(len(pl), 2) + pl.remove_entry(0) + self.assertEqual(len(pl), 1) + + def test_remove_entry_not_found_raises(self) -> None: + """remove_entry should raise ValueError when MediaEntry not in playlist.""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3", title="T1") + e2 = MediaEntry(uri="http://a.com/missing.mp3", title="Missing") + pl.add_entry(e1) + with self.assertRaises(ValueError): + pl.remove_entry(e2) + + +class TestPlaylistGotoTrack(unittest.TestCase): + """Test Playlist.goto_track method (lines 565-576).""" + + def test_goto_track_by_media_entry(self) -> None: + """goto_track should find and position to the matching MediaEntry.""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3", title="T1") + e2 = MediaEntry(uri="http://a.com/2.mp3", title="T2") + pl.add_entry(e1) + pl.add_entry(e2) + pl.goto_track(e2) + self.assertEqual(pl.position, 1) + + def test_goto_track_not_found_logs_error(self) -> None: + """goto_track with missing entry should log error without raising.""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3", title="T1") + pl.add_entry(e1) + missing = MediaEntry(uri="http://missing.com/x.mp3") + # Should not raise + pl.goto_track(missing) + + def test_goto_track_by_plugin_stream(self) -> None: + """goto_track should support PluginStream entries.""" + pl = Playlist() + ps = PluginStream(extractor_id="youtube", stream="abc123") + pl.append(ps) + pl.goto_track(ps) + self.assertEqual(pl.position, 0) + + def test_goto_track_nested_playlist(self) -> None: + """goto_track should match nested Playlist by title.""" + pl = Playlist(title="outer") + inner = Playlist(title="inner_pl") + pl.append(inner) + pl.goto_track(inner) + self.assertEqual(pl.position, 0) + + +class TestPlaylistContains(unittest.TestCase): + """Test Playlist.__contains__ (lines 601-615).""" + + def test_contains_media_entry(self) -> None: + """Playlist should report True for a contained MediaEntry.""" + pl = Playlist() + e = MediaEntry(uri="http://a.com/1.mp3") + pl.add_entry(e) + self.assertIn(e, pl) + + def test_not_contains_media_entry(self) -> None: + """Playlist should report False for a missing MediaEntry.""" + pl = Playlist() + e1 = MediaEntry(uri="http://a.com/1.mp3") + e2 = MediaEntry(uri="http://a.com/2.mp3") + pl.add_entry(e1) + self.assertNotIn(e2, pl) + + def test_contains_plugin_stream(self) -> None: + """Playlist should report True for a contained PluginStream.""" + pl = Playlist() + ps = PluginStream(extractor_id="youtube", stream="abc") + pl.append(ps) + self.assertIn(ps, pl) + + def test_contains_dict_entry(self) -> None: + """Playlist should convert dict to entry for __contains__ check.""" + pl = Playlist() + e = MediaEntry(uri="http://a.com/1.mp3") + pl.add_entry(e) + self.assertIn(e.as_dict, pl) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ocp_extra.py b/test/unittests/test_ocp_extra.py new file mode 100644 index 00000000..44851f9e --- /dev/null +++ b/test/unittests/test_ocp_extra.py @@ -0,0 +1,395 @@ +# Copyright 2024, OpenVoiceOS +# Licensed under the Apache License, Version 2.0 + +import unittest +import warnings +from ovos_utils.ocp import ( + MediaEntry, Playlist, PluginStream, dict2entry, + PlaybackType, TrackState, MediaState, PlayerState, + LoopState, PlaybackMode, MatchConfidence, OCP_ID, + find_mime, +) + + +def _make_playlist(uris): + """Helper to build a Playlist from a list of URIs.""" + pl = Playlist() + for uri in uris: + pl.add_entry(MediaEntry(uri=uri, title=uri)) + return pl + + +# ---- MediaEntry tests ------------------------------------------------------- + +class TestMediaEntry(unittest.TestCase): + def test_default_values(self): + entry = MediaEntry() + self.assertEqual(entry.uri, "") + self.assertEqual(entry.title, "") + self.assertEqual(entry.skill_id, OCP_ID) + self.assertEqual(entry.playback, PlaybackType.UNDEFINED) + + def test_as_dict(self): + entry = MediaEntry(uri="http://example.com/audio.mp3", title="Test") + d = entry.as_dict + self.assertIsInstance(d, dict) + self.assertEqual(d["uri"], "http://example.com/audio.mp3") + self.assertEqual(d["title"], "Test") + + def test_from_dict(self): + d = {"uri": "http://example.com/a.mp3", "title": "Song"} + entry = MediaEntry.from_dict(d) + self.assertIsInstance(entry, MediaEntry) + self.assertEqual(entry.uri, "http://example.com/a.mp3") + + def test_infocard(self): + entry = MediaEntry(uri="http://x.com/f.mp3", title="My Song", length=120) + card = entry.infocard + self.assertEqual(card["uri"], "http://x.com/f.mp3") + self.assertEqual(card["track"], "My Song") + self.assertEqual(card["duration"], 120) + + def test_equality(self): + e1 = MediaEntry(uri="http://x.com/f.mp3", title="A") + e2 = MediaEntry(uri="http://x.com/f.mp3", title="A") + self.assertEqual(e1, e2) + + def test_inequality(self): + e1 = MediaEntry(uri="http://x.com/f.mp3", title="A") + e2 = MediaEntry(uri="http://x.com/g.mp3", title="B") + self.assertNotEqual(e1, e2) + + def test_update_from_dict(self): + entry = MediaEntry(uri="http://x.com/f.mp3", title="Old") + entry.update({"title": "New"}) + self.assertEqual(entry.title, "New") + + def test_update_skipkeys(self): + entry = MediaEntry(uri="http://x.com/f.mp3", title="Keep") + entry.update({"title": "New"}, skipkeys=["title"]) + self.assertEqual(entry.title, "Keep") + + def test_update_newonly(self): + entry = MediaEntry(uri="http://x.com/f.mp3", title="Existing") + entry.update({"title": "New"}, newonly=True) + # title is not empty so should NOT be updated + self.assertEqual(entry.title, "Existing") + + def test_mimetype_audio(self): + entry = MediaEntry(uri="http://example.com/audio.mp3") + mime = entry.mimetype + # mp3 should return a mime tuple + self.assertIsNotNone(mime) + + def test_mimetype_no_uri(self): + entry = MediaEntry() + mime = entry.mimetype + self.assertIsNone(mime) + + +class TestFindMime(unittest.TestCase): + def test_known_extension(self): + result = find_mime("file.mp3") + self.assertIsNotNone(result) + + def test_unknown_extension(self): + result = find_mime("file.unknownextension123") + # May return None or empty tuple + # Just check it doesn't crash + + +# ---- PluginStream tests ----------------------------------------------------- + +class TestPluginStream(unittest.TestCase): + def test_creation(self): + ps = PluginStream(stream="abc123", extractor_id="youtube") + self.assertEqual(ps.stream, "abc123") + self.assertEqual(ps.extractor_id, "youtube") + + def test_infocard(self): + ps = PluginStream(stream="abc", extractor_id="yt", title="Video") + card = ps.infocard + self.assertIn("yt//abc", card["uri"]) + self.assertEqual(card["track"], "Video") + + def test_as_dict(self): + ps = PluginStream(stream="abc", extractor_id="yt") + d = ps.as_dict + self.assertIsInstance(d, dict) + self.assertEqual(d["stream"], "abc") + self.assertEqual(d["extractor_id"], "yt") + + def test_from_dict(self): + d = {"stream": "s1", "extractor_id": "yt", "title": "T"} + ps = PluginStream.from_dict(d) + self.assertIsInstance(ps, PluginStream) + + def test_from_dict_missing_extractor_id_raises(self): + with self.assertRaises(ValueError): + PluginStream.from_dict({"stream": "s1"}) + + def test_from_dict_missing_stream_raises(self): + with self.assertRaises(ValueError): + PluginStream.from_dict({"extractor_id": "yt"}) + + def test_as_media_entry(self): + ps = PluginStream(stream="abc", extractor_id="yt", title="Video") + me = ps.as_media_entry + self.assertIsInstance(me, MediaEntry) + self.assertIn("yt//abc", me.uri) + + +# ---- Playlist tests --------------------------------------------------------- + +class TestPlaylistNavigation(unittest.TestCase): + def setUp(self): + self.pl = _make_playlist(["track1", "track2", "track3"]) + + def test_initial_position(self): + self.assertEqual(self.pl.position, 0) + + def test_set_position(self): + self.pl.set_position(1) + self.assertEqual(self.pl.position, 1) + + def test_set_position_out_of_range_resets(self): + self.pl.set_position(100) + self.assertEqual(self.pl.position, 0) + + def test_set_position_negative_resets(self): + self.pl.set_position(-1) + self.assertEqual(self.pl.position, 0) + + def test_next_track(self): + self.pl.set_position(0) + self.pl.next_track() + self.assertEqual(self.pl.position, 1) + + def test_next_track_wraps(self): + self.pl.set_position(2) + self.pl.next_track() + self.assertEqual(self.pl.position, 0) + + def test_prev_track(self): + self.pl.set_position(2) + self.pl.prev_track() + self.assertEqual(self.pl.position, 1) + + def test_prev_track_at_start_wraps(self): + self.pl.set_position(0) + self.pl.prev_track() + self.assertEqual(self.pl.position, 0) + + def test_goto_track_media_entry(self): + target = MediaEntry(uri="track2", title="track2") + self.pl.goto_track(target) + self.assertEqual(self.pl.position, 1) + + def test_goto_track_dict(self): + d = {"uri": "track3", "title": "track3"} + self.pl.goto_track(d) + self.assertEqual(self.pl.position, 2) + + def test_goto_track_not_in_playlist(self): + target = MediaEntry(uri="nonexistent", title="nonexistent") + self.pl.set_position(1) + self.pl.goto_track(target) + self.assertEqual(self.pl.position, 1) + + def test_current_track(self): + self.pl.set_position(0) + track = self.pl.current_track + self.assertIsInstance(track, MediaEntry) + self.assertEqual(track.uri, "track1") + + def test_is_first_track(self): + self.pl.set_position(0) + self.assertTrue(self.pl.is_first_track) + + def test_is_last_track(self): + self.pl.set_position(2) + self.assertTrue(self.pl.is_last_track) + + def test_is_not_last_track(self): + self.pl.set_position(0) + self.assertFalse(self.pl.is_last_track) + + def test_goto_start(self): + self.pl.set_position(2) + self.pl.goto_start() + self.assertEqual(self.pl.position, 0) + + +class TestPlaylistContains(unittest.TestCase): + def setUp(self): + self.pl = _make_playlist(["track_a", "track_b"]) + + def test_contains_media_entry(self): + entry = MediaEntry(uri="track_a", title="track_a") + self.assertIn(entry, self.pl) + + def test_not_contains_media_entry(self): + entry = MediaEntry(uri="track_z", title="track_z") + self.assertNotIn(entry, self.pl) + + def test_contains_dict(self): + d = {"uri": "track_b", "title": "track_b"} + self.assertIn(d, self.pl) + + def test_not_contains_dict(self): + d = {"uri": "missing", "title": "missing"} + self.assertNotIn(d, self.pl) + + def test_contains_plugin_stream(self): + pl = Playlist() + ps = PluginStream(stream="stream1", extractor_id="myextractor") + pl.add_entry(ps) + check = PluginStream(stream="stream1", extractor_id="myextractor") + self.assertIn(check, pl) + + def test_not_contains_plugin_stream_wrong_stream(self): + pl = Playlist() + ps = PluginStream(stream="stream1", extractor_id="myextractor") + pl.add_entry(ps) + check = PluginStream(stream="stream2", extractor_id="myextractor") + self.assertNotIn(check, pl) + + +class TestPlaylistProperties(unittest.TestCase): + def test_empty_playlist_length(self): + pl = Playlist() + # max(-1, sum([])) = max(-1, 0) = 0 + self.assertEqual(pl.length, 0) + + def test_playlist_length_with_entries(self): + pl = Playlist() + pl.add_entry(MediaEntry(uri="a", length=60)) + pl.add_entry(MediaEntry(uri="b", length=120)) + self.assertEqual(pl.length, 180) + + def test_infocard(self): + pl = Playlist(title="My Playlist") + card = pl.infocard + self.assertEqual(card["track"], "My Playlist") + + def test_as_dict(self): + pl = _make_playlist(["t1", "t2"]) + d = pl.as_dict + self.assertIsInstance(d, dict) + self.assertIn("playlist", d) + + def test_from_dict(self): + d = {"playlist": [{"uri": "t1", "title": "T1"}], "title": "PL"} + pl = Playlist.from_dict(d) + self.assertIsInstance(pl, Playlist) + + def test_from_dict_missing_playlist_raises(self): + with self.assertRaises(ValueError): + Playlist.from_dict({"title": "No playlist"}) + + def test_clear(self): + pl = _make_playlist(["a", "b", "c"]) + pl.set_position(2) + pl.clear() + self.assertEqual(len(pl), 0) + self.assertEqual(pl.position, 0) + + def test_sort_by_conf(self): + pl = Playlist() + pl.add_entry(MediaEntry(uri="a", match_confidence=30)) + pl.add_entry(MediaEntry(uri="b", match_confidence=90)) + pl.add_entry(MediaEntry(uri="c", match_confidence=60)) + pl.sort_by_conf() + self.assertEqual(pl[0].match_confidence, 90) + + def test_empty_current_track_is_none(self): + pl = Playlist() + self.assertIsNone(pl.current_track) + + def test_empty_is_first_track(self): + pl = Playlist() + self.assertTrue(pl.is_first_track) + + def test_empty_is_last_track(self): + pl = Playlist() + self.assertTrue(pl.is_last_track) + + def test_entries_property(self): + pl = _make_playlist(["x", "y"]) + entries = pl.entries + self.assertEqual(len(entries), 2) + for e in entries: + self.assertIsInstance(e, MediaEntry) + + def test_add_entry_at_index(self): + pl = _make_playlist(["a", "b"]) + pl.add_entry(MediaEntry(uri="c", title="c"), index=0) + self.assertEqual(pl[0].uri, "c") + + +# ---- dict2entry tests ------------------------------------------------------- + +class TestDict2Entry(unittest.TestCase): + def test_media_entry_from_dict(self): + d = {"uri": "http://example.com/audio.mp3", "title": "Test"} + entry = dict2entry(d) + self.assertIsInstance(entry, MediaEntry) + + def test_plugin_stream_from_dict(self): + d = {"stream": "some_stream", "extractor_id": "youtube", "title": "Test"} + entry = dict2entry(d) + self.assertIsInstance(entry, PluginStream) + + def test_playlist_from_dict(self): + d = {"playlist": [{"uri": "http://example.com/1.mp3", "title": "1"}], + "title": "My Playlist"} + entry = dict2entry(d) + self.assertIsInstance(entry, Playlist) + + def test_invalid_dict_raises(self): + with self.assertRaises(ValueError): + dict2entry({"title": "No URI or stream"}) + + def test_empty_dict_raises(self): + with self.assertRaises((ValueError, KeyError)): + dict2entry({}) + + +# ---- Enum tests ------------------------------------------------------------- + +class TestEnums(unittest.TestCase): + def test_match_confidence_values(self): + self.assertEqual(MatchConfidence.EXACT, 95) + self.assertEqual(MatchConfidence.VERY_HIGH, 90) + self.assertEqual(MatchConfidence.LOW, 15) + + def test_track_state_values(self): + self.assertEqual(TrackState.DISAMBIGUATION, 1) + self.assertEqual(TrackState.PLAYING_AUDIO, 23) + + def test_media_state_values(self): + self.assertEqual(MediaState.UNKNOWN, 0) + self.assertEqual(MediaState.END_OF_MEDIA, 7) + + def test_player_state_values(self): + self.assertEqual(PlayerState.STOPPED, 0) + self.assertEqual(PlayerState.PLAYING, 1) + self.assertEqual(PlayerState.PAUSED, 2) + + def test_loop_state_values(self): + self.assertEqual(LoopState.NONE, 0) + self.assertEqual(LoopState.REPEAT, 1) + self.assertEqual(LoopState.REPEAT_TRACK, 2) + + def test_playback_type_values(self): + self.assertEqual(PlaybackType.SKILL, 0) + self.assertEqual(PlaybackType.VIDEO, 1) + self.assertEqual(PlaybackType.AUDIO, 2) + + def test_playback_mode_values(self): + self.assertEqual(PlaybackMode.AUTO, 0) + self.assertEqual(PlaybackMode.AUDIO_ONLY, 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_parse.py b/test/unittests/test_parse.py index 71cf16ac..9519d9a5 100644 --- a/test/unittests/test_parse.py +++ b/test/unittests/test_parse.py @@ -1,23 +1,180 @@ +# Copyright 2024, OpenVoiceOS +# Licensed under the Apache License, Version 2.0 + import unittest +from ovos_utils.parse import ( + MatchStrategy, + fuzzy_match, + match_one, + match_all, + remove_parentheses, + _validate_matching_strategy, +) + + +class TestMatchStrategy(unittest.TestCase): + def test_enum_values(self): + self.assertIsInstance(MatchStrategy.SIMPLE_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.PARTIAL_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.TOKEN_SORT_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.TOKEN_SET_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.PARTIAL_TOKEN_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.PARTIAL_TOKEN_SORT_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.PARTIAL_TOKEN_SET_RATIO, MatchStrategy) + self.assertIsInstance(MatchStrategy.DAMERAU_LEVENSHTEIN_SIMILARITY, MatchStrategy) + + def test_enum_is_int(self): + self.assertIsInstance(MatchStrategy.SIMPLE_RATIO, int) + + +class TestValidateMatchingStrategy(unittest.TestCase): + def test_simple_ratio_always_valid(self): + result = _validate_matching_strategy(MatchStrategy.SIMPLE_RATIO) + self.assertEqual(result, MatchStrategy.SIMPLE_RATIO) + + def test_falls_back_without_rapidfuzz(self): + import ovos_utils.parse as parse_module + original = parse_module.rapidfuzz + parse_module.rapidfuzz = None + try: + result = _validate_matching_strategy(MatchStrategy.RATIO) + self.assertEqual(result, MatchStrategy.SIMPLE_RATIO) + finally: + parse_module.rapidfuzz = original + + +class TestFuzzyMatch(unittest.TestCase): + def test_identical_strings(self): + score = fuzzy_match("hello", "hello") + self.assertAlmostEqual(score, 1.0) + + def test_empty_strings(self): + score = fuzzy_match("", "") + self.assertAlmostEqual(score, 1.0) + + def test_completely_different(self): + score = fuzzy_match("abc", "xyz") + self.assertLess(score, 0.5) + + def test_partial_match(self): + score = fuzzy_match("hello world", "hello") + self.assertGreater(score, 0.0) + self.assertLess(score, 1.0) + + def test_returns_float(self): + score = fuzzy_match("test", "test") + self.assertIsInstance(score, float) + + def test_simple_ratio_default(self): + score = fuzzy_match("cat", "cat", strategy=MatchStrategy.SIMPLE_RATIO) + self.assertAlmostEqual(score, 1.0) + + def test_score_range(self): + score = fuzzy_match("hello", "world") + self.assertGreaterEqual(score, 0.0) + self.assertLessEqual(score, 1.0) + + +class TestMatchAll(unittest.TestCase): + def test_list_choices(self): + results = match_all("apple", ["apple", "banana", "apricot"]) + self.assertIsInstance(results, list) + self.assertEqual(len(results), 3) + # Best match should be "apple" + self.assertEqual(results[0][0], "apple") + + def test_dict_choices(self): + choices = {"apple": "fruit_a", "banana": "fruit_b", "cherry": "fruit_c"} + results = match_all("apple", choices) + self.assertIsInstance(results, list) + # Returns values not keys + self.assertEqual(results[0][0], "fruit_a") + + def test_sorted_descending(self): + results = match_all("hello", ["hello", "world", "hell"]) + scores = [r[1] for r in results] + self.assertEqual(scores, sorted(scores, reverse=True)) + + def test_invalid_choices_type(self): + with self.assertRaises((ValueError, TypeError)): + match_all("hello", "not_a_list_or_dict") + + def test_ignore_case(self): + results = match_all("HELLO", ["hello", "world"], ignore_case=True) + self.assertGreater(results[0][1], results[1][1]) + + def test_custom_match_func(self): + def always_one(a, b, strategy=None): + return 1.0 + + results = match_all("anything", ["x", "y", "z"], match_func=always_one) + for _, score in results: + self.assertAlmostEqual(score, 1.0) + + def test_tuples_returned(self): + results = match_all("test", ["test", "other"]) + for item in results: + self.assertEqual(len(item), 2) + + +class TestMatchOne(unittest.TestCase): + def test_exact_match(self): + best, score = match_one("apple", ["apple", "banana", "cherry"]) + self.assertEqual(best, "apple") + self.assertAlmostEqual(score, 1.0) + + def test_best_from_list(self): + best, score = match_one("cat", ["dog", "cat", "bird"]) + self.assertEqual(best, "cat") + + def test_dict_input(self): + choices = {"apple": 1, "banana": 2} + best, score = match_one("apple", choices) + self.assertEqual(best, 1) + + def test_ignore_case(self): + best, score = match_one("APPLE", ["apple", "banana"], ignore_case=True) + self.assertEqual(best, "apple") + + +class TestRemoveParentheses(unittest.TestCase): + def test_square_brackets(self): + result = remove_parentheses("hello [world]") + self.assertEqual(result, "hello") + + def test_round_brackets(self): + result = remove_parentheses("hello (world)") + self.assertEqual(result, "hello") + + def test_curly_brackets(self): + result = remove_parentheses("hello {world}") + self.assertEqual(result, "hello") + + def test_no_brackets(self): + result = remove_parentheses("hello world") + self.assertEqual(result, "hello world") + + def test_only_brackets_returns_none(self): + result = remove_parentheses("[everything]") + self.assertIsNone(result) + def test_empty_string_returns_none(self): + result = remove_parentheses("") + self.assertIsNone(result) -class TestParse(unittest.TestCase): - def test_validate_matching_strategy(self): - from ovos_utils.parse import _validate_matching_strategy - # TODO + def test_mixed_brackets(self): + result = remove_parentheses("hi [a] (b) {c}") + self.assertEqual(result, "hi") - def test_fuzzy_match(self): - from ovos_utils.parse import fuzzy_match - # TODO + def test_extra_spaces_collapsed(self): + result = remove_parentheses("hello world") + self.assertEqual(result, "hello world") - def test_match_one(self): - from ovos_utils.parse import match_one - # TODO + def test_unclosed_paren_stripped(self): + result = remove_parentheses("hello (world") + self.assertEqual(result, "hello world") - def test_match_all(self): - from ovos_utils.parse import match_all - # TODO - def test_remove_parentheses(self): - from ovos_utils.parse import remove_parentheses - # TODO +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_process_utils_extra.py b/test/unittests/test_process_utils_extra.py new file mode 100644 index 00000000..5b4e453d --- /dev/null +++ b/test/unittests/test_process_utils_extra.py @@ -0,0 +1,223 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Additional unit tests for ovos_utils.process_utils module.""" + +import unittest +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, call + +if TYPE_CHECKING: + from ovos_utils.process_utils import ProcessStatus + + +class TestRuntimeRequirements(unittest.TestCase): + """Tests for RuntimeRequirements dataclass.""" + + def test_defaults(self) -> None: + """RuntimeRequirements should have sensible defaults.""" + from ovos_utils.process_utils import RuntimeRequirements + req = RuntimeRequirements() + self.assertTrue(req.network_before_load) + self.assertTrue(req.internet_before_load) + self.assertFalse(req.gui_before_load) + self.assertTrue(req.requires_internet) + self.assertTrue(req.requires_network) + self.assertFalse(req.requires_gui) + self.assertFalse(req.no_internet_fallback) + self.assertTrue(req.no_gui_fallback) + + def test_custom_values(self) -> None: + """RuntimeRequirements should accept custom values.""" + from ovos_utils.process_utils import RuntimeRequirements + req = RuntimeRequirements( + network_before_load=False, + internet_before_load=False, + requires_internet=False, + ) + self.assertFalse(req.network_before_load) + self.assertFalse(req.internet_before_load) + self.assertFalse(req.requires_internet) + + +class TestProcessState(unittest.TestCase): + """Tests for ProcessState enum ordering.""" + + def test_ordering(self) -> None: + """ProcessState values should be ordered for easy comparison.""" + from ovos_utils.process_utils import ProcessState + self.assertGreater(ProcessState.ALIVE, ProcessState.STARTED) + self.assertGreater(ProcessState.READY, ProcessState.ALIVE) + self.assertLess(ProcessState.NOT_STARTED, ProcessState.STARTED) + + +class TestStatusCallbackMap(unittest.TestCase): + """Tests for StatusCallbackMap namedtuple.""" + + def test_defaults_to_none(self) -> None: + """StatusCallbackMap should default all callbacks to None.""" + from ovos_utils.process_utils import StatusCallbackMap + cbm = StatusCallbackMap() + self.assertIsNone(cbm.on_started) + self.assertIsNone(cbm.on_alive) + self.assertIsNone(cbm.on_ready) + self.assertIsNone(cbm.on_error) + self.assertIsNone(cbm.on_stopping) + + def test_accepts_callbacks(self) -> None: + """StatusCallbackMap should accept callables.""" + from ovos_utils.process_utils import StatusCallbackMap + cb = MagicMock() + cbm = StatusCallbackMap(on_ready=cb) + self.assertEqual(cbm.on_ready, cb) + + +class TestProcessStatus(unittest.TestCase): + """Tests for ProcessStatus state machine.""" + + def _make_status(self) -> "ProcessStatus": + """Build a ProcessStatus with mock callbacks.""" + from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap + self.on_started = MagicMock() + self.on_alive = MagicMock() + self.on_ready = MagicMock() + self.on_error = MagicMock() + self.on_stopping = MagicMock() + cbm = StatusCallbackMap( + on_started=self.on_started, + on_alive=self.on_alive, + on_ready=self.on_ready, + on_error=self.on_error, + on_stopping=self.on_stopping, + ) + return ProcessStatus("test", callback_map=cbm) + + def test_initial_state_is_not_started(self) -> None: + """ProcessStatus should start in NOT_STARTED state.""" + from ovos_utils.process_utils import ProcessStatus, ProcessState + ps = ProcessStatus("test") + self.assertEqual(ps.state, ProcessState.NOT_STARTED) + + def test_set_started(self) -> None: + """set_started should transition to STARTED and invoke callback.""" + from ovos_utils.process_utils import ProcessState + ps = self._make_status() + ps.set_started() + self.assertEqual(ps.state, ProcessState.STARTED) + self.on_started.assert_called_once() + + def test_set_alive(self) -> None: + """set_alive should transition to ALIVE and invoke callback.""" + from ovos_utils.process_utils import ProcessState + ps = self._make_status() + ps.set_alive() + self.assertEqual(ps.state, ProcessState.ALIVE) + self.on_alive.assert_called_once() + + def test_set_ready(self) -> None: + """set_ready should transition to READY and invoke callback.""" + from ovos_utils.process_utils import ProcessState + ps = self._make_status() + ps.set_ready() + self.assertEqual(ps.state, ProcessState.READY) + self.on_ready.assert_called_once() + + def test_set_stopping(self) -> None: + """set_stopping should transition to STOPPING and invoke callback.""" + from ovos_utils.process_utils import ProcessState + ps = self._make_status() + ps.set_stopping() + self.assertEqual(ps.state, ProcessState.STOPPING) + self.on_stopping.assert_called_once() + + def test_set_error(self) -> None: + """set_error should transition to ERROR and invoke callback with message.""" + from ovos_utils.process_utils import ProcessState + ps = self._make_status() + ps.set_error("something broke") + self.assertEqual(ps.state, ProcessState.ERROR) + self.on_error.assert_called_once_with("something broke") + + def test_check_alive_false_when_not_ready(self) -> None: + """check_alive should return False when state < ALIVE.""" + ps = self._make_status() + self.assertFalse(ps.check_alive()) + + def test_check_alive_true_when_alive(self) -> None: + """check_alive should return True when state >= ALIVE.""" + ps = self._make_status() + ps.set_alive() + self.assertTrue(ps.check_alive()) + + def test_check_ready_false_when_not_ready(self) -> None: + """check_ready should return False when state < READY.""" + ps = self._make_status() + ps.set_alive() + self.assertFalse(ps.check_ready()) + + def test_check_ready_true_when_ready(self) -> None: + """check_ready should return True when state == READY.""" + ps = self._make_status() + ps.set_ready() + self.assertTrue(ps.check_ready()) + + def test_check_alive_responds_to_bus_message(self) -> None: + """check_alive should emit a response message when given a bus message.""" + ps = self._make_status() + ps.set_alive() + mock_bus = MagicMock() + ps.bus = mock_bus + mock_msg = MagicMock() + mock_msg.response.return_value = MagicMock() + ps.check_alive(mock_msg) + mock_msg.response.assert_called_once_with(data={"status": True}) + mock_bus.emit.assert_called_once() + + def test_check_ready_responds_to_bus_message(self) -> None: + """check_ready should emit a response message when given a bus message.""" + ps = self._make_status() + ps.set_ready() + mock_bus = MagicMock() + ps.bus = mock_bus + mock_msg = MagicMock() + mock_msg.response.return_value = MagicMock() + ps.check_ready(mock_msg) + mock_msg.response.assert_called_once_with(data={"status": True}) + mock_bus.emit.assert_called_once() + + def test_bind_registers_bus_handlers(self) -> None: + """bind should register is_alive and is_ready handlers on the bus.""" + from ovos_utils.process_utils import ProcessStatus + mock_bus = MagicMock() + ps = ProcessStatus("myproc", bus=mock_bus, namespace="ovos") + # Should have registered handlers + calls = [c[0][0] for c in mock_bus.on.call_args_list] + self.assertIn("ovos.myproc.is_alive", calls) + self.assertIn("ovos.myproc.is_ready", calls) + + def test_no_callback_set_started(self) -> None: + """set_started without callbacks should not raise.""" + from ovos_utils.process_utils import ProcessStatus + ps = ProcessStatus("test") + ps.set_started() # should not raise + + def test_no_callback_set_error(self) -> None: + """set_error without callbacks should not raise.""" + from ovos_utils.process_utils import ProcessStatus + ps = ProcessStatus("test") + ps.set_error("err") # should not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_security.py b/test/unittests/test_security.py index e5ff0abd..f8ef1ec0 100644 --- a/test/unittests/test_security.py +++ b/test/unittests/test_security.py @@ -1,6 +1,167 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.security module.""" + +import string import unittest +from unittest.mock import MagicMock, patch + + +class TestRandomKey(unittest.TestCase): + """Tests for random_key function.""" + + def test_default_length(self) -> None: + """random_key should return a 16-character string by default.""" + from ovos_utils.security import random_key + key = random_key() + self.assertEqual(len(key), 16) + + def test_custom_length(self) -> None: + """random_key should return a string of the specified length.""" + from ovos_utils.security import random_key + key = random_key(32) + self.assertEqual(len(key), 32) + + def test_key_contains_valid_chars(self) -> None: + """random_key characters should be alphanumeric.""" + from ovos_utils.security import random_key + valid = set(string.ascii_letters + string.digits) + key = random_key(64) + for ch in key: + self.assertIn(ch, valid) + + def test_keys_are_random(self) -> None: + """Two successive calls should (almost certainly) produce different keys.""" + from ovos_utils.security import random_key + keys = {random_key() for _ in range(10)} + self.assertGreater(len(keys), 1) + + +class TestEncryptDecrypt(unittest.TestCase): + """Tests for encrypt/decrypt functions.""" + + @unittest.skipIf( + True, # Skip if AES not available; we'll test via mock + "AES not available" + ) + def test_encrypt_decrypt_roundtrip(self) -> None: + """Encrypting then decrypting should recover the original text.""" + pass # Replaced by mock test below + + def test_encrypt_raises_import_error_when_aes_none(self) -> None: + """encrypt should raise ImportError when AES is None.""" + with patch("ovos_utils.security.AES", None): + from ovos_utils.security import encrypt + with self.assertRaises(ImportError): + encrypt("key1234567890123", "hello") + + def test_decrypt_raises_import_error_when_aes_none(self) -> None: + """decrypt should raise ImportError when AES is None.""" + with patch("ovos_utils.security.AES", None): + from ovos_utils.security import decrypt + with self.assertRaises(ImportError): + decrypt("key1234567890123", b"cipher", b"tag", b"nonce") + + def test_encrypt_with_mock_aes(self) -> None: + """encrypt should call AES.new and return ciphertext, tag, nonce.""" + mock_aes = MagicMock() + mock_cipher = MagicMock() + mock_cipher.encrypt_and_digest.return_value = (b"ciphertext", b"tag") + mock_cipher.nonce = b"nonce123" + mock_aes.new.return_value = mock_cipher + mock_aes.MODE_GCM = 2 # arbitrary constant + + with patch("ovos_utils.security.AES", mock_aes): + from ovos_utils.security import encrypt + ciphertext, tag, nonce = encrypt("1234567890123456", "hello world") + + self.assertEqual(ciphertext, b"ciphertext") + self.assertEqual(tag, b"tag") + self.assertEqual(nonce, b"nonce123") + + def test_decrypt_with_mock_aes(self) -> None: + """decrypt should call AES.new and return decoded plaintext.""" + mock_aes = MagicMock() + mock_cipher = MagicMock() + mock_cipher.decrypt_and_verify.return_value = b"hello world" + mock_aes.new.return_value = mock_cipher + mock_aes.MODE_GCM = 2 + + with patch("ovos_utils.security.AES", mock_aes): + from ovos_utils.security import decrypt + result = decrypt("1234567890123456", b"ciphertext", b"tag", b"nonce") + + self.assertEqual(result, "hello world") + + def test_encrypt_accepts_bytes_key(self) -> None: + """encrypt should work when key is already bytes.""" + mock_aes = MagicMock() + mock_cipher = MagicMock() + mock_cipher.encrypt_and_digest.return_value = (b"ct", b"tag") + mock_cipher.nonce = b"nonce" + mock_aes.new.return_value = mock_cipher + mock_aes.MODE_GCM = 2 + + with patch("ovos_utils.security.AES", mock_aes): + from ovos_utils.security import encrypt + # Passing bytes key — should not encode again + encrypt(b"1234567890123456", "text") + + mock_aes.new.assert_called_once() + call_args = mock_aes.new.call_args[0] + self.assertIsInstance(call_args[0], bytes) + + def test_encrypt_accepts_bytes_text(self) -> None: + """encrypt should work when text is already bytes.""" + mock_aes = MagicMock() + mock_cipher = MagicMock() + mock_cipher.encrypt_and_digest.return_value = (b"ct", b"tag") + mock_cipher.nonce = b"nonce" + mock_aes.new.return_value = mock_cipher + mock_aes.MODE_GCM = 2 + + with patch("ovos_utils.security.AES", mock_aes): + from ovos_utils.security import encrypt + encrypt("1234567890123456", b"already bytes") + + mock_cipher.encrypt_and_digest.assert_called_once_with(b"already bytes") + + def test_decrypt_raises_on_bad_tag(self) -> None: + """decrypt should propagate exceptions from decrypt_and_verify.""" + mock_aes = MagicMock() + mock_cipher = MagicMock() + mock_cipher.decrypt_and_verify.side_effect = ValueError("MAC check failed") + mock_aes.new.return_value = mock_cipher + mock_aes.MODE_GCM = 2 + + with patch("ovos_utils.security.AES", mock_aes): + from ovos_utils.security import decrypt + with self.assertRaises(ValueError): + decrypt("1234567890123456", b"bad", b"bad_tag", b"nonce") + + +class TestCreateSelfSignedCert(unittest.TestCase): + """Tests for create_self_signed_cert (mocked OpenSSL).""" + + def test_raises_import_error_when_crypto_none(self) -> None: + """create_self_signed_cert should raise ImportError when pyOpenSSL is unavailable.""" + with patch("ovos_utils.security.crypto", None): + from ovos_utils.security import create_self_signed_cert + with self.assertRaises(ImportError): + create_self_signed_cert("/tmp/certs") -class TestSecurity(unittest.TestCase): - # TODO: Implement unit tests for security - pass +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_security_extra.py b/test/unittests/test_security_extra.py new file mode 100644 index 00000000..331e660b --- /dev/null +++ b/test/unittests/test_security_extra.py @@ -0,0 +1,77 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Additional unit tests for ovos_utils.security — create_self_signed_cert.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + + +class TestCreateSelfSignedCertMocked(unittest.TestCase): + """Tests for create_self_signed_cert with a mocked pyOpenSSL crypto module.""" + + def _make_mock_crypto(self) -> MagicMock: + """Build a minimal mock of OpenSSL.crypto for testing.""" + mock_crypto = MagicMock() + mock_key = MagicMock() + mock_cert = MagicMock() + + mock_crypto.PKey.return_value = mock_key + mock_crypto.TYPE_RSA = 6 # arbitrary constant + mock_crypto.X509.return_value = mock_cert + mock_crypto.FILETYPE_PEM = 1 + + mock_crypto.dump_certificate.return_value = b"CERT_DATA" + mock_crypto.dump_privatekey.return_value = b"KEY_DATA" + + return mock_crypto + + def test_creates_cert_and_key_files(self) -> None: + """create_self_signed_cert should write .crt and .key files.""" + from ovos_utils.security import create_self_signed_cert + + mock_crypto = self._make_mock_crypto() + with patch("ovos_utils.security.crypto", mock_crypto): + with tempfile.TemporaryDirectory() as tmpdir: + cert_path, key_path = create_self_signed_cert(tmpdir, name="test") + self.assertTrue(cert_path.endswith(".crt")) + self.assertTrue(key_path.endswith(".key")) + + def test_returns_existing_files_without_regenerating(self) -> None: + """create_self_signed_cert should not overwrite existing cert/key files.""" + from ovos_utils.security import create_self_signed_cert + + mock_crypto = self._make_mock_crypto() + with patch("ovos_utils.security.crypto", mock_crypto): + with tempfile.TemporaryDirectory() as tmpdir: + # Pre-create the cert and key files + cert_file = os.path.join(tmpdir, "test.crt") + key_file = os.path.join(tmpdir, "test.key") + with open(cert_file, "w") as f: + f.write("EXISTING_CERT") + with open(key_file, "w") as f: + f.write("EXISTING_KEY") + + cert_path, key_path = create_self_signed_cert(tmpdir, name="test") + + # PKey should NOT have been called since files exist + mock_crypto.PKey.assert_not_called() + self.assertEqual(cert_path, cert_file) + self.assertEqual(key_path, key_file) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_signal.py b/test/unittests/test_signal.py deleted file mode 100644 index 6d65b01a..00000000 --- a/test/unittests/test_signal.py +++ /dev/null @@ -1,6 +0,0 @@ -import unittest - - -class TestSignal(unittest.TestCase): - # TODO: Implement unit tests for signal - pass diff --git a/test/unittests/test_skill_installer.py b/test/unittests/test_skill_installer.py new file mode 100644 index 00000000..b64394cc --- /dev/null +++ b/test/unittests/test_skill_installer.py @@ -0,0 +1,515 @@ +# Copyright 2025 OpenVoiceOS +# +# 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. +"""Unit tests for :class:`~ovos_utils.skill_installer.ServiceInstaller`.""" +import sys +from unittest.mock import MagicMock, patch, call + +import pytest + +from ovos_bus_client import Message +from ovos_utils.skill_installer import InstallError, ServiceInstaller + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + +class FakeBus: + """Minimal in-process bus stub sufficient for unit tests.""" + + def __init__(self) -> None: + self.emitted: list[Message] = [] + self.handlers: dict[str, list] = {} + + def emit(self, message: Message) -> None: + self.emitted.append(message) + + def on(self, event: str, handler) -> None: + self.handlers.setdefault(event, []).append(handler) + + def remove(self, event: str, handler) -> None: + if event in self.handlers and handler in self.handlers[event]: + self.handlers[event].remove(handler) + + # helpers for assertions + def last_type(self) -> str: + return self.emitted[-1].msg_type + + def last_data(self) -> dict: + return self.emitted[-1].data + + +@pytest.fixture() +def bus() -> FakeBus: + return FakeBus() + + +@pytest.fixture() +def installer(bus: FakeBus) -> ServiceInstaller: + return ServiceInstaller(bus, service_name="ovos_test", config={"allow_pip": True}) + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +class TestRegistration: + def test_broadcast_topics_registered(self, bus: FakeBus) -> None: + ServiceInstaller(bus, "svc", config={}) + assert "ovos.pip.install" in bus.handlers + assert "ovos.pip.uninstall" in bus.handlers + + def test_targeted_topics_registered(self, bus: FakeBus) -> None: + ServiceInstaller(bus, "my_service", config={}) + assert "ovos.pip.install.my_service" in bus.handlers + assert "ovos.pip.uninstall.my_service" in bus.handlers + + def test_shutdown_removes_all_handlers(self, bus: FakeBus) -> None: + inst = ServiceInstaller(bus, "svc", config={}) + inst.shutdown() + assert bus.handlers.get("ovos.pip.install", []) == [] + assert bus.handlers.get("ovos.pip.uninstall", []) == [] + assert bus.handlers.get("ovos.pip.install.svc", []) == [] + assert bus.handlers.get("ovos.pip.uninstall.svc", []) == [] + + +# --------------------------------------------------------------------------- +# Audio feedback +# --------------------------------------------------------------------------- + +class TestAudioFeedback: + def test_play_error_sound_default(self, installer: ServiceInstaller, bus: FakeBus) -> None: + installer.play_error_sound() + assert bus.last_type() == "mycroft.audio.play_sound" + assert bus.last_data() == {"uri": "snd/error.mp3"} + + def test_play_success_sound_default(self, installer: ServiceInstaller, bus: FakeBus) -> None: + installer.play_success_sound() + assert bus.last_type() == "mycroft.audio.play_sound" + assert bus.last_data() == {"uri": "snd/acknowledge.mp3"} + + def test_play_error_sound_custom(self, bus: FakeBus) -> None: + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True, "sounds": {"pip_error": "snd/boom.mp3"}}) + inst.play_error_sound() + assert bus.last_data() == {"uri": "snd/boom.mp3"} + + def test_play_success_sound_custom(self, bus: FakeBus) -> None: + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True, "sounds": {"pip_success": "snd/yay.mp3"}}) + inst.play_success_sound() + assert bus.last_data() == {"uri": "snd/yay.mp3"} + + +# --------------------------------------------------------------------------- +# handle_install_python — pip disabled +# --------------------------------------------------------------------------- + +class TestHandleInstallDisabled: + @pytest.fixture() + def disabled(self, bus: FakeBus) -> ServiceInstaller: + return ServiceInstaller(bus, "svc", config={"allow_pip": False}) + + def test_emits_failed_when_disabled(self, disabled: ServiceInstaller, bus: FakeBus) -> None: + msg = Message("ovos.pip.install", {"packages": ["some-pkg"]}) + disabled.handle_install_python(msg) + assert bus.last_type() == "ovos.pip.install.failed" + assert bus.last_data()["error"] == InstallError.DISABLED.value + + def test_emits_failed_when_no_packages(self, installer: ServiceInstaller, bus: FakeBus) -> None: + msg = Message("ovos.pip.install", {}) + installer.handle_install_python(msg) + assert bus.last_type() == "ovos.pip.install.failed" + assert bus.last_data()["error"] == InstallError.NO_PKGS.value + + +# --------------------------------------------------------------------------- +# handle_install_python — success / failure paths +# --------------------------------------------------------------------------- + +class TestHandleInstallPython: + def test_emits_complete_on_success(self, installer: ServiceInstaller, bus: FakeBus) -> None: + with patch.object(installer, "pip_install", return_value=True): + msg = Message("ovos.pip.install", {"packages": ["pkg-a"]}) + installer.handle_install_python(msg) + assert bus.last_type() == "ovos.pip.install.complete" + + def test_emits_failed_on_pip_error(self, installer: ServiceInstaller, bus: FakeBus) -> None: + with patch.object(installer, "pip_install", side_effect=RuntimeError("fail")): + msg = Message("ovos.pip.install", {"packages": ["pkg-a"]}) + installer.handle_install_python(msg) + assert bus.last_type() == "ovos.pip.install.failed" + assert bus.last_data()["error"] == InstallError.PIP_ERROR.value + + def test_targeted_message_handled(self, installer: ServiceInstaller, bus: FakeBus) -> None: + with patch.object(installer, "pip_install", return_value=True): + msg = Message("ovos.pip.install.ovos_test", {"packages": ["pkg-b"]}) + installer.handle_install_python(msg) + assert bus.last_type() == "ovos.pip.install.complete" + + +# --------------------------------------------------------------------------- +# handle_uninstall_python +# --------------------------------------------------------------------------- + +class TestHandleUninstallPython: + def test_emits_complete_on_success(self, installer: ServiceInstaller, bus: FakeBus) -> None: + with patch.object(installer, "pip_uninstall", return_value=True): + msg = Message("ovos.pip.uninstall", {"packages": ["pkg-a"]}) + installer.handle_uninstall_python(msg) + assert bus.last_type() == "ovos.pip.uninstall.complete" + + def test_emits_failed_on_pip_error(self, installer: ServiceInstaller, bus: FakeBus) -> None: + with patch.object(installer, "pip_uninstall", side_effect=RuntimeError("fail")): + msg = Message("ovos.pip.uninstall", {"packages": ["pkg-a"]}) + installer.handle_uninstall_python(msg) + assert bus.last_type() == "ovos.pip.uninstall.failed" + + def test_emits_failed_when_no_packages(self, installer: ServiceInstaller, bus: FakeBus) -> None: + msg = Message("ovos.pip.uninstall", {}) + installer.handle_uninstall_python(msg) + assert bus.last_type() == "ovos.pip.uninstall.failed" + assert bus.last_data()["error"] == InstallError.NO_PKGS.value + + def test_disabled_emits_failed(self, bus: FakeBus) -> None: + inst = ServiceInstaller(bus, "svc", config={"allow_pip": False}) + msg = Message("ovos.pip.uninstall", {"packages": ["pkg"]}) + inst.handle_uninstall_python(msg) + assert bus.last_type() == "ovos.pip.uninstall.failed" + assert bus.last_data()["error"] == InstallError.DISABLED.value + + +# --------------------------------------------------------------------------- +# pip_install — subprocess logic (mocked) +# --------------------------------------------------------------------------- + +class TestPipInstall: + def test_returns_false_when_no_packages(self, installer: ServiceInstaller) -> None: + result = installer.pip_install([]) + assert result is False + + def test_returns_false_on_bad_constraints(self, installer: ServiceInstaller) -> None: + with patch.object(ServiceInstaller, "validate_constraints", return_value=False): + result = installer.pip_install(["pkg"]) + assert result is False + + def test_calls_subprocess_with_uv_when_available( + self, installer: ServiceInstaller, bus: FakeBus + ) -> None: + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "UV", "/usr/bin/uv"), + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + result = installer.pip_install(["my-pkg"], constraints="http://example.com/c.txt") + assert result is True + args = popen_mock.call_args[0][0] + assert args[0] == "/usr/bin/uv" + assert "my-pkg" in args + + def test_calls_subprocess_with_python_when_uv_absent( + self, installer: ServiceInstaller + ) -> None: + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "UV", None), + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + installer.pip_install(["my-pkg"], constraints="http://example.com/c.txt") + args = popen_mock.call_args[0][0] + assert args[0] == sys.executable + + def test_raises_on_nonzero_exit(self, installer: ServiceInstaller) -> None: + proc_mock = MagicMock() + proc_mock.wait.return_value = 1 + proc_mock.stderr = None + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + with pytest.raises(RuntimeError): + installer.pip_install(["bad-pkg"], print_logs=False) + + def test_on_install_complete_called(self, installer: ServiceInstaller) -> None: + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + patch.object(installer, "_on_install_complete") as hook, + ): + installer.pip_install(["pkg"], constraints="http://x.com/c.txt") + hook.assert_called_once() + + +# --------------------------------------------------------------------------- +# validate_constraints +# --------------------------------------------------------------------------- + +class TestValidateConstraints: + def test_returns_true_for_existing_file(self, tmp_path) -> None: + f = tmp_path / "constraints.txt" + f.write_text("ovos-core==1.0\n") + assert ServiceInstaller.validate_constraints(str(f)) is True + + def test_returns_false_for_missing_file(self) -> None: + assert ServiceInstaller.validate_constraints("/nonexistent/path.txt") is False + + def test_returns_true_for_valid_url(self) -> None: + resp = MagicMock() + resp.status_code = 200 + with patch("ovos_utils.skill_installer.requests.head", return_value=resp): + assert ServiceInstaller.validate_constraints("http://example.com/c.txt") is True + + def test_returns_false_for_bad_url(self) -> None: + resp = MagicMock() + resp.status_code = 404 + with patch("ovos_utils.skill_installer.requests.head", return_value=resp): + assert ServiceInstaller.validate_constraints("http://example.com/missing.txt") is False + + def test_returns_false_on_request_exception(self) -> None: + with patch("ovos_utils.skill_installer.requests.head", side_effect=Exception("timeout")): + assert ServiceInstaller.validate_constraints("http://example.com/c.txt") is False + + +# --------------------------------------------------------------------------- +# Extension hooks +# --------------------------------------------------------------------------- + +class TestExtensionHooks: + def test_on_install_complete_is_noop_by_default(self, installer: ServiceInstaller) -> None: + """Base class hook should not raise.""" + installer._on_install_complete() + + def test_on_uninstall_complete_is_noop_by_default(self, installer: ServiceInstaller) -> None: + installer._on_uninstall_complete() + + def test_subclass_hook_called_on_install(self, bus: FakeBus) -> None: + hook_calls = [] + + class MyInstaller(ServiceInstaller): + def _on_install_complete(self) -> None: + hook_calls.append("install_complete") + + inst = MyInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + inst.pip_install(["pkg"], constraints="http://x.com/c.txt") + assert hook_calls == ["install_complete"] + + +# --------------------------------------------------------------------------- +# pip_install — break_system_packages and allow_alphas options (lines 230, 232) +# --------------------------------------------------------------------------- + +class TestPipInstallOptions: + """Tests for pip_install config options.""" + + def test_break_system_packages_flag(self, bus: FakeBus) -> None: + """pip_install should pass --break-system-packages when configured.""" + inst = ServiceInstaller(bus, "svc", config={ + "allow_pip": True, "break_system_packages": True + }) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + inst.pip_install(["pkg"], constraints="http://x.com/c.txt") + args = popen_mock.call_args[0][0] + assert "--break-system-packages" in args + + def test_allow_alphas_flag(self, bus: FakeBus) -> None: + """pip_install should pass --pre when allow_alphas is True.""" + inst = ServiceInstaller(bus, "svc", config={ + "allow_pip": True, "allow_alphas": True + }) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + inst.pip_install(["pkg"], constraints="http://x.com/c.txt") + args = popen_mock.call_args[0][0] + assert "--pre" in args + + def test_print_logs_false_uses_pipe(self, bus: FakeBus) -> None: + """pip_install with print_logs=False should redirect stdout/stderr to PIPE.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + inst.pip_install(["pkg"], constraints="http://x.com/c.txt", + print_logs=False) + kwargs = popen_mock.call_args[1] + assert "stdout" in kwargs + + def test_nonzero_exit_with_stderr_raises(self, bus: FakeBus) -> None: + """pip_install nonzero exit with stderr content should raise RuntimeError.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 1 + proc_mock.stderr.read.return_value = b"some error" + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + import pytest as _pytest + with _pytest.raises(RuntimeError): + inst.pip_install(["pkg"], constraints="http://x.com/c.txt", + print_logs=False) + + +# --------------------------------------------------------------------------- +# pip_uninstall (lines 273-343) +# --------------------------------------------------------------------------- + +class TestPipUninstall: + """Tests for pip_uninstall method.""" + + def test_returns_false_when_no_packages(self, installer: ServiceInstaller) -> None: + """pip_uninstall should return False when packages list is empty.""" + result = installer.pip_uninstall([]) + assert result is False + + def test_returns_false_on_bad_constraints(self, installer: ServiceInstaller) -> None: + """pip_uninstall should return False when constraints fail validation.""" + with patch.object(ServiceInstaller, "validate_constraints", return_value=False): + result = installer.pip_uninstall(["pkg"]) + assert result is False + + def test_returns_false_for_protected_package(self, installer: ServiceInstaller) -> None: + """pip_uninstall should refuse to uninstall ovos-core.""" + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + ): + # ovos-core is in the default protected list + result = installer.pip_uninstall(["ovos-core"]) + assert result is False + + def test_success_with_uv(self, bus: FakeBus) -> None: + """pip_uninstall should call uv pip uninstall when UV is available.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "UV", "/usr/bin/uv"), + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + result = inst.pip_uninstall(["custom-pkg"]) + assert result is True + args = popen_mock.call_args[0][0] + assert args[0] == "/usr/bin/uv" + assert "uninstall" in args + + def test_success_without_uv(self, bus: FakeBus) -> None: + """pip_uninstall should use sys.executable when UV is None.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "UV", None), + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + result = inst.pip_uninstall(["custom-pkg"]) + assert result is True + args = popen_mock.call_args[0][0] + assert args[0] == sys.executable + + def test_raises_on_nonzero_exit(self, bus: FakeBus) -> None: + """pip_uninstall should raise RuntimeError on nonzero subprocess exit.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 1 + proc_mock.stderr.read.return_value = b"uninstall error" + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + import pytest as _pytest + with _pytest.raises(RuntimeError): + inst.pip_uninstall(["custom-pkg"], print_logs=False) + + def test_reads_constraints_from_url(self, bus: FakeBus) -> None: + """pip_uninstall should fetch constraints via HTTP when URL is provided.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + import requests as _req + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.requests.get") as mock_get, + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + mock_get.return_value.text = "some-package==1.0\n" + result = inst.pip_uninstall(["custom-pkg"], + constraints="http://example.com/c.txt") + assert result is True + mock_get.assert_called_once() + + def test_reads_constraints_from_file(self, bus: FakeBus, tmp_path) -> None: + """pip_uninstall should read constraints from a local file.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + constraints_file = tmp_path / "constraints.txt" + constraints_file.write_text("some-package==1.0\n") + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + ): + result = inst.pip_uninstall(["custom-pkg"], + constraints=str(constraints_file)) + assert result is True + + def test_break_system_packages_flag(self, bus: FakeBus) -> None: + """pip_uninstall should pass --break-system-packages when configured.""" + inst = ServiceInstaller(bus, "svc", config={ + "allow_pip": True, "break_system_packages": True + }) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock) as popen_mock, + ): + inst.pip_uninstall(["custom-pkg"]) + args = popen_mock.call_args[0][0] + assert "--break-system-packages" in args + + def test_on_uninstall_complete_called(self, bus: FakeBus) -> None: + """pip_uninstall should call _on_uninstall_complete on success.""" + inst = ServiceInstaller(bus, "svc", config={"allow_pip": True}) + proc_mock = MagicMock() + proc_mock.wait.return_value = 0 + with ( + patch.object(ServiceInstaller, "validate_constraints", return_value=True), + patch("ovos_utils.skill_installer.Popen", return_value=proc_mock), + patch.object(inst, "_on_uninstall_complete") as hook, + ): + inst.pip_uninstall(["custom-pkg"]) + hook.assert_called_once() diff --git a/test/unittests/test_skills.py b/test/unittests/test_skills.py new file mode 100644 index 00000000..16dd9440 --- /dev/null +++ b/test/unittests/test_skills.py @@ -0,0 +1,127 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.skills module.""" + +import unittest +from unittest.mock import MagicMock, patch + + +class TestGetNonProperties(unittest.TestCase): + """Tests for the get_non_properties helper function.""" + + def test_returns_set(self) -> None: + """get_non_properties should return a set.""" + from ovos_utils.skills import get_non_properties + + class SimpleClass: + def regular_method(self): + pass + + @property + def my_prop(self): + return 1 + + result = get_non_properties(SimpleClass()) + self.assertIsInstance(result, set) + + def test_excludes_properties(self) -> None: + """Properties should not appear in the returned set.""" + from ovos_utils.skills import get_non_properties + + class WithProp: + @property + def prop_val(self): + return 42 + + def regular(self): + pass + + result = get_non_properties(WithProp()) + self.assertNotIn("prop_val", result) + self.assertIn("regular", result) + + def test_includes_inherited_non_properties(self) -> None: + """Methods from base classes should be included (unless named object/MycroftSkill).""" + from ovos_utils.skills import get_non_properties + + class Base: + def base_method(self): + pass + + class Child(Base): + def child_method(self): + pass + + result = get_non_properties(Child()) + self.assertIn("child_method", result) + self.assertIn("base_method", result) + + def test_skips_mycroft_skill_base(self) -> None: + """MycroftSkill base class methods should be excluded from recursion.""" + from ovos_utils.skills import get_non_properties + + class MycroftSkill: + def mycroft_method(self): + pass + + class MySkill(MycroftSkill): + def my_method(self): + pass + + result = get_non_properties(MySkill()) + self.assertIn("my_method", result) + self.assertNotIn("mycroft_method", result) + + +class TestSkillsLoaded(unittest.TestCase): + """Tests for the skills_loaded function.""" + + def test_returns_false_when_bus_is_none(self) -> None: + """skills_loaded should return False when no bus is provided.""" + from ovos_utils.skills import skills_loaded + result = skills_loaded(bus=None) + self.assertFalse(result) + + def test_returns_false_when_no_reply(self) -> None: + """skills_loaded should return False when wait_for_reply returns None.""" + from ovos_utils.skills import skills_loaded + with patch("ovos_utils.skills.wait_for_reply", return_value=None): + fake_bus = MagicMock() + result = skills_loaded(bus=fake_bus) + self.assertFalse(result) + + def test_returns_status_from_reply(self) -> None: + """skills_loaded should return the status field from a successful reply.""" + from ovos_utils.skills import skills_loaded + mock_reply = MagicMock() + mock_reply.data = {"status": True} + with patch("ovos_utils.skills.wait_for_reply", return_value=mock_reply): + fake_bus = MagicMock() + result = skills_loaded(bus=fake_bus) + self.assertTrue(result) + + def test_returns_false_status_from_reply(self) -> None: + """skills_loaded should return False when reply status is False.""" + from ovos_utils.skills import skills_loaded + mock_reply = MagicMock() + mock_reply.data = {"status": False} + with patch("ovos_utils.skills.wait_for_reply", return_value=mock_reply): + fake_bus = MagicMock() + result = skills_loaded(bus=fake_bus) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_smtp_utils.py b/test/unittests/test_smtp_utils.py index aa791907..3b2ce916 100644 --- a/test/unittests/test_smtp_utils.py +++ b/test/unittests/test_smtp_utils.py @@ -1,6 +1,113 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.smtp_utils module.""" + import unittest +from unittest.mock import MagicMock, patch + + +class TestSendSmtp(unittest.TestCase): + """Tests for the send_smtp function.""" + + @patch("ovos_utils.smtp_utils.SMTP_SSL") + def test_send_smtp_calls_login_and_sendmail(self, mock_smtp_ssl: MagicMock) -> None: + """send_smtp should login, build the message, and call sendmail.""" + from ovos_utils.smtp_utils import send_smtp + + mock_server = MagicMock() + mock_smtp_ssl.return_value.__enter__ = MagicMock(return_value=mock_server) + mock_smtp_ssl.return_value.__exit__ = MagicMock(return_value=False) + + send_smtp( + user="user@example.com", + pswd="secret", + sender="sender@example.com", + destinatary="dest@example.com", + subject="Test Subject", + contents="Hello World", + host="smtp.example.com", + port=465, + ) + + mock_smtp_ssl.assert_called_once_with(host="smtp.example.com", port=465) + mock_server.login.assert_called_once_with("user@example.com", "secret") + self.assertTrue(mock_server.sendmail.called) + + @patch("ovos_utils.smtp_utils.SMTP_SSL") + def test_send_smtp_default_port(self, mock_smtp_ssl: MagicMock) -> None: + """send_smtp should use port 465 as the default.""" + from ovos_utils.smtp_utils import send_smtp + + mock_server = MagicMock() + mock_smtp_ssl.return_value.__enter__ = MagicMock(return_value=mock_server) + mock_smtp_ssl.return_value.__exit__ = MagicMock(return_value=False) + + send_smtp("u", "p", "s", "d", "subj", "body", "host.example.com") + mock_smtp_ssl.assert_called_once_with(host="host.example.com", port=465) + + +class TestSendEmail(unittest.TestCase): + """Tests for the send_email function.""" + + def test_send_email_raises_when_no_config(self) -> None: + """send_email should raise KeyError when email config is missing.""" + from ovos_utils.smtp_utils import send_email + + with patch("ovos_utils.smtp_utils.LOG"): + with patch.dict("sys.modules", {"ovos_config": None, + "ovos_config.config": None}): + # ImportError path — returns empty dict, which should trigger KeyError + with self.assertRaises(KeyError): + send_email("subj", "body") + + @patch("ovos_utils.smtp_utils.send_smtp") + def test_send_email_uses_config(self, mock_send_smtp: MagicMock) -> None: + """send_email should read from config when parameters are missing.""" + from ovos_utils.smtp_utils import send_email + fake_config = { + "email": { + "smtp": { + "username": "user@test.com", + "password": "pass123", + "host": "mail.test.com", + "port": 587, + }, + "recipient": "recv@test.com", + } + } + + config_mock = MagicMock() + config_mock.read_mycroft_config.return_value = fake_config + + with patch("ovos_utils.smtp_utils.LOG"), \ + patch.dict("sys.modules", {"ovos_config": MagicMock(), "ovos_config.config": config_mock}): + send_email("Hello", "Body", recipient="recv@test.com") + + # verify the args + mock_send_smtp.assert_called_once_with( + "user@test.com", + "pass123", + "user@test.com", + "recv@test.com", + "Hello", + "Body", + "mail.test.com", + 587, + ) + -class TestSMTPUtils(unittest.TestCase): - # TODO: Implement unit tests for smtp_utils - pass +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_sound.py b/test/unittests/test_sound.py index fca73be9..4843e985 100644 --- a/test/unittests/test_sound.py +++ b/test/unittests/test_sound.py @@ -1,18 +1,355 @@ +# Copyright 2024, OpenVoiceOS +# +# 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. + +"""Unit tests for ovos_utils.sound module.""" + +import os +import sys +import tempfile +import types import unittest -from time import sleep +import wave +from unittest.mock import MagicMock, patch, mock_open + +# distutils was removed in Python 3.12+; provide a minimal stub so sound.py can import +if "distutils" not in sys.modules: + distutils_stub = types.ModuleType("distutils") + spawn_stub = types.ModuleType("distutils.spawn") + spawn_stub.find_executable = lambda x: None + distutils_stub.spawn = spawn_stub + sys.modules["distutils"] = distutils_stub + sys.modules["distutils.spawn"] = spawn_stub + +class TestGetPulseEnvironment(unittest.TestCase): + """Tests for _get_pulse_environment helper.""" + + def test_pulse_duck_enabled(self) -> None: + """Should return _ENVIRONMENT when pulse_duck is True in tts config.""" + from ovos_utils.sound import _get_pulse_environment, _ENVIRONMENT + config = {"tts": {"pulse_duck": True}} + result = _get_pulse_environment(config) + self.assertIs(result, _ENVIRONMENT) + + def test_pulse_duck_disabled(self) -> None: + """Should return os.environ when pulse_duck is False.""" + import os + from ovos_utils.sound import _get_pulse_environment + config = {"tts": {"pulse_duck": False}} + result = _get_pulse_environment(config) + self.assertIs(result, os.environ) -class TestSound(unittest.TestCase): - # TODO: Some tests already implemented in `test_sound` - def test_get_pulse_environment(self): + def test_no_tts_config(self) -> None: + """Should return os.environ when tts config is absent.""" + import os from ovos_utils.sound import _get_pulse_environment - # TODO + config = {} + result = _get_pulse_environment(config) + self.assertIs(result, os.environ) + + +class TestFindPlayer(unittest.TestCase): + """Tests for _find_player helper.""" + + @patch("ovos_utils.sound.find_executable") + 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 + from ovos_utils.sound import _find_player + result = _find_player("test.mp3") + self.assertIsNotNone(result) + self.assertIn("play", result) + + @patch("ovos_utils.sound.find_executable") + 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: + if x == "play": + return None + if x == "ogg123": + return "/usr/bin/ogg123" + return None + mock_find.side_effect = side_effect + from ovos_utils.sound import _find_player + result = _find_player("test.ogg") + self.assertIsNotNone(result) + self.assertIn("ogg123", result) - def test_find_player(self): + @patch("ovos_utils.sound.find_executable") + 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: + if x == "pw-play": + return "/usr/bin/pw-play" + return None + mock_find.side_effect = side_effect from ovos_utils.sound import _find_player - # TODO + result = _find_player("test.mp3") + self.assertIsNotNone(result) + self.assertIn("pw-play", result) + + @patch("ovos_utils.sound.find_executable") + 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: + if x == "paplay": + return "/usr/bin/paplay" + return None + mock_find.side_effect = side_effect + from ovos_utils.sound import _find_player + result = _find_player("test.wav") + self.assertIsNotNone(result) + self.assertIn("paplay", result) + + @patch("ovos_utils.sound.find_executable") + 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: + if x == "aplay": + return "/usr/bin/aplay" + return None + mock_find.side_effect = side_effect + from ovos_utils.sound import _find_player + result = _find_player("test.wav") + self.assertIsNotNone(result) + self.assertIn("aplay", result) + + @patch("ovos_utils.sound.find_executable") + 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: + if x == "mpg123": + return "/usr/bin/mpg123" + return None + mock_find.side_effect = side_effect + from ovos_utils.sound import _find_player + result = _find_player("test.mp3") + self.assertIsNotNone(result) + self.assertIn("mpg123", result) + + @patch("ovos_utils.sound.find_executable", 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 + result = _find_player("test.xyz") + self.assertIsNone(result) + + +class TestPlayAudio(unittest.TestCase): + """Tests for play_audio function.""" + + @patch("ovos_utils.sound.read_mycroft_config", return_value={}) + @patch("ovos_utils.sound._find_player", return_value="/usr/bin/play --type mp3 %1") + @patch("subprocess.Popen") + def test_play_audio_basic(self, mock_popen: MagicMock, + mock_finder: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should call Popen with the resolved command.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + result = play_audio("test.mp3") + self.assertIsNotNone(result) + mock_popen.assert_called_once() - def test_play_audio(self): + @patch("ovos_utils.sound.read_mycroft_config", return_value={}) + @patch("ovos_utils.sound._find_player", return_value=None) + def test_play_audio_no_player(self, mock_finder: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should return None when no player is found.""" from ovos_utils.sound import play_audio - # TODO + result = play_audio("test.mp3") + self.assertIsNone(result) + + @patch("ovos_utils.sound.read_mycroft_config", + return_value={"play_ogg_cmdline": "ogg123 %1"}) + @patch("subprocess.Popen") + def test_play_audio_uses_config_ogg_cmd(self, mock_popen: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should use configured ogg command for .ogg files.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + result = play_audio("file:///path/to/test.ogg") + self.assertIsNotNone(result) + cmd_args = mock_popen.call_args[0][0] + self.assertIn("ogg123", cmd_args) + + @patch("ovos_utils.sound.read_mycroft_config", + return_value={"play_wav_cmdline": "aplay %1"}) + @patch("subprocess.Popen") + def test_play_audio_uses_config_wav_cmd(self, mock_popen: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should use configured wav command for .wav files.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + result = play_audio("test.wav") + self.assertIsNotNone(result) + + @patch("ovos_utils.sound.read_mycroft_config", + return_value={"play_mp3_cmdline": "mpg123 %1"}) + @patch("subprocess.Popen") + def test_play_audio_uses_config_mp3_cmd(self, mock_popen: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should use configured mp3 command for .mp3 files.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + result = play_audio("song.mp3") + self.assertIsNotNone(result) + + @patch("ovos_utils.sound.read_mycroft_config", return_value={}) + @patch("ovos_utils.sound._find_player", return_value="broken %1") + @patch("subprocess.Popen", side_effect=OSError("no such file")) + def test_play_audio_popen_exception(self, mock_popen: MagicMock, + mock_finder: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should return None when Popen raises an exception.""" + from ovos_utils.sound import play_audio + result = play_audio("test.mp3") + self.assertIsNone(result) + + @patch("ovos_utils.sound.read_mycroft_config", return_value={}) + @patch("ovos_utils.sound._find_player", return_value="player %1") + @patch("subprocess.Popen") + def test_play_audio_strips_query_string(self, mock_popen: MagicMock, + mock_finder: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should strip query strings from URIs before processing.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + play_audio("http://example.com/stream?quality=high") + cmd_args = mock_popen.call_args[0][0] + # URI with no extension; query stripped + self.assertNotIn("?", " ".join(cmd_args)) + + @patch("ovos_utils.sound.read_mycroft_config", return_value={}) + @patch("ovos_utils.sound._find_player", return_value="player %1") + @patch("subprocess.Popen") + def test_play_audio_custom_play_cmd(self, mock_popen: MagicMock, + mock_finder: MagicMock, + mock_config: MagicMock) -> None: + """play_audio should use a caller-provided play_cmd if given.""" + mock_popen.return_value = MagicMock() + from ovos_utils.sound import play_audio + result = play_audio("test.wav", play_cmd="custom_player %1") + self.assertIsNotNone(result) + cmd_args = mock_popen.call_args[0][0] + self.assertIn("custom_player", cmd_args) + + +class TestGetSoundDuration(unittest.TestCase): + """Tests for get_sound_duration function.""" + + def test_wav_file_duration(self) -> None: + """get_sound_duration should return duration for a valid .wav file.""" + # Create a minimal valid wave file + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + fname = f.name + try: + with wave.open(fname, "w") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00" * 32000) # 1 second of silence + from ovos_utils.sound import get_sound_duration + duration = get_sound_duration(fname) + self.assertAlmostEqual(duration, 1.0, places=1) + finally: + os.unlink(fname) + + def test_file_not_found(self) -> None: + """get_sound_duration should raise FileNotFoundError for missing files.""" + from ovos_utils.sound import get_sound_duration + with self.assertRaises(FileNotFoundError): + get_sound_duration("/nonexistent/path/audio.wav") + + def test_snd_prefix_resolved(self) -> None: + """get_sound_duration should resolve snd/-prefixed paths using base_dir.""" + with tempfile.TemporaryDirectory() as base_dir: + snd_dir = os.path.join(base_dir, "snd") + os.makedirs(snd_dir) + wav_path = os.path.join(snd_dir, "test.wav") + with wave.open(wav_path, "w") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(8000) + wf.writeframes(b"\x00" * 8000) + from ovos_utils.sound import get_sound_duration + duration = get_sound_duration("snd/test.wav", base_dir=base_dir) + self.assertGreater(duration, 0) + + @patch("ovos_utils.sound.find_executable") + @patch("subprocess.Popen") + def test_ffprobe_fallback(self, mock_popen: MagicMock, + mock_find: MagicMock) -> None: + """get_sound_duration should use ffprobe for non-wav files when available.""" + # Create a temp file with non-wav extension + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: + f.write(b"\x00" * 100) + fname = f.name + try: + def find_side_effect(x: str) -> str | None: + return "/usr/bin/ffprobe" if x == "ffprobe" else None + mock_find.side_effect = find_side_effect + + mock_proc = MagicMock() + mock_proc.stdout.read.return_value = b"[FORMAT]\nduration=3.5\n[/FORMAT]\n" + mock_popen.return_value = mock_proc + + from ovos_utils.sound import get_sound_duration + duration = get_sound_duration(fname) + self.assertAlmostEqual(duration, 3.5, places=1) + finally: + os.unlink(fname) + + @patch("ovos_utils.sound.find_executable") + @patch("subprocess.Popen") + def test_mediainfo_fallback(self, mock_popen: MagicMock, + mock_find: MagicMock) -> None: + """get_sound_duration should use mediainfo when ffprobe is unavailable.""" + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: + f.write(b"\x00" * 100) + fname = f.name + try: + def find_side_effect(x: str) -> str | None: + return "/usr/bin/mediainfo" if x == "mediainfo" else None + mock_find.side_effect = find_side_effect + + mock_proc = MagicMock() + # Simulate mediainfo output with "Duration" field + mock_proc.stdout.read.return_value = ( + b"General\nDuration: 2 s 500 ms\n" + ) + mock_popen.return_value = mock_proc + + from ovos_utils.sound import get_sound_duration + duration = get_sound_duration(fname) + self.assertGreaterEqual(duration, 0) + finally: + os.unlink(fname) + + @patch("ovos_utils.sound.find_executable", 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: + f.write(b"\x00" * 100) + fname = f.name + try: + from ovos_utils.sound import get_sound_duration + with self.assertRaises(RuntimeError): + get_sound_duration(fname) + finally: + os.unlink(fname) + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ssml_extra.py b/test/unittests/test_ssml_extra.py new file mode 100644 index 00000000..ff6f3540 --- /dev/null +++ b/test/unittests/test_ssml_extra.py @@ -0,0 +1,262 @@ +# 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. +# +import unittest + +from ovos_utils.ssml import SSMLBuilder + + +class TestSSMLBuilderExtra(unittest.TestCase): + """Additional SSMLBuilder tests covering uncovered methods.""" + + TEXT = "hello world" + + def test_sub(self) -> None: + result = SSMLBuilder().sub(alias="World", word="W").build() + self.assertIn(" None: + with self.assertRaises(TypeError): + SSMLBuilder().sub(word="W") + + def test_sub_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().sub(alias="A") + + def test_sub_empty_word_raises(self) -> None: + with self.assertRaises(ValueError): + SSMLBuilder().sub(alias="A", word=" ") + + def test_emphasis(self) -> None: + result = SSMLBuilder().emphasis(level="moderate", word=self.TEXT).build() + self.assertIn("", result) + + def test_emphasis_none_level_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().emphasis(word=self.TEXT) + + def test_emphasis_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().emphasis(level="moderate") + + def test_emphasis_empty_word_raises(self) -> None: + with self.assertRaises(ValueError): + SSMLBuilder().emphasis(level="moderate", word=" ") + + def test_parts_of_speech(self) -> None: + result = SSMLBuilder().parts_of_speech(word="bass", role="amazon:VB").build() + self.assertIn("bass", result) + + def test_parts_of_speech_none_word_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().parts_of_speech(role="amazon:VB") + + def test_parts_of_speech_none_role_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().parts_of_speech(word="bass") + + def test_pause_by_strength(self) -> None: + result = SSMLBuilder().pause_by_strength(strength="Medium").build() + self.assertIn("", result) + + def test_pause_by_strength_none_raises(self) -> None: + with self.assertRaises(TypeError): + SSMLBuilder().pause_by_strength() + + def test_pause_by_strength_non_string_raises(self) -> None: + with self.assertRaises(AttributeError): + SSMLBuilder().pause_by_strength(strength=42) + + def test_audio(self) -> None: + result = SSMLBuilder().audio(audio_file="sound.mp3", text=self.TEXT).build() + self.assertIn("