diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml deleted file mode 100644 index af72644c..00000000 --- a/.github/workflows/build_tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Run Build Tests -on: - push: - branches: - - master - pull_request: - branches: - - dev - paths-ignore: - - 'ovos_workshop/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.7, 3.8, 3.9, "3.10" ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Python - uses: actions/setup-python@v1 - 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 swig libssl-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] - - uses: pypa/gh-action-pip-audit@v1.0.0 - with: - # Ignore setuptools vulnerability we can't do much about - ignore-vulns: | - GHSA-r9hx-vwmv-q579 \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..c6fec4ee --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,43 @@ +name: Run CodeCov +on: + push: + branches: + - dev + workflow_dispatch: + +jobs: + run: + runs-on: ubuntu-latest + env: + PYTHON: '3.9' + steps: + - uses: actions/checkout@master + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: 3.9 + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt install python3-dev + python -m pip install build wheel + - name: Install repo + run: | + pip install -e . + - name: Install test dependencies + run: | + sudo apt install libssl-dev libfann-dev portaudio19-dev libpulse-dev + pip install -r requirements/test.txt + - name: Generate coverage report + run: | + pytest --cov=./ovos_workshop --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage/reports/ + fail_ci_if_error: true + files: ./coverage.xml,!./cache + flags: unittests + name: codecov-umbrella + verbose: true \ No newline at end of file diff --git a/.github/workflows/dev2master.yml b/.github/workflows/dev2master.yml deleted file mode 100644 index cc76fee2..00000000 --- a/.github/workflows/dev2master.yml +++ /dev/null @@ -1,20 +0,0 @@ -# This workflow will generate a distribution and upload it to PyPI - -name: Push dev -> master -on: - workflow_dispatch: - -jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - ref: dev - - name: Push dev -> master - uses: ad-m/github-push-action@master - - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: master \ No newline at end of file diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml index 29f4063e..7d0c4f6b 100644 --- a/.github/workflows/license_tests.yml +++ b/.github/workflows/license_tests.yml @@ -1,44 +1,10 @@ name: Run License Tests on: push: - branches: - - master + workflow_dispatch: pull_request: branches: - - dev - workflow_dispatch: - + - master jobs: license_tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - 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 swig libssl-dev - - name: Install core repo - run: | - pip install . - - name: Get explicit and transitive dependencies - run: | - pip freeze > requirements-all.txt - - name: Check python - id: license_check_report - uses: pilosus/action-pip-license-checker@v0.5.0 - with: - requirements: 'requirements-all.txt' - fail: 'Copyleft,Other,Error' - fails-only: true - exclude: '^(tqdm).*' - exclude-license: '^(Mozilla).*$' - - name: Print report - if: ${{ always() }} - run: echo "${{ steps.license_check_report.outputs.report }}" \ No newline at end of file + uses: neongeckocom/.github/.github/workflows/license_tests.yml@master diff --git a/.github/workflows/propose_release.yml b/.github/workflows/propose_release.yml new file mode 100644 index 00000000..72db0763 --- /dev/null +++ b/.github/workflows/propose_release.yml @@ -0,0 +1,32 @@ +name: Propose Stable Release +on: + workflow_dispatch: + inputs: + release_type: + type: choice + description: Release Type + options: + - build + - minor + - major +jobs: + update_version: + uses: neongeckocom/.github/.github/workflows/propose_semver_release.yml@master + with: + release_type: ${{ inputs.release_type }} + version_file: ovos_workshop/version.py + alpha_var: VERSION_ALPHA + build_var: VERSION_BUILD + minor_var: VERSION_MINOR + major_var: VERSION_MAJOR + update_changelog: True + branch: dev + + pull_changes: + needs: update_version + uses: neongeckocom/.github/.github/workflows/pull_master.yml@master + with: + pr_assignee: ${{ github.actor }} + pr_draft: false + pr_title: ${{ needs.update_version.outputs.version }} + pr_body: ${{ needs.update_version.outputs.changelog }} diff --git a/.github/workflows/publish_alpha.yml b/.github/workflows/publish_alpha.yml index 27779cc5..efed3b01 100644 --- a/.github/workflows/publish_alpha.yml +++ b/.github/workflows/publish_alpha.yml @@ -19,54 +19,15 @@ on: workflow_dispatch: jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Increment Version - run: | - VER=$(python setup.py --version) - python scripts/bump_alpha.py - - name: "Generate release changelog" - uses: heinrichreimer/github-changelog-generator-action@v2.3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - id: changelog - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Increment Version - branch: dev - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - Changes in this Release - ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: true - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file + publish_alpha_release: + uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + with: + version_file: "ovos_workshop/version.py" + publish_prerelease: true + update_changelog: true + alpha_var: VERSION_ALPHA + build_var: VERSION_BUILD + minor_var: VERSION_MINOR + major_var: VERSION_MAJOR diff --git a/.github/workflows/publish_build.yml b/.github/workflows/publish_build.yml deleted file mode 100644 index 9fdcd309..00000000 --- a/.github/workflows/publish_build.yml +++ /dev/null @@ -1,76 +0,0 @@ -# This workflow will generate a distribution and upload it to PyPI - -name: Publish Build Release ..X -on: - workflow_dispatch: - -jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Remove alpha (declare stable) - run: | - VER=$(python setup.py --version) - python scripts/remove_alpha.py - - name: "Generate release changelog" - uses: heinrichreimer/github-changelog-generator-action@v2.3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - id: changelog - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Declare alpha stable - branch: dev - - name: Push dev -> master - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: master - force: true - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - Changes in this Release - ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: false - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Prepare next Build version - run: echo "::set-output name=version::$(python setup.py --version)" - id: alpha - - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 - run: | - VER=$(python setup.py --version) - python scripts/bump_build.py - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Prepare Next Version - branch: dev - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file diff --git a/.github/workflows/publish_major.yml b/.github/workflows/publish_major.yml deleted file mode 100644 index 87cee864..00000000 --- a/.github/workflows/publish_major.yml +++ /dev/null @@ -1,76 +0,0 @@ -# This workflow will generate a distribution and upload it to PyPI - -name: Publish Major Release X.0.0 -on: - workflow_dispatch: - -jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Remove alpha (declare stable) - run: | - VER=$(python setup.py --version) - python scripts/remove_alpha.py - - name: "Generate release changelog" - uses: heinrichreimer/github-changelog-generator-action@v2.3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - id: changelog - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Declare alpha stable - branch: dev - - name: Push dev -> master - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: master - force: true - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - Changes in this Release - ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: false - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Prepare next Major version - run: echo "::set-output name=version::$(python setup.py --version)" - id: alpha - - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 - run: | - VER=$(python setup.py --version) - python scripts/bump_major.py - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Prepare Next Version - branch: dev - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file diff --git a/.github/workflows/publish_minor.yml b/.github/workflows/publish_minor.yml deleted file mode 100644 index 4e8b2312..00000000 --- a/.github/workflows/publish_minor.yml +++ /dev/null @@ -1,76 +0,0 @@ -# This workflow will generate a distribution and upload it to PyPI - -name: Publish Minor Release .X.0 -on: - workflow_dispatch: - -jobs: - build_and_publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install Build Tools - run: | - python -m pip install build wheel - - name: Remove alpha (declare stable) - run: | - VER=$(python setup.py --version) - python scripts/remove_alpha.py - - name: "Generate release changelog" - uses: heinrichreimer/github-changelog-generator-action@v2.3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - id: changelog - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Declare alpha stable - branch: dev - - name: Push dev -> master - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: master - force: true - - name: version - run: echo "::set-output name=version::$(python setup.py --version)" - id: version - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: V${{ steps.version.outputs.version }} - release_name: Release ${{ steps.version.outputs.version }} - body: | - Changes in this Release - ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: false - - name: Build Distribution Packages - run: | - python setup.py bdist_wheel - - name: Prepare next Minor version - run: echo "::set-output name=version::$(python setup.py --version)" - id: alpha - - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 - run: | - VER=$(python setup.py --version) - python scripts/bump_minor.py - - name: Commit to dev - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Prepare Next Version - branch: dev - - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{secrets.PYPI_TOKEN}} \ No newline at end of file diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml new file mode 100644 index 00000000..185756ad --- /dev/null +++ b/.github/workflows/publish_release.yml @@ -0,0 +1,11 @@ +name: Publish Release +on: + push: + branches: + - master + +jobs: + build_and_publish_pypi_and_release: + uses: neongeckocom/.github/.github/workflows/publish_stable_release.yml@master + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 0cc68ec1..9d5c634b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,11 +31,15 @@ on: workflow_dispatch: jobs: + py_build_tests: + uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master + with: + python_version: "3.8" + unit_tests: strategy: - max-parallel: 2 matrix: - python-version: [ 3.7, 3.8, 3.9, "3.10" ] + python-version: [ 3.7, 3.8, 3.9, "3.10", "3.11" ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -50,31 +54,19 @@ jobs: python -m pip install build wheel - name: Install ovos workshop run: | - pip install . + pip install -e . - name: Install test dependencies run: | sudo apt install libssl-dev libfann-dev portaudio19-dev libpulse-dev - pip install ovos-core>=0.0.6a17 - pip install pytest pytest-timeout pytest-cov adapt-parser~=0.5 + pip install -r requirements/test.txt - name: Run unittests run: | pytest --cov=ovos_workshop --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) - - name: Replace ovos-core with mycroft-core - run: | - pip uninstall ovos-core -y - pip uninstall ovos-lingua-franca -y - pip install git+https://github.com/MycroftAI/mycroft-core - pip install . - - name: Run mycroft unittests - run: | - pytest --cov-append --cov=ovos_workshop --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) - name: Upload coverage + if: "${{ matrix.python-version == '3.9' }}" env: CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} uses: codecov/codecov-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 443dd24b..cc358eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,437 @@ # Changelog +## [0.0.12a50](https://github.com/OpenVoiceOS/OVOS-workshop/tree/0.0.12a50) (2023-07-20) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/0.0.12a49...0.0.12a50) + +**Merged pull requests:** + +- Add description to setup.py to fix \#125 [\#126](https://github.com/OpenVoiceOS/OVOS-workshop/pull/126) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [0.0.12a49](https://github.com/OpenVoiceOS/OVOS-workshop/tree/0.0.12a49) (2023-07-20) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a48...0.0.12a49) + +**Merged pull requests:** + +- Update automation to latest standards [\#125](https://github.com/OpenVoiceOS/OVOS-workshop/pull/125) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a48](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a48) (2023-07-20) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a47...V0.0.12a48) + +**Merged pull requests:** + +- Update dependencies to stable versions [\#124](https://github.com/OpenVoiceOS/OVOS-workshop/pull/124) ([NeonDaniel](https://github.com/NeonDaniel)) +- Fix codecov automation [\#123](https://github.com/OpenVoiceOS/OVOS-workshop/pull/123) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a47](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a47) (2023-07-19) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a46...V0.0.12a47) + +**Merged pull requests:** + +- Minor import and logging updates to troubleshoot logged warnings [\#122](https://github.com/OpenVoiceOS/OVOS-workshop/pull/122) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a46](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a46) (2023-07-15) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a45...V0.0.12a46) + +**Merged pull requests:** + +- Loosen ovos-backend-client dependency to allow latest stable version [\#121](https://github.com/OpenVoiceOS/OVOS-workshop/pull/121) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a45](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a45) (2023-07-14) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a44...V0.0.12a45) + +**Fixed bugs:** + +- PHAL and admin PHAL failures to successfully initialize started evening of Jul 13 [\#119](https://github.com/OpenVoiceOS/OVOS-workshop/issues/119) +- fix init [\#120](https://github.com/OpenVoiceOS/OVOS-workshop/pull/120) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a44](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a44) (2023-07-13) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a43...V0.0.12a44) + +**Merged pull requests:** + +- Update OVOSAbstractApplication and unit tests [\#118](https://github.com/OpenVoiceOS/OVOS-workshop/pull/118) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a43](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a43) (2023-07-13) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a42...V0.0.12a43) + +**Merged pull requests:** + +- Fix language handling in intent and entity file resolution [\#117](https://github.com/OpenVoiceOS/OVOS-workshop/pull/117) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a42](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a42) (2023-07-13) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a41...V0.0.12a42) + +**Closed issues:** + +- Since version 0.0.12a26, settings are not reloaded with my skill [\#91](https://github.com/OpenVoiceOS/OVOS-workshop/issues/91) + +**Merged pull requests:** + +- Unit tests and improvements to settings change callback [\#116](https://github.com/OpenVoiceOS/OVOS-workshop/pull/116) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a41](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a41) (2023-07-12) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a40...V0.0.12a41) + +## [V0.0.12a40](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a40) (2023-07-12) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a39...V0.0.12a40) + +**Closed issues:** + +- When core restarts, I got this trace in the "wikipedia" and "weather" skills [\#94](https://github.com/OpenVoiceOS/OVOS-workshop/issues/94) + +**Merged pull requests:** + +- Update `default_shutdown` with unit tests [\#115](https://github.com/OpenVoiceOS/OVOS-workshop/pull/115) ([NeonDaniel](https://github.com/NeonDaniel)) +- Docstrings, Annotation, and Outlined unit tests [\#111](https://github.com/OpenVoiceOS/OVOS-workshop/pull/111) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a39](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a39) (2023-07-12) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a38...V0.0.12a39) + +**Closed issues:** + +- When core restarts, I got this trace in the "hello world" skill [\#93](https://github.com/OpenVoiceOS/OVOS-workshop/issues/93) + +**Merged pull requests:** + +- Add locking around skill settings changes [\#114](https://github.com/OpenVoiceOS/OVOS-workshop/pull/114) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a38](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a38) (2023-07-12) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a37...V0.0.12a38) + +**Merged pull requests:** + +- Update requirements.txt [\#112](https://github.com/OpenVoiceOS/OVOS-workshop/pull/112) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a37](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a37) (2023-07-10) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a36...V0.0.12a37) + +**Merged pull requests:** + +- Add docstrings and unit tests for app.py [\#110](https://github.com/OpenVoiceOS/OVOS-workshop/pull/110) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a36](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a36) (2023-07-10) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a35...V0.0.12a36) + +**Implemented enhancements:** + +- Skills module tests, docstrings, and annotations [\#108](https://github.com/OpenVoiceOS/OVOS-workshop/pull/108) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a35](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a35) (2023-07-07) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a34...V0.0.12a35) + +**Merged pull requests:** + +- Decorator module tests, docstrings, and annotations [\#107](https://github.com/OpenVoiceOS/OVOS-workshop/pull/107) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a34](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a34) (2023-07-06) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a33...V0.0.12a34) + +**Merged pull requests:** + +- Refactor `SkillGUI` with unit tests [\#106](https://github.com/OpenVoiceOS/OVOS-workshop/pull/106) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a33](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a33) (2023-07-04) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a32...V0.0.12a33) + +**Fixed bugs:** + +- fix/play\_audio [\#105](https://github.com/OpenVoiceOS/OVOS-workshop/pull/105) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a32](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a32) (2023-06-24) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a31...V0.0.12a32) + +**Implemented enhancements:** + +- feat/session\_id\_wait\_while\_speaking [\#102](https://github.com/OpenVoiceOS/OVOS-workshop/pull/102) ([JarbasAl](https://github.com/JarbasAl)) + +**Closed issues:** + +- handle file change errors in logs [\#100](https://github.com/OpenVoiceOS/OVOS-workshop/issues/100) + +## [V0.0.12a31](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a31) (2023-06-19) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a30...V0.0.12a31) + +**Fixed bugs:** + +- fix: skill reloading broken [\#101](https://github.com/OpenVoiceOS/OVOS-workshop/pull/101) ([fidesachates](https://github.com/fidesachates)) + +## [V0.0.12a30](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a30) (2023-06-16) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a29...V0.0.12a30) + +**Merged pull requests:** + +- updated requirements [\#99](https://github.com/OpenVoiceOS/OVOS-workshop/pull/99) ([builderjer](https://github.com/builderjer)) + +## [V0.0.12a29](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a29) (2023-06-15) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a28...V0.0.12a29) + +**Merged pull requests:** + +- Update AudioInterface reference to resolve deprecation warning [\#98](https://github.com/OpenVoiceOS/OVOS-workshop/pull/98) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a28](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a28) (2023-06-14) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a27...V0.0.12a28) + +**Fixed bugs:** + +- fix skill initialization compat + unittests [\#95](https://github.com/OpenVoiceOS/OVOS-workshop/pull/95) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a27](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a27) (2023-05-17) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a26...V0.0.12a27) + +**Merged pull requests:** + +- Refactor to remove deprecated reference [\#90](https://github.com/OpenVoiceOS/OVOS-workshop/pull/90) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a26](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a26) (2023-05-17) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a25...V0.0.12a26) + +**Implemented enhancements:** + +- Implement Unit Tests [\#89](https://github.com/OpenVoiceOS/OVOS-workshop/pull/89) ([NeonDaniel](https://github.com/NeonDaniel)) + +**Closed issues:** + +- When core restart, the skill returns an error [\#82](https://github.com/OpenVoiceOS/OVOS-workshop/issues/82) + +## [V0.0.12a25](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a25) (2023-05-03) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a24...V0.0.12a25) + +**Fixed bugs:** + +- fix/core\_reload in standalone launcher [\#88](https://github.com/OpenVoiceOS/OVOS-workshop/pull/88) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a24](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a24) (2023-05-01) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a23...V0.0.12a24) + +**Implemented enhancements:** + +- refactor/skill\_init\_wizardry [\#70](https://github.com/OpenVoiceOS/OVOS-workshop/pull/70) ([JarbasAl](https://github.com/JarbasAl)) + +**Merged pull requests:** + +- fix/codecov automation [\#87](https://github.com/OpenVoiceOS/OVOS-workshop/pull/87) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a23](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a23) (2023-04-30) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a22...V0.0.12a23) + +**Fixed bugs:** + +- fix/missing\_requirement [\#86](https://github.com/OpenVoiceOS/OVOS-workshop/pull/86) ([JarbasAl](https://github.com/JarbasAl)) + +**Merged pull requests:** + +- workflow/codecov [\#85](https://github.com/OpenVoiceOS/OVOS-workshop/pull/85) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a22](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a22) (2023-04-30) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a21...V0.0.12a22) + +**Fixed bugs:** + +- fix/activate\_plugin\_skills [\#84](https://github.com/OpenVoiceOS/OVOS-workshop/pull/84) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a21](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a21) (2023-04-30) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a20...V0.0.12a21) + +**Fixed bugs:** + +- fix/super\_fallbacks [\#83](https://github.com/OpenVoiceOS/OVOS-workshop/pull/83) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a20](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a20) (2023-04-26) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a19...V0.0.12a20) + +**Fixed bugs:** + +- fix/fallback\_some\_more [\#81](https://github.com/OpenVoiceOS/OVOS-workshop/pull/81) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a19](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a19) (2023-04-26) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a18...V0.0.12a19) + +**Fixed bugs:** + +- fix/fallback [\#80](https://github.com/OpenVoiceOS/OVOS-workshop/pull/80) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a18](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a18) (2023-04-25) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a17...V0.0.12a18) + +**Implemented enhancements:** + +- refactor/fallback\_skills\_v2 [\#66](https://github.com/OpenVoiceOS/OVOS-workshop/pull/66) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a17](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a17) (2023-04-24) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a16...V0.0.12a17) + +## [V0.0.12a16](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a16) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a15...V0.0.12a16) + +**Implemented enhancements:** + +- feat/standalone\_skills [\#78](https://github.com/OpenVoiceOS/OVOS-workshop/pull/78) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a15](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a15) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a14...V0.0.12a15) + +**Merged pull requests:** + +- python-version: \[ 3.7, 3.8, 3.9, "3.10", "3.11"\] [\#77](https://github.com/OpenVoiceOS/OVOS-workshop/pull/77) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a14](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a14) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a13...V0.0.12a14) + +**Merged pull requests:** + +- refactor/common\_qa\_speak [\#63](https://github.com/OpenVoiceOS/OVOS-workshop/pull/63) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a13](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a13) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a12...V0.0.12a13) + +**Fixed bugs:** + +- fix/is\_classic\_core2 [\#76](https://github.com/OpenVoiceOS/OVOS-workshop/pull/76) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a12](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a12) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a11...V0.0.12a12) + +**Fixed bugs:** + +- fix/classic core checks [\#75](https://github.com/OpenVoiceOS/OVOS-workshop/pull/75) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a11](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a11) (2023-04-22) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a10...V0.0.12a11) + +**Implemented enhancements:** + +- feat/auto\_tx\_skills\_continued [\#74](https://github.com/OpenVoiceOS/OVOS-workshop/pull/74) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a10](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a10) (2023-04-21) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a9...V0.0.12a10) + +**Fixed bugs:** + +- ERROR - No module named 'ovos\_utils.lang.translate' [\#67](https://github.com/OpenVoiceOS/OVOS-workshop/issues/67) +- fix/universal\_skills\_are\_back [\#72](https://github.com/OpenVoiceOS/OVOS-workshop/pull/72) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a9](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a9) (2023-04-21) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a8...V0.0.12a9) + +**Fixed bugs:** + +- fix/skill\_launcher\_locale\_init [\#71](https://github.com/OpenVoiceOS/OVOS-workshop/pull/71) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a8](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a8) (2023-04-21) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a7...V0.0.12a8) + +**Merged pull requests:** + +- refactor/move\_intent\_decorators [\#73](https://github.com/OpenVoiceOS/OVOS-workshop/pull/73) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a7](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a7) (2023-04-20) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a6...V0.0.12a7) + +**Implemented enhancements:** + +- feat/skill\_launcher [\#65](https://github.com/OpenVoiceOS/OVOS-workshop/pull/65) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a6](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a6) (2023-04-20) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a5...V0.0.12a6) + +**Merged pull requests:** + +- Cleanup Changes [\#64](https://github.com/OpenVoiceOS/OVOS-workshop/pull/64) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a5](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a5) (2023-04-18) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a4...V0.0.12a5) + +**Merged pull requests:** + +- Update automation to match ovos-utils [\#62](https://github.com/OpenVoiceOS/OVOS-workshop/pull/62) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a4](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a4) (2023-04-14) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a3...V0.0.12a4) + +**Implemented enhancements:** + +- feat/common\_qa\_class [\#61](https://github.com/OpenVoiceOS/OVOS-workshop/pull/61) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a3](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a3) (2023-04-05) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a2...V0.0.12a3) + +**Merged pull requests:** + +- ovos-bus-client + skillGui class in ovos\_workshop [\#57](https://github.com/OpenVoiceOS/OVOS-workshop/pull/57) ([JarbasAl](https://github.com/JarbasAl)) + +## [V0.0.12a2](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a2) (2023-03-02) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.12a1...V0.0.12a2) + +**Closed issues:** + +- Add helper function `voc_list` to the OvosSkill class [\#53](https://github.com/OpenVoiceOS/OVOS-workshop/issues/53) + +**Merged pull requests:** + +- Add backwards-compat. voc\_cache property and setter [\#55](https://github.com/OpenVoiceOS/OVOS-workshop/pull/55) ([NeonDaniel](https://github.com/NeonDaniel)) + +## [V0.0.12a1](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.12a1) (2023-03-02) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.11...V0.0.12a1) + +**Merged pull requests:** + +- add `voc_list` helper function [\#54](https://github.com/OpenVoiceOS/OVOS-workshop/pull/54) ([emphasize](https://github.com/emphasize)) + +## [V0.0.11](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.11) (2023-02-25) + +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.11a6...V0.0.11) + ## [V0.0.11a6](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.11a6) (2023-02-25) [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.11a5...V0.0.11a6) @@ -234,10 +666,6 @@ [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.6...V0.0.7a1) -**Fixed bugs:** - -- Fix/optional adapt [\#19](https://github.com/OpenVoiceOS/OVOS-workshop/pull/19) ([JarbasAl](https://github.com/JarbasAl)) - ## [V0.0.6](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.6) (2022-03-03) [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.6a1...V0.0.6) @@ -246,14 +674,6 @@ [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.5...V0.0.6a1) -**Breaking changes:** - -- remove/skillgui\_patches [\#18](https://github.com/OpenVoiceOS/OVOS-workshop/pull/18) ([JarbasAl](https://github.com/JarbasAl)) - -**Closed issues:** - -- OVOSSkill class inherited skills do not initialize [\#17](https://github.com/OpenVoiceOS/OVOS-workshop/issues/17) - ## [V0.0.5](https://github.com/OpenVoiceOS/OVOS-workshop/tree/V0.0.5) (2022-02-25) [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/V0.0.5a12...V0.0.5) @@ -262,30 +682,6 @@ [Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/d9261b124f73a3e4d50c6edfcd9c2243b2bc3cf6...V0.0.5a12) -**Implemented enhancements:** - -- add idleDisplaySkill type [\#14](https://github.com/OpenVoiceOS/OVOS-workshop/pull/14) ([AIIX](https://github.com/AIIX)) -- Add media service based video player and seek controls [\#9](https://github.com/OpenVoiceOS/OVOS-workshop/pull/9) ([AIIX](https://github.com/AIIX)) -- add a busy page for common play [\#8](https://github.com/OpenVoiceOS/OVOS-workshop/pull/8) ([AIIX](https://github.com/AIIX)) -- Add new work in progress audio player ui for media service [\#6](https://github.com/OpenVoiceOS/OVOS-workshop/pull/6) ([AIIX](https://github.com/AIIX)) -- Add next and previous buttons [\#4](https://github.com/OpenVoiceOS/OVOS-workshop/pull/4) ([AIIX](https://github.com/AIIX)) -- add player pos property and fix mycroft players for plugin [\#3](https://github.com/OpenVoiceOS/OVOS-workshop/pull/3) ([AIIX](https://github.com/AIIX)) - -**Fixed bugs:** - -- Fix/idleskill [\#15](https://github.com/OpenVoiceOS/OVOS-workshop/pull/15) ([NeonJarbas](https://github.com/NeonJarbas)) -- remove forced focus event to allow page swipes [\#11](https://github.com/OpenVoiceOS/OVOS-workshop/pull/11) ([AIIX](https://github.com/AIIX)) -- fix end of media state [\#10](https://github.com/OpenVoiceOS/OVOS-workshop/pull/10) ([AIIX](https://github.com/AIIX)) -- fix icon paths and lower version [\#7](https://github.com/OpenVoiceOS/OVOS-workshop/pull/7) ([AIIX](https://github.com/AIIX)) -- fix AudioPlayer property name [\#5](https://github.com/OpenVoiceOS/OVOS-workshop/pull/5) ([AIIX](https://github.com/AIIX)) -- fix condition in video player [\#2](https://github.com/OpenVoiceOS/OVOS-workshop/pull/2) ([AIIX](https://github.com/AIIX)) -- add a timeout to videoplayer when nothing is playing for more than 60 seconds [\#1](https://github.com/OpenVoiceOS/OVOS-workshop/pull/1) ([AIIX](https://github.com/AIIX)) - -**Merged pull requests:** - -- Feat/workflows [\#16](https://github.com/OpenVoiceOS/OVOS-workshop/pull/16) ([JarbasAl](https://github.com/JarbasAl)) -- feat/pypi\_automation [\#13](https://github.com/OpenVoiceOS/OVOS-workshop/pull/13) ([JarbasAl](https://github.com/JarbasAl)) - \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/README.md b/README.md new file mode 100644 index 00000000..960d9c63 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# OVOS Workshop +OVOS Workshop contains skill base classes and supporting tools to build skills +and applications for OpenVoiceOS systems. diff --git a/ovos_workshop/app.py b/ovos_workshop/app.py index 6e0a5208..b4f134c5 100644 --- a/ovos_workshop/app.py +++ b/ovos_workshop/app.py @@ -1,18 +1,37 @@ from os.path import isdir, join - +from typing import Optional from ovos_config.locations import get_xdg_config_save_path from ovos_utils.messagebus import get_mycroft_bus -from ovos_utils.log import LOG - +from ovos_utils.log import log_deprecation +from ovos_utils.gui import GUIInterface +from ovos_bus_client.client.client import MessageBusClient from ovos_workshop.resource_files import locate_lang_directories from ovos_workshop.skills.ovos import OVOSSkill class OVOSAbstractApplication(OVOSSkill): - def __init__(self, skill_id, bus=None, resources_dir=None, - lang=None, settings=None, gui=None, enable_settings_manager=False): - super().__init__(bus=bus, gui=gui, resources_dir=resources_dir, - enable_settings_manager=enable_settings_manager) + def __init__(self, skill_id: str, bus: Optional[MessageBusClient] = None, + resources_dir: Optional[str] = None, + lang=None, settings: Optional[dict] = None, + gui: Optional[GUIInterface] = None, + enable_settings_manager: bool = False, **kwargs): + """ + Create an Application. An application is essentially a skill, but + designed such that it may be run without an intent service. + @param skill_id: Unique ID for this application + @param bus: MessageBusClient to bind to application + @param resources_dir: optional root resource directory (else defaults to + application `root_dir` + @param lang: DEPRECATED language of the application + @param settings: DEPRECATED settings object + @param gui: GUIInterface to bind (if `None`, one is created) + @param enable_settings_manager: if True, enables a SettingsManager for + this application to manage default settings and backend sync + """ + super().__init__(skill_id=skill_id, bus=bus, gui=gui, + resources_dir=resources_dir, + enable_settings_manager=enable_settings_manager, + **kwargs) self.skill_id = skill_id self._dedicated_bus = False if bus: @@ -22,25 +41,38 @@ def __init__(self, skill_id, bus=None, resources_dir=None, bus = get_mycroft_bus() self._startup(bus, skill_id) if settings: - LOG.warning("settings arg is deprecated and will be removed " - "in a future release") + log_deprecation(f"Settings should be set in {self._settings_path}. " + f"Passing `settings` to __init__ is not supported.", + "0.1.0") self.settings.merge(settings) @property - def _settings_path(self): + def _settings_path(self) -> str: + """ + Overrides the default path to put settings in `apps` subdirectory. + """ return join(get_xdg_config_save_path(), 'apps', self.skill_id, 'settings.json') def default_shutdown(self): + """ + Shutdown this application. + """ self.clear_intents() super().default_shutdown() if self._dedicated_bus: self.bus.close() - def get_language_dir(self, base_path=None, lang=None): - """ checks for all language variations and returns best path - eg, if lang is set to pt-pt but only pt-br resources exist, - those will be loaded instead of failing, or en-gb vs en-us and so on + def get_language_dir(self, base_path: Optional[str] = None, + lang: Optional[str] = None) -> Optional[str]: + """ + Get the best matched language resource directory for the requested lang. + This will consider dialects for the requested language, i.e. if lang is + set to pt-pt but only pt-br resources exist, the `pt-br` resource path + will be returned. + @param base_path: root path to find resources (default res_dir) + @param lang: language to get resources for (default self.lang) + @return: path to language resources if they exist, else None """ base_path = base_path or self.res_dir @@ -56,13 +88,15 @@ def get_language_dir(self, base_path=None, lang=None): similar_dialect_directories = locate_lang_directories(lang, base_path) for directory in similar_dialect_directories: if directory.exists(): - return directory + return str(directory) def clear_intents(self): - # remove bus handlers, otherwise if re-registered we get multiple - # handler executions + """ + Remove bus event handlers and detach from the intent service to prevent + multiple registered handlers. + """ for intent_name, _ in self.intent_service: event_name = f'{self.skill_id}:{intent_name}' self.remove_event(event_name) - - self.intent_service.detach_all() # delete old intents before re-registering + # delete old intents before re-registering + self.intent_service.detach_all() diff --git a/ovos_workshop/decorators/__init__.py b/ovos_workshop/decorators/__init__.py index 4f382502..bc00d219 100644 --- a/ovos_workshop/decorators/__init__.py +++ b/ovos_workshop/decorators/__init__.py @@ -1,19 +1,89 @@ +from ovos_utils.log import log_deprecation from ovos_workshop.decorators.killable import killable_intent, killable_event from ovos_workshop.decorators.layers import enables_layer, \ disables_layer, layer_intent, removes_layer, resets_layers, replaces_layer -from ovos_workshop.decorators.converse import converse_handler -from ovos_workshop.decorators.fallback_handler import fallback_handler +from ovos_workshop.decorators.ocp import ocp_play, ocp_pause, ocp_resume, \ + ocp_search, ocp_previous, ocp_featured_media +from functools import wraps + +# TODO: Deprecate unused import retained for backwards-compat. from ovos_utils import classproperty -try: - from ovos_workshop.decorators.ocp import ocp_next, ocp_play, ocp_pause, ocp_resume, ocp_search, ocp_previous, ocp_featured_media -except ImportError: - pass # these imports are only available if extra requirements are installed -def resting_screen_handler(name): - """Decorator for adding a method as an resting screen handler. +def adds_context(context: str, words: str = ''): + """ + Decorator to add context to the Adapt context manager. + + Args: + context (str): context Keyword to insert + words (str): optional string content of Keyword + """ + + def context_add_decorator(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + ret = func(*args, **kwargs) + args[0].set_context(context, words) + return ret + return func_wrapper + return context_add_decorator + - If selected will be shown on screen when device enters idle mode. +def removes_context(context: str): + """ + Decorator to remove context from the Adapt context manager. + + Args: + context (str): Context keyword to remove + """ + + def context_removes_decorator(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + ret = func(*args, **kwargs) + args[0].remove_context(context) + return ret + return func_wrapper + return context_removes_decorator + + +def intent_handler(intent_parser: object): + """ + Decorator for adding a method as an intent handler. + @param intent_parser: string intent name or adapt.IntentBuilder object + """ + + def real_decorator(func): + # Store the intent_parser inside the function + # This will be used later to call register_intent + if not hasattr(func, 'intents'): + func.intents = [] + func.intents.append(intent_parser) + return func + + return real_decorator + + +def intent_file_handler(intent_file: str): + """ + Deprecated decorator for adding a method as an intent file handler. + """ + def real_decorator(func): + # Store the intent_file inside the function + # This will be used later to call register_intent_file + if not hasattr(func, 'intent_files'): + func.intent_files = [] + func.intent_files.append(intent_file) + return func + log_deprecation(f"Use `@intent_handler({intent_file})`", "0.1.0") + return real_decorator + + +def resting_screen_handler(name: str): + """ + Decorator for adding a method as a resting screen handler to optionally + be shown on screen when device enters idle mode. + @param name: Name of the restring screen to register """ def real_decorator(func): @@ -25,3 +95,39 @@ def real_decorator(func): return real_decorator + +def skill_api_method(func: callable): + """ + Decorator for adding a method to the skill's public api. Methods with this + decorator will be registered on the messagebus and an api object can be + created for interaction with the skill. + @param func: API method to expose + """ + # tag the method by adding an api_method member to it + if not hasattr(func, 'api_method') and hasattr(func, '__name__'): + func.api_method = True + return func + + +def converse_handler(func): + """ + Decorator for aliasing a method as the converse method + """ + if not hasattr(func, 'converse'): + func.converse = True + return func + + +def fallback_handler(priority: int = 50): + """ + Decorator for adding a fallback intent handler. + + @param priority: Fallback priority (0-100) with lower values having higher + priority + """ + def real_decorator(func): + if not hasattr(func, 'fallback_priority'): + func.fallback_priority = priority + return func + + return real_decorator diff --git a/ovos_workshop/decorators/converse.py b/ovos_workshop/decorators/converse.py index 126761d7..f4e5fcc2 100644 --- a/ovos_workshop/decorators/converse.py +++ b/ovos_workshop/decorators/converse.py @@ -1,11 +1,11 @@ +from ovos_utils.log import log_deprecation -def converse_handler(): - """Decorator for aliasing a method as the converse method""" - - def real_decorator(func): - if not hasattr(func, 'converse'): - func.converse = True - return func - - return real_decorator +def converse_handler(func): + """ + Decorator for aliasing a method as the converse method + """ + log_deprecation("Import from `ovos_workshop.decorators`", "0.1.0") + if not hasattr(func, 'converse'): + func.converse = True + return func diff --git a/ovos_workshop/decorators/fallback_handler.py b/ovos_workshop/decorators/fallback_handler.py index 56b79c49..76ff7861 100644 --- a/ovos_workshop/decorators/fallback_handler.py +++ b/ovos_workshop/decorators/fallback_handler.py @@ -1,5 +1,9 @@ +from ovos_utils.log import log_deprecation + def fallback_handler(priority=50): + log_deprecation("Import from `ovos_workshop.decorators`", "0.1.0") + def real_decorator(func): if not hasattr(func, 'fallback_priority'): func.fallback_priority = priority diff --git a/ovos_workshop/decorators/killable.py b/ovos_workshop/decorators/killable.py index 65e2f651..cb65aa5c 100644 --- a/ovos_workshop/decorators/killable.py +++ b/ovos_workshop/decorators/killable.py @@ -1,3 +1,5 @@ +from typing import Callable, Optional, Type + import time from ovos_utils import create_killable_daemon from ovos_utils.messagebus import Message @@ -18,16 +20,36 @@ class AbortQuestion(AbortEvent): """ gracefully abort get_response queries """ -def killable_intent(msg="mycroft.skills.abort_execution", - callback=None, react_to_stop=True, call_stop=True, - stop_tts=True): +def killable_intent(msg: str = "mycroft.skills.abort_execution", + callback: Optional[callable] = None, + react_to_stop: bool = True, + call_stop: bool = True, stop_tts: bool = True) -> callable: + """ + Decorator to mark an intent that can be terminated during execution. + @param msg: Message name to terminate on + @param callback: Optional function or method to call on termination + @param react_to_stop: If true, also terminate on `stop` Messages + @param call_stop: If true, also call `Class.stop` method + @param stop_tts: If true, emit message to stop TTS audio playback + """ return killable_event(msg, AbortIntent, callback, react_to_stop, call_stop, stop_tts) -def killable_event(msg="mycroft.skills.abort_execution", exc=AbortEvent, - callback=None, react_to_stop=False, call_stop=False, - stop_tts=False): +def killable_event(msg: str = "mycroft.skills.abort_execution", + exc: Type[Exception] = AbortEvent, + callback: Optional[callable] = None, + react_to_stop: bool = False, call_stop: bool = False, + stop_tts: bool = False): + """ + Decorator to mark a method that can be terminated during execution. + @param msg: Message name to terminate on + @param exc: Exception to raise in killed thread + @param callback: Optional function or method to call on termination + @param react_to_stop: If true, also terminate on `stop` Messages + @param call_stop: If true, also call `Class.stop` method + @param stop_tts: If true, emit message to stop TTS audio playback + """ # Begin wrapper def create_killable(func): @@ -54,7 +76,7 @@ def abort(_): try: while t.is_alive(): t.raise_exc(exc) - time.sleep(0.1) + t.join(1) except threading.ThreadError: pass # already killed except AssertionError: @@ -79,4 +101,3 @@ def abort(_): return call_function return create_killable - diff --git a/ovos_workshop/decorators/layers.py b/ovos_workshop/decorators/layers.py index b305fa78..569e6adc 100644 --- a/ovos_workshop/decorators/layers.py +++ b/ovos_workshop/decorators/layers.py @@ -1,36 +1,44 @@ import inspect from functools import wraps -from ovos_utils.log import LOG +from typing import Optional, List +from ovos_bus_client import MessageBusClient +from ovos_utils.log import LOG +from ovos_workshop.skills.base import BaseSkill -def dig_for_skill(max_records: int = 10): - from ovos_workshop.app import OVOSAbstractApplication - from ovos_workshop.skills import MycroftSkill +def dig_for_skill(max_records: int = 10) -> Optional[object]: + """ + Dig through the call stack to locate a Skill object + @param max_records: maximum number of records in the stack to check + @return: Skill or AbstractApplication instance if found + """ stack = inspect.stack()[1:] # First frame will be this function call stack = stack if len(stack) <= max_records else stack[:max_records] for record in stack: args = inspect.getargvalues(record.frame) if args.locals.get("self"): obj = args.locals["self"] - if isinstance(obj, MycroftSkill) or \ - isinstance(obj, OVOSAbstractApplication): + if isinstance(obj, BaseSkill): return obj elif args.locals.get("args"): for obj in args.locals["args"]: - if isinstance(obj, MycroftSkill) or \ - isinstance(obj, OVOSAbstractApplication): + if isinstance(obj, BaseSkill): return obj return None -def enables_layer(layer_name): +def enables_layer(layer_name: str): + """ + Decorator to enable an intent layer when a method is called + @param layer_name: name of intent layer to enable + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.activate_layer(layer_name) @@ -39,13 +47,17 @@ def call_function(*args, **kwargs): return layer_handler -def disables_layer(layer_name): +def disables_layer(layer_name: str): + """ + Decorator to disable an intent layer when a method is called + @param layer_name: name of intent layer to disable + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.deactivate_layer(layer_name) @@ -54,13 +66,18 @@ def call_function(*args, **kwargs): return layer_handler -def replaces_layer(layer_name, intent_list): +def replaces_layer(layer_name: str, intent_list: Optional[List[str]]): + """ + Replaces intents at the specified layer + @param layer_name: name of intent layer to replace + @param intent_list: list of new intents for the specified layer + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.replace_layer(layer_name, intent_list) @@ -69,15 +86,19 @@ def call_function(*args, **kwargs): return layer_handler -def removes_layer(layer_name, intent_list): +def removes_layer(layer_name: str): + """ + Decorator to remove an intent layer when a method is called + @param layer_name: name of intent layer to remove + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) - skill.intent_layers.replace_layer(layer_name, intent_list) + skill.intent_layers.remove_layer(layer_name) return call_function @@ -85,12 +106,15 @@ def call_function(*args, **kwargs): def resets_layers(): + """ + Decorator to reset and disable intent layers + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.disable() @@ -99,9 +123,13 @@ def call_function(*args, **kwargs): return layer_handler -def layer_intent(intent_parser, layer_name): - """Decorator for adding a method as an intent handler belonging to an - intent layer.""" +def layer_intent(intent_parser: callable, layer_name: str): + """ + Decorator for adding a method as an intent handler belonging to an + intent layer. + @param intent_parser: intent parser method + @param layer_name: name of intent layer intent is associated with + """ def real_decorator(func): # Store the intent_parser inside the function @@ -136,25 +164,25 @@ def __init__(self): self._layers = {} self._active_layers = [] - def bind(self, skill): + def bind(self, skill: object): if skill: self._skill = skill return self @property - def skill(self): + def skill(self) -> BaseSkill: return self._skill @property - def bus(self): + def bus(self) -> Optional[MessageBusClient]: return self._skill.bus if self._skill else None @property - def skill_id(self): + def skill_id(self) -> str: return self._skill.skill_id if self._skill else "IntentLayers" @property - def active_layers(self): + def active_layers(self) -> List[str]: return self._active_layers def disable(self): @@ -163,7 +191,8 @@ def disable(self): for layer_name, intents in self._layers.items(): self.deactivate_layer(layer_name) - def update_layer(self, layer_name, intent_list=None): + def update_layer(self, layer_name: str, + intent_list: Optional[List[str]] = None): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" intent_list = intent_list or [] @@ -172,7 +201,7 @@ def update_layer(self, layer_name, intent_list=None): self._layers[layer_name] += intent_list or [] LOG.info(f"Adding {intent_list} to {layer_name}") - def activate_layer(self, layer_name): + def activate_layer(self, layer_name: str): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" if layer_name in self._layers: @@ -184,7 +213,7 @@ def activate_layer(self, layer_name): else: LOG.debug("no layer named: " + layer_name) - def deactivate_layer(self, layer_name): + def deactivate_layer(self, layer_name: str): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" if layer_name in self._layers: @@ -196,7 +225,7 @@ def deactivate_layer(self, layer_name): else: LOG.debug("no layer named: " + layer_name) - def remove_layer(self, layer_name): + def remove_layer(self, layer_name: str): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" if layer_name in self._layers: @@ -206,7 +235,8 @@ def remove_layer(self, layer_name): else: LOG.debug("no layer named: " + layer_name) - def replace_layer(self, layer_name, intent_list=None): + def replace_layer(self, layer_name: str, + intent_list: Optional[List[str]] = None): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" if layer_name in self._layers: @@ -215,7 +245,7 @@ def replace_layer(self, layer_name, intent_list=None): else: self.update_layer(layer_name, intent_list) - def is_active(self, layer_name): + def is_active(self, layer_name: str): if not layer_name.startswith(f"{self.skill_id}:"): layer_name = f"{self.skill_id}:{layer_name}" return layer_name in self.active_layers diff --git a/ovos_workshop/decorators/ocp.py b/ovos_workshop/decorators/ocp.py index 74350e7f..164563a0 100644 --- a/ovos_workshop/decorators/ocp.py +++ b/ovos_workshop/decorators/ocp.py @@ -1,7 +1,3 @@ -from functools import wraps -from ovos_workshop.decorators.layers import IntentLayers -from ovos_plugin_common_play.ocp import * -from ovos_plugin_common_play.ocp.status import * def ocp_search(): @@ -34,7 +30,9 @@ def real_decorator(func): def ocp_play(): - """Decorator for adding a method as an common play search handler.""" + """ + Decorator for adding a method to handle media playback. + """ def real_decorator(func): # Store the flag inside the function @@ -48,7 +46,9 @@ def real_decorator(func): def ocp_previous(): - """Decorator for adding a method as an common play prev handler.""" + """ + Decorator for adding a method to handle requests to skip backward. + """ def real_decorator(func): # Store the flag inside the function @@ -62,7 +62,9 @@ def real_decorator(func): def ocp_next(): - """Decorator for adding a method as an common play next handler.""" + """ + Decorator for adding a method to handle requests to skip forward. + """ def real_decorator(func): # Store the flag inside the function @@ -76,7 +78,9 @@ def real_decorator(func): def ocp_pause(): - """Decorator for adding a method as an common play pause handler.""" + """ + Decorator for adding a method to handle requests to pause playback. + """ def real_decorator(func): # Store the flag inside the function @@ -90,7 +94,9 @@ def real_decorator(func): def ocp_resume(): - """Decorator for adding a method as an common play resume handler.""" + """ + Decorator for adding a method to handle requests to resume playback. + """ def real_decorator(func): # Store the flag inside the function @@ -104,7 +110,9 @@ def real_decorator(func): def ocp_featured_media(): - """Decorator for adding a method as an common play search handler.""" + """ + Decorator for adding a method to handle requests to provide featured media. + """ def real_decorator(func): # Store the flag inside the function @@ -115,3 +123,133 @@ def real_decorator(func): return func return real_decorator + + +try: + from ovos_plugin_common_play.ocp.status import MediaType, PlayerState, \ + MediaState, MatchConfidence, PlaybackType, PlaybackMode, LoopState, \ + TrackState +except ImportError: + + # TODO - manually keep these in sync as needed + # apps interfacing with OCP need the enums, + # but they are native to OCP does not make sense for OCP to import them from here, + # therefore we duplicate them when needed + from enum import IntEnum + + + class MatchConfidence(IntEnum): + EXACT = 95 + VERY_HIGH = 90 + HIGH = 80 + AVERAGE_HIGH = 70 + AVERAGE = 50 + AVERAGE_LOW = 30 + LOW = 15 + VERY_LOW = 1 + + + class TrackState(IntEnum): + DISAMBIGUATION = 1 # media result, not queued for playback + + PLAYING_SKILL = 20 # Skill is handling playback internally + PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service + PLAYING_VIDEO = 22 # Skill forwarded playback to gui player + PLAYING_AUDIO = 23 # Skill forwarded audio playback to gui player + PLAYING_MPRIS = 24 # External media player is handling playback + PLAYING_WEBVIEW = 25 # Media playback handled in browser (eg. javascript) + + QUEUED_SKILL = 30 # Waiting playback to be handled inside skill + QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service + QUEUED_VIDEO = 32 # Waiting playback in gui + QUEUED_AUDIO = 33 # Waiting playback in gui + QUEUED_WEBVIEW = 34 # Waiting playback in gui + + + class MediaState(IntEnum): + # https://doc.qt.io/qt-5/qmediaplayer.html#MediaStatus-enum + # The status of the media cannot be determined. + UNKNOWN = 0 + # There is no current media. PlayerState == STOPPED + NO_MEDIA = 1 + # The current media is being loaded. The player may be in any state. + LOADING_MEDIA = 2 + # The current media has been loaded. PlayerState== STOPPED + LOADED_MEDIA = 3 + # Playback of the current media has stalled due to + # insufficient buffering or some other temporary interruption. + # PlayerState != STOPPED + STALLED_MEDIA = 4 + # The player is buffering data but has enough data buffered + # for playback to continue for the immediate future. + # PlayerState != STOPPED + BUFFERING_MEDIA = 5 + # The player has fully buffered the current media. PlayerState != STOPPED + BUFFERED_MEDIA = 6 + # Playback has reached the end of the current media. PlayerState == STOPPED + END_OF_MEDIA = 7 + # The current media cannot be played. PlayerState == STOPPED + INVALID_MEDIA = 8 + + + class PlayerState(IntEnum): + # https://doc.qt.io/qt-5/qmediaplayer.html#State-enum + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + + + class LoopState(IntEnum): + NONE = 0 + REPEAT = 1 + REPEAT_TRACK = 2 + + + class PlaybackType(IntEnum): + SKILL = 0 # skills handle playback whatever way they see fit, + # eg spotify / mycroft common play + VIDEO = 1 # Video results + AUDIO = 2 # Results should be played audio only + AUDIO_SERVICE = 3 # Results should be played without using the GUI + MPRIS = 4 # External MPRIS compliant player + WEBVIEW = 5 # GUI webview, render a url instead of media player + UNDEFINED = 100 # data not available, hopefully status will be updated soon.. + + + class PlaybackMode(IntEnum): + AUTO = 0 # play each entry as considered appropriate, + # ie, make it happen the best way possible + AUDIO_ONLY = 10 # only consider audio entries + VIDEO_ONLY = 20 # only consider video entries + FORCE_AUDIO = 30 # cast video to audio unconditionally + # (audio can still play in mycroft-gui) + FORCE_AUDIOSERVICE = 40 # cast everything to audio service backend, + # mycroft-gui will not be used + EVENTS_ONLY = 50 # only emit ocp events, do not display or play anything. + # allows integration with external interfaces + + + class MediaType(IntEnum): + GENERIC = 0 + AUDIO = 1 + MUSIC = 2 + VIDEO = 3 + AUDIOBOOK = 4 + GAME = 5 + PODCAST = 6 + RADIO = 7 + NEWS = 8 + TV = 9 + MOVIE = 10 + TRAILER = 11 + VISUAL_STORY = 13 + BEHIND_THE_SCENES = 14 + DOCUMENTARY = 15 + RADIO_THEATRE = 16 + SHORT_FILM = 17 + SILENT_MOVIE = 18 + BLACK_WHITE_MOVIE = 20 + CARTOON = 21 + + ADULT = 69 + HENTAI = 70 diff --git a/ovos_workshop/filesystem.py b/ovos_workshop/filesystem.py index 45a84225..05243efd 100644 --- a/ovos_workshop/filesystem.py +++ b/ovos_workshop/filesystem.py @@ -11,27 +11,32 @@ # 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 os import shutil from os.path import join, expanduser, isdir +from typing import TextIO + from ovos_config.locations import get_xdg_data_save_path from ovos_config.meta import get_xdg_base +from ovos_utils.log import log_deprecation class FileSystemAccess: - """A class for providing access to the mycroft FS sandbox. - - Intended to be attached to skills at initialization time to provide a - skill-specific namespace. - """ - - def __init__(self, path): - #: Member value containing the root path of the namespace + def __init__(self, path: str): + """ + Create a filesystem in a valid location. + @param path: path basename/name of the module requesting a filesystem + """ self.path = self.__init_path(path) @staticmethod - def __init_path(path): + def __init_path(path: str): + """ + Initialize a directory for filesystem access. + @param path: path basename to initialize + @return: validated existing path for this filesystem + """ if not isinstance(path, str) or len(path) == 0: raise ValueError("path must be initialized as a non empty string") @@ -39,37 +44,27 @@ def __init_path(path): xdg_path = expanduser(f'{get_xdg_data_save_path()}/filesystem/{path}') # Migrate from the old location if it still exists if isdir(old_path) and not isdir(xdg_path): + log_deprecation(f"Settings at {old_path} will be ignored", "0.1.0") shutil.move(old_path, xdg_path) if not isdir(xdg_path): os.makedirs(xdg_path) return xdg_path - def open(self, filename, mode): - """Open a file in the provided namespace. - - Get a handle to a file (with the provided mode) within the - skill-specific namespace. - - Parameters: - filename (str): a path relative to the namespace. - subdirs not currently supported. - - mode (str): a file handle mode - - Returns: - an open file handle. + def open(self, filename: str, mode: str) -> TextIO: + """ + Open the requested file in this FileSystem in the requested mode. + @param filename: string filename, relative to this FileSystemAccess + @param mode: mode to open file with (i.e. `rb`, `w+`) + @return: TextIO object for the requested file in the requested mode """ file_path = join(self.path, filename) return open(file_path, mode) - def exists(self, filename): - """Check if file exists in the namespace. - - Args: - filename (str): a path relative to the namespace. - subdirs not currently supported. - Returns: - bool: True if file exists, else False. + def exists(self, filename: str) -> bool: + """ + Check if a file exists in the namespace. + @param filename: string filename, relative to this FileSystemAccess + @return: True if the filename exists, else False """ return os.path.exists(join(self.path, filename)) diff --git a/ovos_workshop/permissions.py b/ovos_workshop/permissions.py index c4c59431..9325439d 100644 --- a/ovos_workshop/permissions.py +++ b/ovos_workshop/permissions.py @@ -1,26 +1,40 @@ -""" -mode of operation is defined in the .conf file for the different components -""" import enum class ConverseMode(str, enum.Enum): - ACCEPT_ALL = "accept_all" # default mycroft-core behavior - WHITELIST = "whitelist" # only call converse for skills in whitelist - BLACKLIST = "blacklist" # only call converse for skills NOT in blacklist + """ + Defines a mode for handling `converse` requests. + ACCEPT ALL - default behavior where all skills may implement `converse` + WHITELIST - only skills explicitly allowed may implement `converse` + BLACKLIST - all skills except those disallowed may implement `converse` + """ + ACCEPT_ALL = "accept_all" # Default + WHITELIST = "whitelist" + BLACKLIST = "blacklist" class FallbackMode(str, enum.Enum): - ACCEPT_ALL = "accept_all" # default mycroft-core behavior - WHITELIST = "whitelist" # only call fallback for skills in whitelist - BLACKLIST = "blacklist" # only call fallback for skills NOT in blacklist + """ + Defines a mode for handling fallbacks (utterances without a matched intent) + ACCEPT ALL - default behavior where all installed FallbackSkills are used + WHITELIST - only explicitly allowed FallbackSkills may respond + BLACKLIST - all FallbackSkills except those disallowed may respond + """ + ACCEPT_ALL = "accept_all" # Default + WHITELIST = "whitelist" + BLACKLIST = "blacklist" class ConverseActivationMode(str, enum.Enum): - ACCEPT_ALL = "accept_all" # default mycroft-core behavior - PRIORITY = "priority" # skills can only activate themselves if no skill - # with higher priority is active - WHITELIST = "whitelist" # only skills in "converse_whitelist" - # can activate themselves - BLACKLIST = "blacklist" # only skills NOT in converse "converse_blacklist" - # can activate themselves + """ + Defines a mode for manually activating `converse` handling + ACCEPT ALL - default behavior where any skill may activate itself + PRIORITY - a skill may only activate itself if no higher-priority skill is + currently active + WHITELIST - only explicitly allowed skills may activate themselves + BLACKLIST - all skills except those disallowed may activate themselves + """ + ACCEPT_ALL = "accept_all" # Default + PRIORITY = "priority" + WHITELIST = "whitelist" + BLACKLIST = "blacklist" diff --git a/ovos_workshop/res/text/cs-cz/noise_words.list b/ovos_workshop/res/text/cs-cz/noise_words.list new file mode 100644 index 00000000..903cc381 --- /dev/null +++ b/ovos_workshop/res/text/cs-cz/noise_words.list @@ -0,0 +1,24 @@ +kde +co je +který +jim +oni +kdy +co +to +bude +od +z +že +také +kdo +jak +a +ale +také +proč +pro +je +to +nebo +do diff --git a/ovos_workshop/res/text/de-de/noise_words.list b/ovos_workshop/res/text/de-de/noise_words.list new file mode 100644 index 00000000..cc619c9f --- /dev/null +++ b/ovos_workshop/res/text/de-de/noise_words.list @@ -0,0 +1,52 @@ +wo +wohin +sie +ihnen +sie +man +wann +als +wo +was +welcher +welche +welches +der +die +das +dass +daß +werden +werde +wirst +wird +werdet +wollen +willst +von +auch +wer +wie +tat +taten +und +aber +auch +warum +für +ist +es +tun +tut +oder +zu +auf +bis +von +aus +um +ein +einer +eines +mal +bitte diff --git a/ovos_workshop/res/text/en-us/noise_words.list b/ovos_workshop/res/text/en-us/noise_words.list new file mode 100644 index 00000000..85cf69de --- /dev/null +++ b/ovos_workshop/res/text/en-us/noise_words.list @@ -0,0 +1,30 @@ +where +what's +which +them +they +when +what +that +will +from +that +also +who +how +did +and +but +the +too +why +for +is +it +do +or +to +of +a + + diff --git a/ovos_workshop/resource_files.py b/ovos_workshop/resource_files.py index d576dc05..b146d12c 100644 --- a/ovos_workshop/resource_files.py +++ b/ovos_workshop/resource_files.py @@ -17,6 +17,7 @@ import re from collections import namedtuple from os import walk +from os.path import dirname from pathlib import Path from typing import List, Optional, Tuple @@ -46,9 +47,19 @@ ) -def locate_base_directories(skill_directory, resource_subdirectory=None): - base_dirs = [Path(skill_directory, resource_subdirectory)] if resource_subdirectory else [] - base_dirs += [Path(skill_directory, "locale"), Path(skill_directory, "text")] +def locate_base_directories(skill_directory: str, + resource_subdirectory: Optional[str] = None) -> \ + List[Path]: + """ + Locate all possible resource directories found in the given skill_directory + @param skill_directory: skill base directory to search for resources + @param resource_subdirectory: optional extra resource directory to prepend + @return: list of existing skill resource directories + """ + base_dirs = [Path(skill_directory, resource_subdirectory)] if \ + resource_subdirectory else [] + base_dirs += [Path(skill_directory, "locale"), + Path(skill_directory, "text")] candidates = [] for directory in base_dirs: if directory.exists(): @@ -56,7 +67,17 @@ def locate_base_directories(skill_directory, resource_subdirectory=None): return candidates -def locate_lang_directories(lang, skill_directory, resource_subdirectory=None): +def locate_lang_directories(lang: str, skill_directory: str, + resource_subdirectory: Optional[str] = None) -> \ + List[Path]: + """ + Locate all possible resource directories found in the given skill_directory + for the specified language + @param lang: BCP-47 language code to get resources for + @param skill_directory: skill base directory to search for resources + @param resource_subdirectory: optional extra resource directory to prepend + @return: list of existing skill resource directories for the given lang + """ base_lang = lang.split("-")[0] base_dirs = [Path(skill_directory, "locale"), Path(skill_directory, "text")] @@ -71,6 +92,77 @@ def locate_lang_directories(lang, skill_directory, resource_subdirectory=None): return candidates +def resolve_resource_file(res_name: str) -> Optional[str]: + """Convert a resource into an absolute filename. + + Resource names are in the form: 'filename.ext' + or 'path/filename.ext' + + The system wil look for $XDG_DATA_DIRS/mycroft/res_name first + (defaults to ~/.local/share/mycroft/res_name), and if not found will + look at /opt/mycroft/res_name, then finally it will look for res_name + in the 'mycroft/res' folder of the source code package. + + Example: + With mycroft running as the user 'bob', if you called + ``resolve_resource_file('snd/beep.wav')`` + it would return either: + '$XDG_DATA_DIRS/mycroft/beep.wav', + '/home/bob/.mycroft/snd/beep.wav' or + '/opt/mycroft/snd/beep.wav' or + '.../mycroft/res/snd/beep.wav' + where the '...' is replaced by the path + where the package has been installed. + + Args: + res_name (str): a resource path/name + + Returns: + (str) path to resource or None if no resource found + """ + # TODO: Deprecate in 0.1.0 + LOG.warning(f"This method has moved to `ovos_utils.file_utils` and will be" + f"removed in a future release.") + from ovos_utils.file_utils import resolve_resource_file + config = Configuration() + return resolve_resource_file(res_name, config=config) + + +def find_resource(res_name: str, root_dir: str, res_dirname: str, + lang: Optional[str] = None) -> Optional[Path]: + """ + Find a resource file. + + Searches for the given filename using this scheme: + 1. Search the resource lang directory: + /// + 2. Search the resource directory: + // + 3. Search the locale lang directory or other subdirectory: + /locale// or + /locale//.../ + + Args: + res_name (string): The resource name to be found + root_dir (string): A skill root directory + res_dirname (string): A skill sub directory + lang (string): language folder to be used + + Returns: + Path: The full path to the resource file or None if not found + """ + if lang: + for directory in locate_lang_directories(lang, root_dir, res_dirname): + for x in directory.iterdir(): + if x.is_file() and res_name == x.name: + return x + + for directory in locate_base_directories(root_dir, res_dirname): + for d, _, file_names in walk(directory): + if res_name in file_names: + return Path(directory, d, res_name) + + class ResourceType: """Defines the attributes of a type of skill resource. @@ -87,7 +179,8 @@ class ResourceType: base_directory: directory containing all files for the resource type """ - def __init__(self, resource_type: str, file_extension: str, language=None): + def __init__(self, resource_type: str, file_extension: str, + language: Optional[str] = None): self.resource_type = resource_type self.file_extension = file_extension self.language = language @@ -123,7 +216,7 @@ def _locate_base_no_lang(self, skill_directory, resource_subdirectory): if self.user_directory: self.base_directory = self.user_directory - def locate_base_directory(self, skill_directory): + def locate_base_directory(self, skill_directory: str) -> Optional[str]: """Find the skill's base directory for the specified resource type. There are three supported methodologies for storing resource files. @@ -204,12 +297,12 @@ class ResourceFile: file_path: absolute path to the file """ - def __init__(self, resource_type, resource_name): + def __init__(self, resource_type: ResourceType, resource_name: str): self.resource_type = resource_type self.resource_name = resource_name self.file_path = self._locate() - def _locate(self): + def _locate(self) -> str: """Locates a resource file in the skill's locale directory. A skill's locale directory can contain a subdirectory structure defined @@ -438,7 +531,10 @@ def load(self) -> Optional[str]: class SkillResources: - def __init__(self, skill_directory, language, dialog_renderer=None, skill_id=None): + def __init__(self, skill_directory: str, + language: str, + dialog_renderer: Optional[MustacheDialogRenderer] = None, + skill_id: Optional[str] = None): self.skill_directory = skill_directory self.language = language self.skill_id = skill_id @@ -447,16 +543,25 @@ def __init__(self, skill_directory, language, dialog_renderer=None, skill_id=Non self.static = dict() @property - def dialog_renderer(self): + def dialog_renderer(self) -> MustacheDialogRenderer: + """ + Get a dialog renderer object for these resources + """ if not self._dialog_renderer: self._load_dialog_renderer() return self._dialog_renderer @dialog_renderer.setter - def dialog_renderer(self, val): + def dialog_renderer(self, val: MustacheDialogRenderer): + """ + Set the dialog renderer object for these resources + """ self._dialog_renderer = val def _load_dialog_renderer(self): + """ + Initialize a MustacheDialogRenderer object for these resources + """ base_dirs = locate_lang_directories(self.language, self.skill_directory, "dialog") @@ -468,7 +573,8 @@ def _load_dialog_renderer(self): LOG.debug(f'No dialog loaded for {self.language}') def _define_resource_types(self) -> SkillResourceTypes: - """Defines all known types of skill resource files. + """ + Defines all known types of skill resource files. A resource file contains information the skill needs to function. Examples include dialog files to be spoken and vocab files for intent @@ -492,8 +598,10 @@ def _define_resource_types(self) -> SkillResourceTypes: resource_type.locate_base_directory(self.skill_directory) return SkillResourceTypes(**resource_types) - def load_dialog_file(self, name, data=None) -> List[str]: - """Loads the contents of a dialog file into memory. + def load_dialog_file(self, name: str, + data: Optional[dict] = None) -> List[str]: + """ + Loads the contents of a dialog file into memory. Named variables in the dialog are populated with values found in the data dictionary. @@ -508,12 +616,14 @@ def load_dialog_file(self, name, data=None) -> List[str]: dialog_file.data = data return dialog_file.load() - def locate_qml_file(self, name): + def locate_qml_file(self, name: str) -> str: qml_file = QmlFile(self.types.qml, name) return qml_file.load() - def load_list_file(self, name, data=None) -> List[str]: - """Load a file containing a list of words or phrases + def load_list_file(self, name: str, + data: Optional[dict] = None) -> List[str]: + """ + Load a file containing a list of words or phrases Named variables in the dialog are populated with values found in the data dictionary. @@ -528,8 +638,10 @@ def load_list_file(self, name, data=None) -> List[str]: list_file.data = data return list_file.load() - def load_named_value_file(self, name, delimiter=None) -> dict: - """Load file containing a set names and values. + def load_named_value_file(self, name: str, + delimiter: Optional[str] = None) -> dict: + """ + Load file containing a set names and values. Loads a simple delimited file of name/value pairs. The name is the first item, the value is the second. @@ -551,8 +663,9 @@ def load_named_value_file(self, name, delimiter=None) -> dict: return named_values - def load_regex_file(self, name) -> List[str]: - """Loads a file containing regular expression patterns. + def load_regex_file(self, name: str) -> List[str]: + """ + Loads a file containing regular expression patterns. The regular expression patterns are generally used to find a value in a user utterance the skill needs to properly perform the requested @@ -566,8 +679,10 @@ def load_regex_file(self, name) -> List[str]: regex_file = RegexFile(self.types.regex, name) return regex_file.load() - def load_template_file(self, name, data=None) -> List[str]: - """Loads the contents of a dialog file into memory. + def load_template_file(self, name: str, + data: Optional[dict] = None) -> List[str]: + """ + Loads the contents of a dialog file into memory. Named variables in the dialog are populated with values found in the data dictionary. @@ -582,12 +697,13 @@ def load_template_file(self, name, data=None) -> List[str]: template_file.data = data return template_file.load() - def load_vocabulary_file(self, name) -> List[List[str]]: - """Loads a file containing variations of words meaning the same thing. + def load_vocabulary_file(self, name: str) -> List[List[str]]: + """ + Loads a file containing variations of words meaning the same thing. A vocabulary file defines words a skill uses for intent matching. It can also be used to match words in an utterance after intent - intent matching is complete. + matching is complete. Args: name: name of the regular expression file, no extension needed @@ -597,7 +713,7 @@ def load_vocabulary_file(self, name) -> List[List[str]]: vocabulary_file = VocabularyFile(self.types.vocabulary, name) return vocabulary_file.load() - def load_word_file(self, name) -> Optional[str]: + def load_word_file(self, name: str) -> Optional[str]: """Loads a file containing a word. Args: @@ -608,8 +724,9 @@ def load_word_file(self, name) -> Optional[str]: word_file = WordFile(self.types.word, name) return word_file.load() - def render_dialog(self, name, data=None) -> str: - """Selects a record from a dialog file at random for TTS purposes. + def render_dialog(self, name: str, data: Optional[dict] = None) -> str: + """ + Selects a record from a dialog file at random for TTS purposes. Args: name: name of the list file (no extension needed) @@ -622,12 +739,18 @@ def render_dialog(self, name, data=None) -> str: return resource_file.render(self.dialog_renderer) def load_skill_vocabulary(self, alphanumeric_skill_id: str) -> dict: + """ + Load all vocabulary files in the skill's resources + @param alphanumeric_skill_id: alphanumeric ID of the skill associated + with these resources. + @return: dict of vocab name to loaded contents + """ skill_vocabulary = {} base_directory = self.types.vocabulary.base_directory for directory, _, files in walk(base_directory): - vocabulary_files = [ + vocabulary_files = ( file_name for file_name in files if file_name.endswith(".voc") - ] + ) for file_name in vocabulary_files: vocab_type = alphanumeric_skill_id + file_name[:-4].title() vocabulary = self.load_vocabulary_file(file_name) @@ -637,12 +760,18 @@ def load_skill_vocabulary(self, alphanumeric_skill_id: str) -> dict: return skill_vocabulary def load_skill_regex(self, alphanumeric_skill_id: str) -> List[str]: + """ + Load all regex files in the skill's resources + @param alphanumeric_skill_id: alphanumeric ID of the skill associated + with these resources. + @return: list of string regex expressions + """ skill_regexes = [] base_directory = self.types.regex.base_directory for directory, _, files in walk(base_directory): - regex_files = [ + regex_files = ( file_name for file_name in files if file_name.endswith(".rx") - ] + ) for file_name in regex_files: skill_regexes.extend(self.load_regex_file(file_name)) @@ -653,9 +782,8 @@ def load_skill_regex(self, alphanumeric_skill_id: str) -> List[str]: return skill_regexes @staticmethod - def _make_unique_regex_group( - regexes: List[str], alphanumeric_skill_id: str - ) -> List[str]: + def _make_unique_regex_group(regexes: List[str], + alphanumeric_skill_id: str) -> List[str]: """Adds skill ID to group ID in a regular expression for uniqueness. Args: @@ -678,8 +806,11 @@ def _make_unique_regex_group( class CoreResources(SkillResources): def __init__(self, language): - from mycroft import MYCROFT_ROOT_PATH - directory = f"{MYCROFT_ROOT_PATH}/mycroft/res" + try: + from mycroft import MYCROFT_ROOT_PATH + directory = f"{MYCROFT_ROOT_PATH}/mycroft/res" + except ImportError: + directory = f"{dirname(__file__)}/res" super().__init__(directory, language) @@ -690,25 +821,21 @@ def __init__(self, language, skill_id): class RegexExtractor: - """Extracts data from an utterance using regular expressions. - - Attributes: - group_name: - regex_patterns: regular expressions read from a .rx file - """ - - def __init__(self, group_name, regex_patterns): + def __init__(self, group_name: str, regex_patterns: List[str]): + """ + Init an object representing an entity and a list of possible regex + patterns for extracting it + @param group_name: Named group entity to extract + @param regex_patterns: List of string regex patterns to evaluate + """ self.group_name = group_name self.regex_patterns = regex_patterns - def extract(self, utterance) -> Optional[str]: - """Attempt to find a value in a user request. - - Args: - utterance: request spoken by the user - - Returns: - The value extracted from the utterance, if found + def extract(self, utterance: str) -> Optional[str]: + """ + Attempt to extract `group_name` from the specified `utterance` + @param utterance: String to evaluate + @return: Extracted `group_name` value if matched in `utterance` """ extract = None pattern_match = self._match_utterance_to_patterns(utterance) @@ -718,14 +845,12 @@ def extract(self, utterance) -> Optional[str]: return extract - def _match_utterance_to_patterns(self, utterance: str): - """Match regular expressions to user request. - - Args: - utterance: request spoken by the user - - Returns: - a regular expression match object if a match is found + def _match_utterance_to_patterns(self, + utterance: str) -> Optional[re.Match]: + """ + Compare `utterance` to all `regex_patterns` until a match is found. + @param utterance: String to evaluate + @return: re.Match object if utterance mathes any `regex_patterns` """ pattern_match = None for pattern in self.regex_patterns: @@ -735,11 +860,11 @@ def _match_utterance_to_patterns(self, utterance: str): return pattern_match - def _extract_group_from_match(self, pattern_match): - """Extract the alarm name from the utterance. - - Args: - pattern_match: a regular expression match object + def _extract_group_from_match(self, pattern_match: re.Match) -> str: + """ + Extract the specified regex group value. + @param pattern_match: Match object associated with a particular input + @return: String matched to `self.group_name` """ extract = None try: @@ -753,112 +878,11 @@ def _extract_group_from_match(self, pattern_match): return extract def _log_extraction_result(self, extract: str): - """Log the results of the matching. - - Args: - extract: the value extracted from the user utterance + """ + Log the results of the matching. + @param extract: the value extracted from the user utterance """ if extract is None: LOG.info(f"No {self.group_name.lower()} extracted from utterance") else: LOG.info(f"{self.group_name} extracted from utterance: " + extract) - - -def resolve_resource_file(res_name): - """Convert a resource into an absolute filename. - - Resource names are in the form: 'filename.ext' - or 'path/filename.ext' - - The system wil look for $XDG_DATA_DIRS/mycroft/res_name first - (defaults to ~/.local/share/mycroft/res_name), and if not found will - look at /opt/mycroft/res_name, then finally it will look for res_name - in the 'mycroft/res' folder of the source code package. - - Example: - With mycroft running as the user 'bob', if you called - ``resolve_resource_file('snd/beep.wav')`` - it would return either: - '$XDG_DATA_DIRS/mycroft/beep.wav', - '/home/bob/.mycroft/snd/beep.wav' or - '/opt/mycroft/snd/beep.wav' or - '.../mycroft/res/snd/beep.wav' - where the '...' is replaced by the path - where the package has been installed. - - Args: - res_name (str): a resource path/name - - Returns: - (str) path to resource or None if no resource found - """ - config = Configuration() - - # First look for fully qualified file (e.g. a user setting) - if os.path.isfile(res_name): - return res_name - - # Now look for XDG_DATA_DIRS - for path in get_xdg_data_dirs(): - filename = os.path.join(path, res_name) - if os.path.isfile(filename): - return filename - - # Now look in the old user location - filename = os.path.join(os.path.expanduser('~'), - f'.{get_xdg_base()}', - res_name) - if os.path.isfile(filename): - return filename - - # Next look for /opt/mycroft/res/res_name - data_dir = config.get('data_dir', get_xdg_data_save_path()) - res_dir = os.path.join(data_dir, 'res') - filename = os.path.expanduser(os.path.join(res_dir, res_name)) - if os.path.isfile(filename): - return filename - - # Finally look for it in the ovos-core package - try: - from mycroft import MYCROFT_ROOT_PATH - filename = f"{MYCROFT_ROOT_PATH}/mycroft/res/{res_name}" - filename = os.path.abspath(os.path.normpath(filename)) - if os.path.isfile(filename): - return filename - except ImportError: - pass - - return None # Resource cannot be resolved - - -def find_resource(res_name, root_dir, res_dirname, lang=None): - """Find a resource file. - - Searches for the given filename using this scheme: - 1. Search the resource lang directory: - /// - 2. Search the resource directory: - // - 3. Search the locale lang directory or other subdirectory: - /locale// or - /locale//.../ - - Args: - res_name (string): The resource name to be found - root_dir (string): A skill root directory - res_dirname (string): A skill sub directory - lang (string): language folder to be used - - Returns: - Path: The full path to the resource file or None if not found - """ - if lang: - for directory in locate_lang_directories(lang, root_dir, res_dirname): - for x in directory.iterdir(): - if x.is_file() and res_name == x.name: - return x - - for directory in locate_base_directories(root_dir, res_dirname): - for d, _, file_names in walk(directory): - if res_name in file_names: - return Path(directory, d, res_name) diff --git a/ovos_workshop/settings.py b/ovos_workshop/settings.py new file mode 100644 index 00000000..1e7a4a39 --- /dev/null +++ b/ovos_workshop/settings.py @@ -0,0 +1,162 @@ +import json +import yaml + +from os.path import isfile +from typing import Optional +from threading import Timer +from ovos_backend_client.api import DeviceApi +from ovos_backend_client.pairing import is_paired, requires_backend +from ovos_backend_client.settings import RemoteSkillSettings, get_display_name +from ovos_bus_client import MessageBusClient +from ovos_bus_client.message import Message, dig_for_message +from ovos_utils.log import LOG + + +class SkillSettingsManager: + def __init__(self, skill): + from ovos_workshop.skills.base import BaseSkill + self.download_timer: Optional[Timer] = None + self.skill: BaseSkill = skill + self.api = DeviceApi() + self.remote_settings = \ + RemoteSkillSettings(self.skill_id, + settings=dict(self.skill.settings), + meta=self.load_meta(), remote_id=self.skill_gid) + self.register_bus_handlers() + + def start(self): + self._download() + + def _download(self): + # If this method is called outside of the timer loop, ensure the + # existing timer is canceled before starting a new one. + if self.download_timer: + self.download_timer.cancel() + + self.download() + + # prepare to download again in 60 seconds + self.download_timer = Timer(60, self._download) + self.download_timer.daemon = True + self.download_timer.start() + + def stop(self): + # If this method is called outside of the timer loop, ensure the + # existing timer is canceled + if self.download_timer: + self.download_timer.cancel() + + @property + def bus(self) -> MessageBusClient: + return self.skill.bus + + @property + def skill_id(self) -> str: + return self.skill.skill_id + + @property + def display_name(self) -> str: + return get_display_name(self.skill_id) + + @property + def skill_gid(self) -> str: + return f"@{self.api.uuid}|{self.skill_id}" + + @property + def skill_meta(self) -> dict: + return self.remote_settings.meta + + def register_bus_handlers(self): + self.skill.add_event('mycroft.skills.settings.update', + self.handle_download_remote) # backwards compat + self.skill.add_event('mycroft.skills.settings.download', + self.handle_download_remote) + self.skill.add_event('mycroft.skills.settings.upload', + self.handle_upload_local) + self.skill.add_event('mycroft.skills.settings.upload.meta', + self.handle_upload_meta) + self.skill.add_event('mycroft.paired', + self.handle_upload_local) + + def load_meta(self) -> dict: + json_path = f"{self.skill.root_dir}/settingsmeta.json" + yaml_path = f"{self.skill.root_dir}/settingsmeta.yaml" + if isfile(yaml_path): + with open(yaml_path) as meta_file: + return yaml.safe_load(meta_file) + elif isfile(json_path): + with open(json_path) as meta_file: + return json.load(meta_file) + return {} + + def save_meta(self, generate: bool = False): + # unset reload flag to avoid a reload on settingmeta change + # TODO - support for settingsmeta XDG paths + reload = self.skill.reload_skill + self.skill.reload_skill = False + + # generate meta for missing fields + if generate: + self.remote_settings.generate_meta() + + # write to disk + json_path = f"{self.skill.root_dir}/settingsmeta.json" + yaml_path = f"{self.skill.root_dir}/settingsmeta.yaml" + if isfile(yaml_path): + with open(yaml_path) as meta_file: + yaml.dump(self.remote_settings.meta, meta_file) + else: + with open(json_path, "w") as meta_file: + json.dump(self.remote_settings.meta, meta_file) + + # reset reloading flag + self.skill.reload_skill = reload + + @requires_backend + def upload(self, generate: bool = False): + if not is_paired(): + LOG.error("Device needs to be paired to upload settings") + return + self.remote_settings.settings = dict(self.skill.settings) + if generate: + self.remote_settings.generate_meta() + self.remote_settings.upload() + + @requires_backend + def upload_meta(self, generate: bool = False): + if not is_paired(): + LOG.error("Device needs to be paired to upload settingsmeta") + return + if generate: + self.remote_settings.settings = dict(self.skill.settings) + self.remote_settings.generate_meta() + self.remote_settings.upload_meta() + + @requires_backend + def download(self): + if not is_paired(): + LOG.error("Device needs to be paired to download remote settings") + return + self.remote_settings.download() + # we do not update skill object settings directly + # skill will handle the event and trigger a callback + if self.skill.settings != self.remote_settings.settings: + # dig old message to keep context + msg = dig_for_message() or Message("") + msg = msg.forward('mycroft.skills.settings.changed') + + msg.data[self.skill_id] = self.remote_settings.settings + self.bus.emit(msg) + + def handle_upload_meta(self, message: Message): + skill_id = message.data.get("skill_id") + if skill_id == self.skill_id: + self.upload_meta() + + def handle_upload_local(self, message: Message): + skill_id = message.data.get("skill_id") + if skill_id == self.skill_id: + self.upload() + + def handle_download_remote(self, message: Message): + self.download() diff --git a/ovos_workshop/skill_launcher.py b/ovos_workshop/skill_launcher.py new file mode 100644 index 00000000..e5ca41ab --- /dev/null +++ b/ovos_workshop/skill_launcher.py @@ -0,0 +1,669 @@ +import gc +import os +import sys +from os.path import isdir +from inspect import isclass +from types import ModuleType +from typing import Optional +from time import time +from ovos_bus_client.client import MessageBusClient +from ovos_bus_client.message import Message +from ovos_config.config import Configuration +from ovos_config.locale import setup_locale +from ovos_plugin_manager.skills import find_skill_plugins +from ovos_utils import wait_for_exit_signal +from ovos_utils.file_utils import FileWatcher +from ovos_utils.log import LOG, deprecated, log_deprecation +from ovos_utils.process_utils import RuntimeRequirements +from ovos_utils.skills.locations import get_skill_directories as _get_skill_dirs + +from ovos_workshop.skills.active import ActiveSkill +from ovos_workshop.skills.auto_translatable import UniversalSkill, UniversalFallback +from ovos_workshop.skills.base import BaseSkill +from ovos_workshop.skills.common_play import OVOSCommonPlaybackSkill +from ovos_workshop.skills.common_query_skill import CommonQuerySkill +from ovos_workshop.skills.fallback import FallbackSkill +from ovos_workshop.skills.mycroft_skill import MycroftSkill +from ovos_workshop.skills.ovos import OVOSSkill, OVOSFallbackSkill + +SKILL_BASE_CLASSES = [ + BaseSkill, MycroftSkill, OVOSSkill, OVOSFallbackSkill, + OVOSCommonPlaybackSkill, OVOSFallbackSkill, CommonQuerySkill, ActiveSkill, + FallbackSkill, UniversalSkill, UniversalFallback +] + +SKILL_MAIN_MODULE = '__init__.py' + + +@deprecated("This method has moved to `ovos_utils.skills.locations`", "0.1.0") +def get_skill_directories(conf=None): + conf = conf or Configuration() + return _get_skill_dirs(conf) + + +@deprecated("This method has moved to `ovos_utils.skills.locations`", "0.1.0") +def get_default_skills_directory(conf=None): + from ovos_utils.skills.locations import get_default_skills_directory + conf = conf or Configuration() + return get_default_skills_directory(conf) + + +def remove_submodule_refs(module_name: str): + """ + Ensure submodules are reloaded by removing the refs from sys.modules. + + Python import system puts a reference for each module in the sys.modules + dictionary to bypass loading if a module is already in memory. To make + sure skills are completely reloaded these references are deleted. + + Args: + module_name: name of skill module. + """ + submodules = [] + LOG.debug(f'Skill module: {module_name}') + # Collect found submodules + for m in sys.modules: + if m.startswith(module_name + '.'): + submodules.append(m) + # Remove all references them to in sys.modules + for m in submodules: + LOG.debug(f'Removing sys.modules ref for {m}') + del sys.modules[m] + + +def load_skill_module(path: str, skill_id: str) -> ModuleType: + """ + Load a skill module + + This function handles the differences between python 3.4 and 3.5+ as well + as makes sure the module is inserted into the sys.modules dict. + + Args: + path: Path to the skill main file (__init__.py) + skill_id: skill_id used as skill identifier in the module list + Returns: + loaded skill module + """ + import importlib.util + module_name = skill_id.replace('.', '_') + + remove_submodule_refs(module_name) + + spec = importlib.util.spec_from_file_location(module_name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + return mod + + +def get_skill_class(skill_module: ModuleType) -> Optional[callable]: + """ + Find MycroftSkill based class in skill module. + + Arguments: + skill_module (module): module to search for Skill class + + Returns: + (MycroftSkill): Found subclass of MycroftSkill or None. + """ + if not skill_module: + raise ValueError("Expected module and got None") + if callable(skill_module): + # it's a skill plugin + # either a func that returns the skill or the skill class itself + return skill_module + + candidates = [] + for name, obj in skill_module.__dict__.items(): + if isclass(obj): + if any(issubclass(obj, c) for c in SKILL_BASE_CLASSES) and \ + not any(obj is c for c in SKILL_BASE_CLASSES): + candidates.append(obj) + + for candidate in list(candidates): + others = [clazz for clazz in candidates if clazz != candidate] + # if we found a subclass of this candidate, it is not the final skill + if any(issubclass(clazz, candidate) for clazz in others): + candidates.remove(candidate) + + if candidates: + if len(candidates) > 1: + LOG.warning(f"Multiple skills found in a single file!\n" + f"{candidates}") + LOG.debug(f"Loading skill class: {candidates[0]}") + return candidates[0] + return None + + +def get_create_skill_function(skill_module: ModuleType) -> Optional[callable]: + """Find create_skill function in skill module. + + Arguments: + skill_module (module): module to search for create_skill function + + Returns: + (function): Found create_skill function or None. + """ + if hasattr(skill_module, "create_skill") and \ + callable(skill_module.create_skill): + log_deprecation("`create_skill` method is no longer supported", "0.1.0") + return skill_module.create_skill + return None + + +class SkillLoader: + def __init__(self, bus: MessageBusClient, + skill_directory: Optional[str] = None, + skill_id: Optional[str] = None): + """ + Create a SkillLoader object to load/unload a skill and + @param bus: MessageBusClient object + @param skill_directory: path to skill source + (containing __init__.py, locale, gui, etc.) + @param skill_id: Unique ID for the skill + """ + self.bus = bus + self._skill_directory = skill_directory + self._skill_id = skill_id + self._skill_class = None + self._loaded = None + self.load_attempted = False + self.last_loaded = 0 + self.instance: Optional[BaseSkill] = None + self.active = True + self._watchdog = None + self.config = Configuration() + self.skill_module = None + + @property + def loaded(self) -> bool: + """ + Return True if skill is loaded + """ + return self._loaded + + @loaded.setter + def loaded(self, val: bool): + """ + Set the skill loaded state + """ + self._loaded = val + + @property + def skill_directory(self) -> Optional[str]: + """ + Return the skill directory or `None` if unset and no instance exists + """ + skill_dir = self._skill_directory + if self.instance and not skill_dir: + skill_dir = self.instance.root_dir + return skill_dir + + @skill_directory.setter + def skill_directory(self, val: str): + """ + Set (override) the skill directory + """ + self._skill_directory = val + + @property + def skill_id(self) -> Optional[str]: + """ + Return the skill's reported Skill ID + """ + skill_id = self._skill_id + if self.instance and not skill_id: + skill_id = self.instance.skill_id + if self.skill_directory and not skill_id: + skill_id = os.path.basename(self.skill_directory) + return skill_id + + @skill_id.setter + def skill_id(self, val: str): + """ + Set (override) the skill ID + """ + self._skill_id = val + + @property + def skill_class(self) -> Optional[callable]: + """ + Get the skill's class + """ + skill_class = self._skill_class + if self.instance and not skill_class: + skill_class = self.instance.__class__ + if self.skill_module and not skill_class: + skill_class = get_skill_class(self.skill_module) + return skill_class + + @skill_class.setter + def skill_class(self, val: callable): + """ + Set (override) the skill class + """ + self._skill_class = val + + @property + def runtime_requirements(self) -> RuntimeRequirements: + """ + Return the skill's runtime requirements + """ + if not self.skill_class or not hasattr(self.skill_class, + "runtime_requirements"): + return RuntimeRequirements() + return self.skill_class.runtime_requirements + + @property + def is_blacklisted(self) -> bool: + """ + Return true if the skill is blacklisted in configuration + """ + blacklist = self.config['skills'].get('blacklisted_skills') or [] + if self.skill_id in blacklist: + return True + else: + return False + + @property + def reload_allowed(self) -> bool: + """ + Return true if the skill can be reloaded + """ + return self.active and (self.instance is None or + self.instance.reload_skill) + + def reload(self) -> bool: + """ + Request reload the skill + @return: True if skill was reloaded + """ + self.load_attempted = True + LOG.info(f'ATTEMPTING TO RELOAD SKILL: {self.skill_id}') + if self.instance: + if not self.instance.reload_skill: + LOG.info("skill does not allow reloading!") + return False # not allowed + self._unload() + return self._load() + + def load(self, _=None) -> bool: + """ + Request to load the skill + @return: True if skill was loaded + """ + LOG.info(f'ATTEMPTING TO LOAD SKILL: {self.skill_id}') + return self._load() + + def _unload(self): + """ + Remove listeners and stop threads before loading + """ + if self._watchdog: + self._watchdog.shutdown() + self._watchdog = None + + self._execute_instance_shutdown() + if self.config.get("debug", False): + self._garbage_collect() + self._emit_skill_shutdown_event() + + def unload(self): + """ + Shutdown and unload the skill instance + """ + if self.instance: + self._execute_instance_shutdown() + + def activate(self): + """ + Mark skill as active and (re)load the skill + """ + self.active = True + self.load() + + def deactivate(self): + """ + Mark skill as inactive and unload the skill + """ + self.active = False + self.unload() + + def _execute_instance_shutdown(self): + """ + Call the shutdown method of the skill being reloaded. + """ + try: + self.instance.default_shutdown() + except Exception as e: + LOG.exception(f'An error occurred while shutting down ' + f'{self.skill_id}: {e}') + else: + LOG.info(f'Skill {self.skill_id} shut down successfully') + del self.instance + self.instance = None + + def _garbage_collect(self): + """ + Invoke Python garbage collector to remove false references + """ + gc.collect() + # Remove two local references that are known + refs = sys.getrefcount(self.instance) - 2 + if refs > 0: + LOG.warning( + f"After shutdown of {self.skill_id} there are still {refs} " + f"references remaining. The skill won't be cleaned from memory." + ) + + def _emit_skill_shutdown_event(self): + """ + Emit `mycroft.skills.shutdown` to notify the skill is being shutdown + """ + message = Message("mycroft.skills.shutdown", + {"path": self.skill_directory, "id": self.skill_id}) + self.bus.emit(message) + + def _load(self) -> bool: + """ + Load the skill if it is not blacklisted, emit load status, start file + watchers, and return load status. + @return: True if skill was loaded + """ + self._prepare_for_load() + if self.is_blacklisted: + self._skip_load() + else: + self.skill_module = self._load_skill_source() + self.loaded = self._create_skill_instance() + + self.last_loaded = time() + self._communicate_load_status() + self._start_filewatcher() + return self.loaded + + def _start_filewatcher(self): + """ + Start a FileWatcher if one isn't already active + """ + if not self._watchdog: + self._watchdog = FileWatcher([self.skill_directory], + callback=self._handle_filechange, + recursive=True) + + def _handle_filechange(self, path: str): + """ + Handle a file change notification by reloading the skill + """ + LOG.info(f'Skill change detected! {path}') + try: + if self.reload_allowed: + self.reload() + except Exception as e: + LOG.exception(f'Unhandled exception occurred while reloading ' + f'{self.skill_directory}: {e}') + + def _prepare_for_load(self): + """ + Prepare SkillLoader for skill load + """ + self.load_attempted = True + self.instance = None + + def _skip_load(self): + """ + Log a warning when requested skill load is skipped + """ + LOG.info(f'Skill {self.skill_id} is blacklisted - ' + f'it will not be loaded') + + def _load_skill_source(self) -> ModuleType: + """ + Use Python's import library to load a skill's source code. + @return: Skill module to instantiate + """ + main_file_path = os.path.join(self.skill_directory, SKILL_MAIN_MODULE) + skill_module = None + if not os.path.exists(main_file_path): + LOG.error(f'Failed to load {self.skill_id} due to a missing file.') + else: + try: + skill_module = load_skill_module(main_file_path, self.skill_id) + except Exception as e: + LOG.exception(f'Failed to load skill: {self.skill_id} ({e})') + return skill_module + + def _create_skill_instance(self, + skill_module: Optional[ModuleType] = None) -> \ + bool: + """ + Create the skill object. + + Arguments: + skill_module (module): Module to load from + + Returns: + (bool): True if skill was loaded successfully. + """ + skill_module = skill_module or self.skill_module + + try: + # in skill classes __new__ should fully create the skill object + skill_class = get_skill_class(skill_module) + self.instance = skill_class(bus=self.bus, skill_id=self.skill_id) + return self.instance is not None + except Exception as e: + LOG.warning(f"Skill load raised exception: {e}") + + try: + # attempt to use old style create_skill function entrypoint + skill_creator = get_create_skill_function(skill_module) or \ + self.skill_class + except Exception as e: + LOG.exception(f"Failed to load skill creator: {e}") + self.instance = None + return False + + # if the signature supports skill_id and bus pass them + # to fully initialize the skill in 1 go + try: + # skills that do will have bus and skill_id available + # as soon as they call super() + self.instance = skill_creator(bus=self.bus, + skill_id=self.skill_id) + except Exception as e: + # most old skills do not expose bus/skill_id kwargs + LOG.warning(f"Legacy skill: {e}") + self.instance = skill_creator() + + try: + # finish initialization of skill if we didn't manage to inject + # skill_id and bus kwargs. + # these skills only have skill_id and bus available in initialize, + # not in __init__ + log_deprecation("This initialization is deprecated. Update skill to" + "handle passed `skill_id` and `bus` kwargs", + "0.1.0") + if not self.instance._is_fully_initialized: + self.instance._startup(self.bus, self.skill_id) + except Exception as e: + LOG.exception(f'Skill __init__ failed with {e}') + self.instance = None + + return self.instance is not None + + def _communicate_load_status(self): + """ + Check internal parameters and emit `mycroft.skills.loaded` or + `mycroft.skills.loading_failure` as appropriate + """ + if self.loaded: + message = Message('mycroft.skills.loaded', + {"path": self.skill_directory, + "id": self.skill_id, + "name": self.instance.name}) + self.bus.emit(message) + LOG.info(f'Skill {self.skill_id} loaded successfully') + else: + message = Message('mycroft.skills.loading_failure', + {"path": self.skill_directory, + "id": self.skill_id}) + self.bus.emit(message) + if not self.is_blacklisted: + LOG.error(f'Skill {self.skill_id} failed to load') + else: + LOG.info(f'Skill {self.skill_id} not loaded') + + +class PluginSkillLoader(SkillLoader): + def __init__(self, bus, skill_id): + super().__init__(bus, skill_id=skill_id) + + def load(self, skill_class: Optional[callable] = None) -> bool: + """ + Load a skill plugin + @param skill_class: Skill class to instantiate + @return: True if skill was loaded + """ + LOG.info('ATTEMPTING TO LOAD PLUGIN SKILL: ' + self.skill_id) + self._skill_class = skill_class or self._skill_class + if not self._skill_class: + raise RuntimeError(f"_skill_class not defined for {self.skill_id}") + return self._load() + + def _load(self): + """ + Load the skill if it is not blacklisted, emit load status, + and return load status. + @return: True if skill was loaded + """ + self._prepare_for_load() + if self.is_blacklisted: + self._skip_load() + else: + self.loaded = self._create_skill_instance() + + self.last_loaded = time() + self._communicate_load_status() + return self.loaded + + +class SkillContainer: + def __init__(self, skill_id: str, skill_directory: Optional[str] = None, + bus: Optional[MessageBusClient] = None): + """ + Init a SkillContainer. + @param skill_id: Unique ID of the skill being loaded + @param skill_directory: path to skill source (if None, directory will be + located by `skill_id`) + @param bus: MessageBusClient object to connect (else one is created) + """ + # ensure any initializations and resource loading is handled + setup_locale() + self.bus = bus + self.skill_id = skill_id + if not skill_directory: # preference to local skills instead of plugins + for p in _get_skill_dirs(): + if isdir(f"{p}/{skill_id}"): + skill_directory = f"{p}/{skill_id}" + LOG.debug(f"found local skill {skill_id}: {skill_directory}") + break + self.skill_directory = skill_directory + self.skill_loader = None + + def _connect_to_core(self): + """ + Initialize messagebus connection and register event to load skill once + core reports ready. + """ + if not self.bus: + self.bus = MessageBusClient() + self.bus.run_in_thread() + self.bus.connected_event.wait() + + LOG.debug("checking skills service status") + response = self.bus.wait_for_response( + Message(f'mycroft.skills.is_ready', + context={"source": "workshop", "destination": "skills"})) + if response and response.data['status']: + LOG.info("connected to core") + self.load_skill() + else: + LOG.warning("Skills service not ready yet. Load on ready event.") + + self.bus.on("mycroft.ready", self.load_skill) + + def load_skill(self, message: Optional[Message] = None): + """ + Load the skill associated with this SkillContainer instance. + @param message: Message triggering skill load if available + """ + if self.skill_loader: + LOG.info("detected core reload, reloading skill") + self.skill_loader.reload() + return + LOG.info("launching skill") + if not self.skill_directory: + self._launch_plugin_skill() + else: + self._launch_standalone_skill() + + def run(self): + """ + Connect to core and run until KeyboardInterrupt. + """ + self._connect_to_core() + try: + wait_for_exit_signal() + except KeyboardInterrupt: + pass + if self.skill_loader: + self.skill_loader.deactivate() + + def _launch_plugin_skill(self): + """ + Launch a skill plugin associated with this SkillContainer instance. + """ + plugins = find_skill_plugins() + if self.skill_id not in plugins: + raise ValueError(f"unknown skill_id: {self.skill_id}") + skill_plugin = plugins[self.skill_id] + self.skill_loader = PluginSkillLoader(self.bus, self.skill_id) + try: + self.skill_loader.load(skill_plugin) + except Exception as e: + LOG.exception(f'Load of skill {self.skill_id} failed! {e}') + + def _launch_standalone_skill(self): + """ + Launch a local skill associated with this SkillContainer instance. + """ + self.skill_loader = SkillLoader(self.bus, self.skill_directory, + skill_id=self.skill_id) + try: + self.skill_loader.load() + except Exception as e: + LOG.exception(f'Load of skill {self.skill_directory} failed! {e}') + + +def _launch_script(): + """ + Console script entrypoint + USAGE: ovos-skill-launcher {skill_id} [path/to/my/skill_id] + """ + args_count = len(sys.argv) + if args_count == 2: + skill_id = sys.argv[1] + skill = SkillContainer(skill_id) + elif args_count == 3: + # user asked explicitly for a directory + skill_id = sys.argv[1] + skill_directory = sys.argv[2] + skill = SkillContainer(skill_id, skill_directory) + else: + print("USAGE: ovos-skill-launcher {skill_id} [path/to/my/skill_id]") + raise SystemExit(2) + + skill.run() + + diff --git a/ovos_workshop/skills/active.py b/ovos_workshop/skills/active.py index fd5b132e..325cd22c 100644 --- a/ovos_workshop/skills/active.py +++ b/ovos_workshop/skills/active.py @@ -9,8 +9,9 @@ def bind(self, bus): self.make_active() def handle_skill_deactivated(self, message=None): - """ skill is always in active skill list, - ie, converse is always called """ + """ + skill is always in active skill list, ie, converse is always called + """ self.make_active() diff --git a/ovos_workshop/skills/auto_translatable.py b/ovos_workshop/skills/auto_translatable.py index 33e0aa11..0f29c39a 100644 --- a/ovos_workshop/skills/auto_translatable.py +++ b/ovos_workshop/skills/auto_translatable.py @@ -1,58 +1,126 @@ +from abc import ABC + +from ovos_config import Configuration +from ovos_plugin_manager.language import OVOSLangDetectionFactory, OVOSLangTranslationFactory from ovos_utils import get_handler_name from ovos_utils.log import LOG -from ovos_workshop.skills.ovos import OVOSSkill, OVOSFallbackSkill -try: - # TODO: Below methods are not defined in ovos_utils - from ovos_utils.lang.translate import detect_lang, translate_text -except ImportError as e: - detect_lang = None - translate_text = None - LOG.exception(e) +from ovos_workshop.resource_files import SkillResources +from ovos_workshop.skills.common_query_skill import CommonQuerySkill +from ovos_workshop.skills.ovos import OVOSSkill +from ovos_workshop.skills.fallback import FallbackSkillV2 class UniversalSkill(OVOSSkill): - ''' Skill that auto translates input/output from any language ''' + """ + Skill that auto translates input/output from any language + + intent handlers are ensured to receive utterances in self.internal_language + intent handlers are expected to produce utterances in self.internal_language + + self.speak will always translate utterances from + self.internal_lang to self.lang + + NOTE: self.lang reflects the original query language + but received utterances are always in self.internal_language + """ def __init__(self, *args, **kwargs): - if not detect_lang and translate_text: - raise NotImplementedError("Translate methods not yet implemented") super().__init__(*args, **kwargs) - self.input_lang = self.lang - self.translate_keys = [] + self.lang_detector = OVOSLangDetectionFactory.create() + self.translator = OVOSLangTranslationFactory.create() + + # the skill internally only works in this language + self.internal_language = None + # __tags__ private value will be translated (adapt entities) self.translate_tags = True + # keys added here will have values translated in message.data + self.translate_keys = ["utterance", "utterances"] + + # autodetect will detect the lang of the utterance regardless of what + # has been reported to test just type in the cli in another language + # and watch answers still coming + self.autodetect = False # TODO from mycroft.conf + if self.internal_language is None: + lang = Configuration().get("lang", "en-us") + LOG.warning(f"UniversalSkill are expected to specify their " + f"internal_language, casting to {lang}") + self.internal_language = lang + + def _load_lang(self, root_directory=None, lang=None): + """ + unlike base skill class all resources are in self.internal_language by + default instead of self.lang (which comes from message) + this ensures things like self.dialog_render reflect self.internal_lang + """ + lang = lang or self.internal_language # self.lang in base class + root_directory = root_directory or self.res_dir + if lang not in self._lang_resources: + self._lang_resources[lang] = SkillResources(root_directory, lang, + skill_id=self.skill_id) + return self._lang_resources[lang] def detect_language(self, utterance): try: - return detect_lang(utterance) - except: + return self.lang_detector.detect(utterance) + except Exception as e: + LOG.error(e) + # self.lang to account for lang defined in message return self.lang.split("-")[0] - def translate(self, text, lang=None): - lang = lang or self.lang - translated = translate_text(text, lang) - LOG.info("translated " + text + " to " + translated) - return translated - - def _translate_utterance(self, utterance="", lang=None): - lang = lang or self.input_lang - if utterance and lang is not None: - ut_lang = self.detect_language(utterance) - if lang.split("-")[0] != ut_lang: - utterance = self.translate(utterance, lang) - return utterance + def translate_utterance(self, text, target_lang, sauce_lang=None): + if self.autodetect: + sauce_lang = self.detect_language(text) + else: + sauce_lang = sauce_lang or self.detect_language(text) + if sauce_lang.split("-")[0] != target_lang: + translated = self.translator.translate(text, source=sauce_lang, + target=target_lang) + LOG.info("translated " + text + " to " + translated) + return translated + return text def _translate_message(self, message): - ut = message.data.get("utterance") - if ut: - message.data["utterance"] = self._translate_utterance(ut) + # translate speech from input lang to internal lang + sauce_lang = self.lang # from message or config + out_lang = self.internal_language # skill wants input is in this language, + + if sauce_lang == out_lang and not self.autodetect: + # do nothing + return message + + translation_data = {"original": {}, "translated": {}, + "source_lang": sauce_lang, + "internal_lang": self.internal_language} + + def _do_tx(thing): + if isinstance(thing, str): + thing = self.translate_utterance(thing, target_lang=out_lang, + sauce_lang=sauce_lang) + elif isinstance(thing, list): + thing = [_do_tx(t) for t in thing] + elif isinstance(thing, dict): + thing = {k: _do_tx(v) for k, v in thing.items()} + return thing + for key in self.translate_keys: if key in message.data: - ut = message.data[key] - message.data[key] = self._translate_utterance(ut) + translation_data["original"][key] = message.data[key] + translation_data["translated"][key] = message.data[key] = \ + _do_tx(message.data[key]) + + # special case if self.translate_tags: + translation_data["original"]["__tags__"] = message.data["__tags__"] for idx, token in enumerate(message.data["__tags__"]): - message.data["__tags__"][idx] = self._translate_utterance(token.get("key", "")) + message.data["__tags__"][idx] = \ + self.translate_utterance(token.get("key", ""), + target_lang=out_lang, + sauce_lang=sauce_lang) + translation_data["translated"]["__tags__"] = \ + message.data["__tags__"] + + message.context["translation_data"] = translation_data return message def create_universal_handler(self, handler): @@ -72,16 +140,39 @@ def register_intent_file(self, intent_file, handler): handler = self.create_universal_handler(handler) super().register_intent_file(intent_file, handler) - def speak(self, utterance, expect_response=False, wait=False): - utterance = self._translate_utterance(utterance) - super().speak(utterance, expect_response, wait) + def speak(self, utterance, *args, **kwargs): + # translate speech from input lang to output lang + out_lang = self.lang # from message or config + sauce_lang = self.internal_language # skill output is in this language + if out_lang != sauce_lang or self.autodetect: + meta = kwargs.get("meta") or {} + meta["translation_data"] = { + "original": utterance, + "internal_lang": self.internal_language, + "target_lang": out_lang + } + utterance = self.translate_utterance(utterance, sauce_lang, out_lang) + meta["translation_data"]["translated"] = utterance + kwargs["meta"] = meta + super().speak(utterance, *args, **kwargs) -class UniversalFallback(UniversalSkill, OVOSFallbackSkill): - ''' Fallback Skill that auto translates input/output from any language ''' +class UniversalFallback(UniversalSkill, FallbackSkillV2): + """ + Fallback Skill that auto translates input/output from any language - def create_universal_fallback_handler(self, handler): + fallback handlers are ensured to receive utterances and expected to produce + responses in self.internal_language + + self.speak will always translate utterances from + self.internal_lang to self.lang + NOTE: self.lang reflects the original query language + but received utterances are always in self.internal_language + + """ + + def create_universal_fallback_handler(self, handler): def universal_fallback_handler(message): # auto_Translate input message = self._translate_message(message) @@ -95,5 +186,61 @@ def universal_fallback_handler(message): def register_fallback(self, handler, priority): handler = self.create_universal_fallback_handler(handler) - self.instance_fallback_handlers.append(handler) - self._register_fallback(handler, priority) + FallbackSkillV2.register_fallback(self, handler, priority) + + +class UniversalCommonQuerySkill(UniversalSkill, CommonQuerySkill, ABC): + """ + CommonQuerySkill that auto translates input/output from any language + + CQS_match_query_phrase and CQS_action are ensured to received phrase in + self.internal_language + + CQS_match_query_phrase is assumed to return a response in self.internal_lang + it will be translated back before speaking + + self.speak will always translate utterances from + self.internal_lang to self.lang + + NOTE: self.lang reflects the original query language + but received utterances are always in self.internal_language + """ + + def __handle_query_action(self, message): + """Message handler for question:action. + + Extracts phrase and data from message forward this to the skills + CQS_action method. + """ + if message.data["skill_id"] != self.skill_id: + # Not for this skill! + return + if self.lang != self.internal_language or self.autodetect: + message.data["phrase"] = self.translate_utterance(message.data["phrase"], + sauce_lang=self.lang, + target_lang=self.internal_language) + + super().__handle_query_action(message) + + def __get_cq(self, search_phrase): + if self.lang == self.internal_language and not self.autodetect: + return super().__get_cq(search_phrase) + + # convert input into internal lang + search_phrase = self.translate_utterance(search_phrase, self.internal_language, self.lang) + result = super().__get_cq(search_phrase) + if not result: + return None + answer = result[2] + # convert response back into source lang + answer = self.translate_utterance(answer, self.lang, self.internal_language) + if len(result) > 3: + # optional callback_data + result = (result[0], result[1], answer, result[3]) + else: + result = (result[0], result[1], answer) + return result + + def remove_noise(self, phrase, lang=None): + """remove noise to produce essence of question""" + return super().remove_noise(phrase, self.internal_language) diff --git a/ovos_workshop/skills/base.py b/ovos_workshop/skills/base.py index 371ae851..83184236 100644 --- a/ovos_workshop/skills/base.py +++ b/ovos_workshop/skills/base.py @@ -13,66 +13,88 @@ # limitations under the License. # """Common functionality relating to the implementation of mycroft skills.""" +import datetime import re import sys import time import traceback from copy import copy -from dataclasses import dataclass from hashlib import md5 from inspect import signature from itertools import chain from os.path import join, abspath, dirname, basename, isfile -from threading import Event - +from threading import Event, RLock +from typing import List, Optional, Dict, Callable, Union +from ovos_bus_client import MessageBusClient +from ovos_bus_client.session import SessionManager from json_database import JsonStorage from lingua_franca.format import pronounce_number, join_list from lingua_franca.parse import yes_or_no, extract_number -from mycroft_bus_client.message import Message, dig_for_message from ovos_backend_client.api import EmailApi, MetricsApi +from ovos_bus_client.message import Message, dig_for_message from ovos_config.config import Configuration from ovos_config.locations import get_xdg_config_save_path from ovos_utils import camel_case_split -from ovos_utils.dialog import get_dialog +from ovos_utils.dialog import get_dialog, MustacheDialogRenderer from ovos_utils.enclosure.api import EnclosureAPI -from ovos_utils.events import EventSchedulerInterface +from ovos_utils.events import EventContainer, EventSchedulerInterface from ovos_utils.file_utils import FileWatcher -from ovos_utils.gui import GUIInterface +from ovos_utils.gui import GUIInterface, get_ui_directories from ovos_utils.intents import ConverseTracker from ovos_utils.intents import Intent, IntentBuilder -from ovos_utils.intents.intent_service_interface import munge_regex, munge_intent_parser, IntentServiceInterface -from ovos_utils.log import LOG -from ovos_utils.messagebus import get_handler_name, create_wrapper, EventContainer, get_message_lang +from ovos_utils.intents.intent_service_interface import munge_regex, \ + munge_intent_parser, IntentServiceInterface +from ovos_utils.json_helper import merge_dict +from ovos_utils.log import LOG, deprecated, log_deprecation +from ovos_utils.messagebus import get_handler_name, create_wrapper, \ + get_message_lang from ovos_utils.parse import match_one +from ovos_utils.process_utils import RuntimeRequirements from ovos_utils.skills import get_non_properties -from ovos_utils.sound import play_acknowledge_sound, wait_while_speaking +from ovos_utils.sound import play_acknowledge_sound +from ovos_utils import classproperty -from ovos_workshop.decorators import classproperty from ovos_workshop.decorators.killable import AbortEvent from ovos_workshop.decorators.killable import killable_event, \ AbortQuestion from ovos_workshop.filesystem import FileSystemAccess from ovos_workshop.resource_files import ResourceFile, \ CoreResources, SkillResources, find_resource -from ovos_utils.process_utils import RuntimeRequirements +from ovos_workshop.settings import SkillSettingsManager # backwards compat alias class SkillNetworkRequirements(RuntimeRequirements): def __init__(self, *args, **kwargs): - LOG.warning("SkillNetworkRequirements has been renamed to RuntimeRequirements\n" - "from ovos_utils.process_utils import RuntimeRequirements") + log_deprecation("Replace with " + "`ovos_utils.process_utils.RuntimeRequirements`", + "0.1.0") super().__init__(*args, **kwargs) -def simple_trace(stack_trace): - """Generate a simplified traceback. +def is_classic_core() -> bool: + """ + Check if the current core is the classic mycroft-core + """ + try: + from mycroft.version import OVOS_VERSION_STR + return False # ovos-core + except ImportError: + try: + log_deprecation("Support for `mycroft_core` will be deprecated", + "0.1.0") + from mycroft.version import CORE_VERSION_STR + return True # mycroft-core + except ImportError: + return False # standalone - Args: - stack_trace: Stack trace to simplify - Returns: (str) Simplified stack trace. +def simple_trace(stack_trace: List[str]) -> str: + """ + Generate a simplified traceback. + @param stack_trace: Formatted stack trace (each string ends with \n) + @return: Stack trace with any empty lines removed and last line removed """ stack_trace = stack_trace[:-1] tb = 'Traceback:\n' @@ -83,57 +105,99 @@ def simple_trace(stack_trace): class BaseSkill: - """Base class for mycroft skills providing common behaviour and parameters - to all Skill implementations. This base class does not require `mycroft` to be importable - - Args: - name (str): skill name + """ + Base class for mycroft skills providing common behaviour and parameters + to all Skill implementations. This base class does not require `mycroft` to + be importable + + skill_launcher.py used to be skill_loader-py in mycroft-core + + for launching skills one can use skill_launcher.py to run them standalone + (eg, docker), but the main objective is to make skills work more like proper + python objects and allow usage of the class directly + + the considerations are: + + - most skills in the wild don't expose kwargs, so don't accept + skill_id or bus + - most skills expect a loader class to set up the bus and skill_id after + object creation + - skills can not do pythonic things in init, instead of doing things after + super() devs are expected to use initialize() which is a mycroft invention + and non-standard + - main concern is that anything depending on self.skill_id being set can not + be used in init method (eg. self.settings and self.file_system) + - __new__ uncouples the skill init from a helper class, making skills work + like regular python objects + - the magic in `__new__` is just so we don't break everything in the wild, + since we cant start requiring skill_id and bus args + + KwArgs: + name (str): skill name - DEPRECATED + skill_id (str): unique skill identifier bus (MycroftWebsocketClient): Optional bus connection """ - def __init__(self, name=None, bus=None, resources_dir=None, - settings: JsonStorage = None, - gui=None, enable_settings_manager=True): + def __init__(self, name: Optional[str] = None, + bus: Optional[MessageBusClient] = None, + resources_dir: Optional[str] = None, + settings: Optional[JsonStorage] = None, + gui: Optional[GUIInterface] = None, + enable_settings_manager: bool = True, + skill_id: str = ""): + """ + Create an OVOSSkill object. + @param name: DEPRECATED skill_name + @param bus: MessageBusClient to bind to skill + @param resources_dir: optional root resource directory (else defaults to + skill `root_dir` + @param settings: Optional settings object, else defined in skill config + path + @param gui: Optional SkillGUI, else one is initialized + @param enable_settings_manager: if True, enables a SettingsManager for + this skill to manage default settings and backend sync + @param skill_id: Unique ID for this skill + """ + self.log = LOG # a dedicated namespace will be assigned in _startup self._enable_settings_manager = enable_settings_manager self._init_event = Event() self.name = name or self.__class__.__name__ self.resting_name = None - self.skill_id = '' # will be set by SkillLoader, guaranteed unique + self.skill_id = skill_id # set by SkillLoader, guaranteed unique self._settings_meta = None # DEPRECATED - backwards compat only self.settings_manager = None - # Get directory of skill - #: Member variable containing the absolute path of the skill's root - #: directory. E.g. $XDG_DATA_HOME/mycroft/skills/my-skill.me/ + # Get directory of skill source (__init__.py) self.root_dir = dirname(abspath(sys.modules[self.__module__].__file__)) self.res_dir = resources_dir or self.root_dir self.gui = gui - self._bus = bus self._enclosure = EnclosureAPI() - #: Mycroft global configuration. (dict) - self.config_core = Configuration() + # Core configuration + self.config_core: Configuration = Configuration() self._settings = None self._initial_settings = settings or dict() self._settings_watchdog = None + self._settings_lock = RLock() - #: Set to register a callback method that will be called every time - #: the skills settings are updated. The referenced method should - #: include any logic needed to handle the updated settings. + # Override to register a callback method that will be called every time + # the skill's settings are updated. The referenced method should + # include any logic needed to handle the updated settings. self.settings_change_callback = None # fully initialized when self.skill_id is set self._file_system = None - self.log = LOG - self.reload_skill = True #: allow reloading (default True) + self.reload_skill = True # allow reloading (default True) self.events = EventContainer(bus) - self.voc_match_cache = {} + + # Cached voc file contents + self._voc_cache = {} # loaded lang file resources self._lang_resources = {} @@ -143,275 +207,202 @@ def __init__(self, name=None, bus=None, resources_dir=None, self.intent_service = IntentServiceInterface() # Skill Public API - self.public_api = {} + self.public_api: Dict[str, dict] = {} self.__original_converse = self.converse - # classproperty not present in mycroft-core + # yay, following python best practices again! + if self.skill_id and bus: + self._startup(bus, self.skill_id) + @classproperty - def runtime_requirements(self): - """ skill developers should override this if they do not require connectivity - - some examples: - - IOT skill that controls skills via LAN could return: - scans_on_init = True - RuntimeRequirements(internet_before_load=False, - network_before_load=scans_on_init, - requires_internet=False, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=False) - - online search skill with a local cache: - has_cache = False - RuntimeRequirements(internet_before_load=not has_cache, - network_before_load=not has_cache, - requires_internet=True, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=True) - - a fully offline skill: - RuntimeRequirements(internet_before_load=False, - network_before_load=False, - requires_internet=False, - requires_network=False, - no_internet_fallback=True, - no_network_fallback=True) + def runtime_requirements(self) -> RuntimeRequirements: + """ + Override to specify what a skill expects to be available at init and at + runtime. Default will assume network and internet are required and GUI + is not required for backwards-compat. + + some examples: + + IOT skill that controls skills via LAN could return: + scans_on_init = True + RuntimeRequirements(internet_before_load=False, + network_before_load=scans_on_init, + requires_internet=False, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=False) + + online search skill with a local cache: + has_cache = False + RuntimeRequirements(internet_before_load=not has_cache, + network_before_load=not has_cache, + requires_internet=True, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=True) + + a fully offline skill: + RuntimeRequirements(internet_before_load=False, + network_before_load=False, + requires_internet=False, + requires_network=False, + no_internet_fallback=True, + no_network_fallback=True) """ return RuntimeRequirements() @classproperty - def network_requirements(self): + def network_requirements(self) -> RuntimeRequirements: LOG.warning("network_requirements renamed to runtime_requirements, " "will be removed in ovos-core 0.0.8") return self.runtime_requirements - # property not present in mycroft-core @property - def _is_fully_initialized(self): - """Determines if the skill has been fully loaded and setup. - When True all data has been loaded and all internal state and events setup""" - return self._init_event.is_set() - - # method not present in mycroft-core - def _handle_first_run(self): - """The very first time a skill is run, speak the intro.""" - intro = self.get_intro_message() - if intro: - # supports .dialog files for easy localization - # when .dialog does not exist, the text is spoken - # it is backwards compatible - self.speak_dialog(intro) - - # method not present in mycroft-core - def _check_for_first_run(self): - """Determine if its the very first time a skill is run.""" - first_run = self.settings.get("__mycroft_skill_firstrun", True) - if first_run: - LOG.info("First run of " + self.skill_id) - self._handle_first_run() - self.settings["__mycroft_skill_firstrun"] = False - self.settings.store() - - # method not present in mycroft-core - def _startup(self, bus, skill_id=""): - """Startup the skill. - - This connects the skill to the messagebus, loads vocabularies and - data files and in the end calls the skill creator's "intialize" code. - - Arguments: - bus: Mycroft Messagebus connection object. - skill_id (str): need to be unique, by default is set from skill path - but skill loader can override this + def voc_match_cache(self) -> Dict[str, List[str]]: """ - if self._is_fully_initialized: - LOG.warning(f"Tried to initialize {self.skill_id} multiple times, ignoring") - return - - # NOTE: this method is called by SkillLoader - # it is private to make it clear to skill devs they should not touch it - try: - # set the skill_id - self.skill_id = skill_id or basename(self.root_dir) - self.intent_service.set_id(self.skill_id) - self.event_scheduler.set_id(self.skill_id) - self.enclosure.set_id(self.skill_id) - - # initialize anything that depends on skill_id - self.log = LOG.create_logger(self.skill_id) - self._init_settings() - - # initialize anything that depends on the messagebus - self.bind(bus) - if not self.gui: - self._init_skill_gui() - if self._enable_settings_manager: - self._init_settings_manager() - self.load_data_files() - self._register_decorated() - self.register_resting_screen() - - # run skill developer initialization code - self.initialize() - self._check_for_first_run() - self._init_event.set() - except Exception as e: - LOG.exception('Skill initialization failed') - # If an exception occurs, attempt to clean up the skill - try: - self.default_shutdown() - except Exception as e2: - pass - raise e - - def _init_settings(self): - """Setup skill settings.""" - LOG.debug(f"initializing skill settings for {self.skill_id}") - - # NOTE: lock is disabled due to usage of deepcopy and to allow json serialization - self._settings = JsonStorage(self._settings_path, disable_lock=True) - if self._initial_settings: - # TODO make a debug log in next version - LOG.warning("Copying default settings values defined in __init__ \n" - "Please move code from __init__() to initialize() " - "if you did not expect to see this message") - for k, v in self._initial_settings.items(): - if k not in self._settings: - self._settings[k] = v - self._initial_settings = copy(self.settings) - - self._start_filewatcher() - - # method not in mycroft-core - def _init_skill_gui(self): - try: - from mycroft.gui import SkillGUI - self.gui = SkillGUI(self) - self.gui.setup_default_handlers() - except ImportError: - self.gui = GUIInterface(self.skill_id) - if self.bus: - self.gui.set_bus(self.bus) - - # method not in mycroft-core - def _init_settings_manager(self): - try: - from mycroft.skills.settings import SkillSettingsManager - self.settings_manager = SkillSettingsManager(self) - except ImportError: - pass - - # method not present in mycroft-core - def _start_filewatcher(self): - if self._settings_watchdog is None and isfile(self._settings.path): - self._settings_watchdog = FileWatcher([self._settings.path], - callback=self._handle_settings_file_change, - ignore_creation=True) + Backwards-compatible accessor method for vocab cache + @return: dict vocab resources to parsed resources + """ + return self._voc_cache - # method not present in mycroft-core - def _upload_settings(self): - if self.settings_manager and self.config_core.get("skills", {}).get("sync2way"): - # upload new settings to backend - generate = self.config_core.get("skills", {}).get("autogen_meta", True) - self.settings_manager.upload(generate) # this will check global sync flag - if generate: - # update settingsmeta file on disk - self.settings_manager.save_meta() + @voc_match_cache.setter + def voc_match_cache(self, val): + self.log.warning("self._voc_cache should not be modified externally. This" + "functionality will be deprecated in a future release") + if isinstance(val, dict): + self._voc_cache = val - # method not present in mycroft-core - def _handle_settings_file_change(self, path): - if self._settings: - self._settings.reload() - if self.settings_change_callback: - try: - self.settings_change_callback() - except: - self.log.exception("settings change callback failed, " - "file changes not handled!") - self._upload_settings() + # not a property in mycroft-core + @property + def _is_fully_initialized(self) -> bool: + """ + Determines if the skill has been fully loaded and setup. + When True, all data has been loaded and all internal state + and events set up. + """ + return self._init_event.is_set() # not a property in mycroft-core @property - def _settings_path(self): - return join(get_xdg_config_save_path(), 'skills', self.skill_id, 'settings.json') + def _settings_path(self) -> str: + """ + Absolute file path of this skill's `settings.json` (file may not exist) + """ + return join(get_xdg_config_save_path(), 'skills', self.skill_id, + 'settings.json') # not a property in mycroft-core @property - def settings(self): + def settings(self) -> JsonStorage: + """ + Get settings specific to this skill + """ if self._settings is not None: return self._settings else: - LOG.error('Skill not fully initialized. ' - 'Only default values can be set, no settings can be read or changed.' - 'Move code from __init__() to initialize() to correct this.') + self.log.warning('Skill not fully initialized. Only default values ' + 'can be set, no settings can be read or changed.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) return self._initial_settings # not a property in mycroft-core @settings.setter - def settings(self, val): + def settings(self, val: dict): + """ + Update settings specific to this skill + """ assert isinstance(val, dict) # init method if self._settings is None: self._initial_settings = val return - # ensure self._settings remains a JsonDatabase - self._settings.clear() # clear data - self._settings.merge(val, skip_empty=False) # merge new data + with self._settings_lock: + # ensure self._settings remains a JsonDatabase + self._settings.clear() # clear data + self._settings.merge(val, skip_empty=False) # merge new data # not a property in mycroft-core @property - def dialog_renderer(self): + def dialog_renderer(self) -> Optional[MustacheDialogRenderer]: + """ + Get a dialog renderer for this skill. Language will be determined by + message history to match the language associated with the current + session or else from Configuration. + """ return self._resources.dialog_renderer @property - def enclosure(self): + def enclosure(self) -> EnclosureAPI: + """ + Get an EnclosureAPI object to interact with hardware + """ if self._enclosure: return self._enclosure else: - LOG.error('Skill not fully initialized. Move code ' + - 'from __init__() to initialize() to correct this.') - LOG.error(simple_trace(traceback.format_stack())) + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) raise Exception('Accessed MycroftSkill.enclosure in __init__') # not a property in mycroft-core @property - def file_system(self): - """ Filesystem access to skill specific folder. - - See mycroft.filesystem for details. + def file_system(self) -> FileSystemAccess: + """ + Get an object that provides managed access to a local Filesystem. """ if not self._file_system and self.skill_id: self._file_system = FileSystemAccess(join('skills', self.skill_id)) if self._file_system: return self._file_system else: - LOG.error('Skill not fully initialized. Move code ' + - 'from __init__() to initialize() to correct this.') - LOG.error(simple_trace(traceback.format_stack())) + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs __init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) raise Exception('Accessed MycroftSkill.file_system in __init__') @file_system.setter - def file_system(self, fs): - """Provided mainly for backwards compatibility with derivative MycroftSkill classes - Skills are advised against redefining the file system directory""" + def file_system(self, fs: FileSystemAccess): + """ + Provided mainly for backwards compatibility with derivative + MycroftSkill classes. Skills are advised against redefining the file + system directory. + @param fs: new FileSystemAccess object to use + """ + self.log.warning(f"Skill manually overriding file_system path to: " + f"{fs.path}") self._file_system = fs @property - def bus(self): + def bus(self) -> MessageBusClient: + """ + Get the MessageBusClient bound to this skill + """ if self._bus: return self._bus else: - LOG.error('Skill not fully initialized. Move code ' + - 'from __init__() to initialize() to correct this.') - LOG.error(simple_trace(traceback.format_stack())) + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) raise Exception('Accessed MycroftSkill.bus in __init__') @bus.setter - def bus(self, value): - from mycroft_bus_client import MessageBusClient + def bus(self, value: MessageBusClient): + """ + Set the MessageBusClient bound to this skill. Note that setting this + after init may have unintended consequences as expected events might + not be registered. Call `bind` to connect a new MessageBusClient. + @param value: new MessageBusClient object + """ + from ovos_bus_client import MessageBusClient from ovos_utils.messagebus import FakeBus if isinstance(value, (MessageBusClient, FakeBus)): self._bus = value @@ -419,31 +410,40 @@ def bus(self, value): raise TypeError(f"Expected a MessageBusClient, got: {type(value)}") @property - def location(self): - """Get the JSON data struction holding location information.""" + def location(self) -> dict: + """ + Get the JSON data struction holding location information. + """ # TODO: Allow Enclosure to override this for devices that - # contain a GPS. + # contain a GPS. return self.config_core.get('location') @property - def location_pretty(self): - """Get a more 'human' version of the location as a string.""" + def location_pretty(self) -> Optional[str]: + """ + Get a speakable city from the location config if available + """ loc = self.location if type(loc) is dict and loc['city']: return loc['city']['name'] return None @property - def location_timezone(self): - """Get the timezone code, such as 'America/Los_Angeles'""" + def location_timezone(self) -> Optional[str]: + """ + Get the timezone code, such as 'America/Los_Angeles' + """ loc = self.location if type(loc) is dict and loc['timezone']: return loc['timezone']['code'] return None @property - def lang(self): - """Get the current language.""" + def lang(self) -> str: + """ + Get the current language as a BCP-47 language code. This will consider + current session data if available, else Configuration. + """ lang = self._core_lang message = dig_for_message() if message: @@ -452,82 +452,271 @@ def lang(self): # property not present in mycroft-core @property - def _core_lang(self): - """Get the configured default language. - NOTE: this should be public, but since if a skill uses this it wont - work in regular mycroft-core it was made private!""" + def _core_lang(self) -> str: + """ + Get the configured default language as a BCP-47 language code. + + NOTE: this should be public, but since if a skill uses this it won't + work in regular mycroft-core it was made private! + """ return self.config_core.get("lang", "en-us").lower() # property not present in mycroft-core @property - def _secondary_langs(self): - """Get the configured secondary languages, mycroft is not - considered to be in these languages, but will load its resource - files. This provides initial support for multilingual input. A skill - may override this method to specify which languages intents are - registered in. - NOTE: this should be public, but since if a skill uses this it wont - work in regular mycroft-core it was made private!""" - return [l.lower() for l in self.config_core.get('secondary_langs', []) - if l != self._core_lang] + def _secondary_langs(self) -> List[str]: + """ + Get the configured secondary languages; resources will be loaded for + these languages to provide support for multilingual input, in addition + to `core_lang`. A skill may override this method to specify which + languages intents are registered in. + + NOTE: this should be public, but since if a skill uses this it won't + work in regular mycroft-core it was made private! + """ + return [lang.lower() for lang in self.config_core.get( + 'secondary_langs', []) if lang != self._core_lang] # property not present in mycroft-core @property - def _native_langs(self): - """Languages natively supported by core - ie, resource files available and explicitly supported - NOTE: this should be public, but since if a skill uses this it wont + def _native_langs(self) -> List[str]: + """ + Languages natively supported by this skill (ie, resource files available + and explicitly supported). This is equivalent to normalized + secondary_langs + core_lang. + + NOTE: this should be public, but since if a skill uses this it won't work in regular mycroft-core it was made private! """ - valid = set([l.lower() for l in self._secondary_langs - if '-' in l and l != self._core_lang] + [self._core_lang]) + valid = set([lang.lower() for lang in self._secondary_langs if '-' in + lang and lang != self._core_lang] + [self._core_lang]) return list(valid) # property not present in mycroft-core @property - def _alphanumeric_skill_id(self): - """skill id converted to only alphanumeric characters - Non alpha-numeric characters are converted to "_" + def _alphanumeric_skill_id(self) -> str: + """ + Skill id converted to only alphanumeric characters and "_". + Non alphanumeric characters are converted to "_" - NOTE: this should be public, but since if a skill uses this it wont + NOTE: this should be public, but since if a skill uses this it won't work in regular mycroft-core it was made private! - - Returns: - (str) String of letters """ return ''.join(c if c.isalnum() else '_' for c in str(self.skill_id)) # property not present in mycroft-core @property - def _resources(self): - """Instantiates a ResourceFileLocator instance when needed. - a new instance is always created to ensure self.lang - reflects the active language and not the default core language - NOTE: this should be public, but since if a skill uses this it wont + def _resources(self) -> SkillResources: + """ + Get a SkillResources object for the current language. Objects are + initialized for the current language as needed. + + NOTE: this should be public, but since if a skill uses this it won't work in regular mycroft-core it was made private! """ return self._load_lang(self.res_dir, self.lang) + # property not present in mycroft-core + @property + def _stop_is_implemented(self) -> bool: + """ + True if this skill implements a `stop` method + """ + return self.__class__.stop is not BaseSkill.stop + + # property not present in mycroft-core + @property + def _converse_is_implemented(self) -> bool: + """ + True if this skill implements a `converse` method + """ + return self.__class__.converse is not BaseSkill.converse or \ + self.__original_converse != self.converse + # method not present in mycroft-core - def _load_lang(self, root_directory=None, lang=None): - """Instantiates a ResourceFileLocator instance when needed. - a new instance is always created to ensure lang - reflects the active language and not the default core language - NOTE: this should be public, but since if a skill uses this it wont + def _handle_first_run(self): + """ + The very first time a skill is run, speak a provided intro_message. + """ + intro = self.get_intro_message() + if intro: + # supports .dialog files for easy localization + # when .dialog does not exist, the text is spoken + # it is backwards compatible + self.speak_dialog(intro) + + # method not present in mycroft-core + def _check_for_first_run(self): + """ + Determine if this is the very first time a skill is run by looking for + `__mycroft_skill_firstrun` in skill settings. + """ + first_run = self.settings.get("__mycroft_skill_firstrun", True) + if first_run: + self.log.info("First run of " + self.skill_id) + self._handle_first_run() + self.settings["__mycroft_skill_firstrun"] = False + self.settings.store() + + def _startup(self, bus: MessageBusClient, skill_id: str = ""): + """ + Startup the skill. Connects the skill to the messagebus, loads resources + and finally calls the skill's "intialize" method. + @param bus: MessageBusClient to bind to skill + @param skill_id: Unique skill identifier, defaults to skill path for + legacy skills and python entrypoints for modern skills + """ + if self._is_fully_initialized: + self.log.warning(f"Tried to initialize {self.skill_id} multiple " + f"times, ignoring") + return + + # NOTE: this method is called by SkillLoader + # it is private to make it clear to skill devs they should not touch it + try: + # set the skill_id + self.skill_id = skill_id or basename(self.root_dir) + self.intent_service.set_id(self.skill_id) + self.event_scheduler.set_id(self.skill_id) + self.enclosure.set_id(self.skill_id) + + # initialize anything that depends on skill_id + self.log = LOG.create_logger(self.skill_id) + self._init_settings() + + # initialize anything that depends on the messagebus + self.bind(bus) + if not self.gui: + self._init_skill_gui() + if self._enable_settings_manager: + self._init_settings_manager() + self.load_data_files() + self._register_decorated() + self.register_resting_screen() + + # run skill developer initialization code + self.initialize() + self._check_for_first_run() + self._init_event.set() + except Exception as e: + self.log.exception('Skill initialization failed') + # If an exception occurs, attempt to clean up the skill + try: + self.default_shutdown() + except Exception as e2: + LOG.debug(e2) + raise e + + def _init_settings(self): + """ + Set up skill settings. Defines settings in the specified file path, + handles any settings passed to skill init, and starts watching the + settings file for changes. + """ + self.log.debug(f"initializing skill settings for {self.skill_id}") + + # NOTE: lock is disabled due to usage of deepcopy and to allow json + # serialization + self._settings = JsonStorage(self._settings_path, disable_lock=True) + with self._settings_lock: + if self._initial_settings and not self._is_fully_initialized: + self.log.warning("Copying default settings values defined in " + "__init__ \nto correct this add kwargs " + "__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + for k, v in self._initial_settings.items(): + if k not in self._settings: + self._settings[k] = v + self._initial_settings = copy(self.settings) + + self._start_filewatcher() + + def _init_skill_gui(self): + """ + Set up the SkillGUI for this skill and connect relevant bus events. + """ + self.gui = SkillGUI(self) + self.gui.setup_default_handlers() + + def _init_settings_manager(self): + """ + Set up the SkillSettingsManager for this skill. + """ + self.settings_manager = SkillSettingsManager(self) + + def _start_filewatcher(self): + """ + Start watching settings for file changes if settings file exists and + there isn't already a FileWatcher watching it + """ + if self._settings_watchdog is None and isfile(self._settings.path): + self._settings_watchdog = \ + FileWatcher([self._settings.path], + callback=self._handle_settings_file_change, + ignore_creation=True) + + # method not present in mycroft-core + def _upload_settings(self): + """ + Upload settings to a remote backend if configured. + """ + if self.settings_manager and self.config_core.get("skills", + {}).get("sync2way"): + # upload new settings to backend + generate = self.config_core.get("skills", {}).get("autogen_meta", + True) + # this will check global sync flag + self.settings_manager.upload(generate) + if generate: + # update settingsmeta file on disk + self.settings_manager.save_meta() + + # method not present in mycroft-core + def _handle_settings_file_change(self, path: str): + """ + Handle a FileWatcher notification that a file was changed. Reload + settings, call `self.settings_change_callback` if defined, and upload + changes if a backend is configured. + @param path: Modified file path + """ + if path != self._settings.path: + LOG.debug(f"Ignoring non-settings change") + return + if self._settings: + with self._settings_lock: + self._settings.reload() + if self.settings_change_callback: + try: + self.settings_change_callback() + except Exception as e: + self.log.exception("settings change callback failed, " + f"file changes not handled!: {e}") + self._upload_settings() + + # method not present in mycroft-core + def _load_lang(self, root_directory: Optional[str] = None, + lang: Optional[str] = None) -> SkillResources: + """ + Get a SkillResources object for this skill in the requested `lang` for + resource files in the requested `root_directory`. + @param root_directory: root path to find resources (default res_dir) + @param lang: language to get resources for (default self.lang) + @return: SkillResources object + + NOTE: this should be public, but since if a skill uses this it won't work in regular mycroft-core it was made private! """ lang = lang or self.lang root_directory = root_directory or self.res_dir if lang not in self._lang_resources: - self._lang_resources[lang] = SkillResources(root_directory, lang, skill_id=self.skill_id) + self._lang_resources[lang] = SkillResources(root_directory, lang, + skill_id=self.skill_id) return self._lang_resources[lang] - def bind(self, bus): - """Register messagebus emitter with skill. - - Args: - bus: Mycroft messagebus connection + def bind(self, bus: MessageBusClient): + """ + Register MessageBusClient with skill. + @param bus: MessageBusClient to bind to skill and internal objects """ if bus: self._bus = bus @@ -538,31 +727,29 @@ def bind(self, bus): self._register_system_event_handlers() self._register_public_api() - try: - from mycroft.version import OVOS_VERSION_STR - except ImportError: - # inject ovos exclusive features in vanila mycroft-core if possible - - ## limited support for missing skill deactivated event + if is_classic_core(): + log_deprecation("Support for mycroft-core is deprecated", + "0.1.0") + # inject ovos exclusive features in vanilla mycroft-core + # if possible + # limited support for missing skill deactivated event # TODO - update ConverseTracker ConverseTracker.connect_bus(self.bus) # pull/1468 self.add_event("converse.skill.deactivated", - self._handle_skill_deactivated, speak_errors=False) + self._handle_skill_deactivated, + speak_errors=False) def _register_public_api(self): - """ Find and register api methods. - Api methods has been tagged with the api_method member, for each - method where this is found the method a message bus handler is - registered. - Finally create a handler for fetching the api info from any requesting - skill. + """ + Find and register API methods decorated with `@api_method` and create a + messagebus handler for fetching the api info if any handlers exist. """ - def wrap_method(func): - """Boiler plate for returning the response to the sender.""" + def wrap_method(fn): + """Boilerplate for returning the response to the sender.""" def wrapper(message): - result = func(*message.data['args'], **message.data['kwargs']) + result = fn(*message.data['args'], **message.data['kwargs']) message.context["skill_id"] = self.skill_id self.bus.emit(message.response(data={'result': result})) @@ -585,7 +772,8 @@ def wrapper(message): for key in self.public_api: if ('type' in self.public_api[key] and 'func' in self.public_api[key]): - LOG.debug(f"Adding api method: {self.public_api[key]['type']}") + self.log.debug(f"Adding api method: " + f"{self.public_api[key]['type']}") # remove the function member since it shouldn't be # reused and can't be sent over the messagebus @@ -597,131 +785,155 @@ def wrapper(message): self.add_event(f'{self.skill_id}.public_api', self._send_public_api, speak_errors=False) - # property not present in mycroft-core - @property - def _stop_is_implemented(self): - return self.__class__.stop is not BaseSkill.stop - - # property not present in mycroft-core - @property - def _converse_is_implemented(self): - return self.__class__.converse is not BaseSkill.converse or \ - self.__original_converse != self.converse - def _register_system_event_handlers(self): - """Add all events allowing the standard interaction with the Mycroft - system. + """ + Register default messagebus event handlers """ # Only register stop if it's been implemented if self._stop_is_implemented: - self.add_event('mycroft.stop', self.__handle_stop, speak_errors=False) - self.add_event('skill.converse.ping', self._handle_converse_ack, speak_errors=False) - self.add_event('skill.converse.request', self._handle_converse_request, speak_errors=False) - self.add_event(f"{self.skill_id}.activate", self.handle_activate, speak_errors=False) - self.add_event(f"{self.skill_id}.deactivate", self.handle_deactivate, speak_errors=False) - self.add_event("intent.service.skills.deactivated", self._handle_skill_deactivated, speak_errors=False) - self.add_event("intent.service.skills.activated", self._handle_skill_activated, speak_errors=False) - self.add_event('mycroft.skill.enable_intent', self.handle_enable_intent, speak_errors=False) - self.add_event('mycroft.skill.disable_intent', self.handle_disable_intent, speak_errors=False) - self.add_event('mycroft.skill.set_cross_context', self.handle_set_cross_context, speak_errors=False) - self.add_event('mycroft.skill.remove_cross_context', self.handle_remove_cross_context, speak_errors=False) - self.add_event('mycroft.skills.settings.changed', self.handle_settings_change, speak_errors=False) - - def handle_settings_change(self, message): - """Update settings if the remote settings changes apply to this skill. + self.add_event('mycroft.stop', self.__handle_stop, + speak_errors=False) + self.add_event('skill.converse.ping', self._handle_converse_ack, + speak_errors=False) + self.add_event('skill.converse.request', self._handle_converse_request, + speak_errors=False) + self.add_event(f"{self.skill_id}.activate", self.handle_activate, + speak_errors=False) + self.add_event(f"{self.skill_id}.deactivate", self.handle_deactivate, + speak_errors=False) + self.add_event("intent.service.skills.deactivated", + self._handle_skill_deactivated, speak_errors=False) + self.add_event("intent.service.skills.activated", + self._handle_skill_activated, speak_errors=False) + self.add_event('mycroft.skill.enable_intent', self.handle_enable_intent, + speak_errors=False) + self.add_event('mycroft.skill.disable_intent', + self.handle_disable_intent, speak_errors=False) + self.add_event('mycroft.skill.set_cross_context', + self.handle_set_cross_context, speak_errors=False) + self.add_event('mycroft.skill.remove_cross_context', + self.handle_remove_cross_context, speak_errors=False) + self.add_event('mycroft.skills.settings.changed', + self.handle_settings_change, speak_errors=False) + + def handle_settings_change(self, message: Message): + """ + Update settings if a remote settings changes apply to this skill. The skill settings downloader uses a single API call to retrieve the - settings for all skills. This is done to limit the number API calls. + settings for all skills to limit the number API calls. A "mycroft.skills.settings.changed" event is emitted for each skill - that had their settings changed. Only update this skill's settings - if its remote settings were among those changed + with settings changes. Only update this skill's settings if its remote + settings were among those changed. """ remote_settings = message.data.get(self.skill_id) if remote_settings is not None: - LOG.info('Updating settings for skill ' + self.skill_id) + self.log.info('Updating settings for skill ' + self.skill_id) self.settings.update(**remote_settings) self.settings.store() if self.settings_change_callback is not None: try: self.settings_change_callback() - except: + except Exception as e: self.log.exception("settings change callback failed, " - "remote changes not handled!") + f"remote changes not handled!: {e}") self._start_filewatcher() def detach(self): + """ + Detach all intents for this skill from the intent_service. + """ for (name, _) in self.intent_service: name = f'{self.skill_id}:{name}' self.intent_service.detach_intent(name) def initialize(self): - """Perform any final setup needed for the skill. - - Invoked after the skill is fully constructed and registered with the - system. + """ + Legacy method overridden by skills to perform extra init after __init__. + Skills should now move any code in this method to `__init__`, after a + call to `super().__init__`. """ pass - def _send_public_api(self, message): - """Respond with the skill's public api.""" + def _send_public_api(self, message: Message): + """ + Respond with the skill's public api. + @param message: `{self.skill_id}.public_api` Message + """ message.context["skill_id"] = self.skill_id self.bus.emit(message.response(data=self.public_api)) - def get_intro_message(self): - """Get a message to speak on first load of the skill. - - Useful for post-install setup instructions. - - Returns: - str: message that will be spoken to the user + def get_intro_message(self) -> str: """ - return None + Override to return a string to speak on first run. i.e. for post-install + setup instructions. + """ + return "" # method not present in mycroft-core - def _handle_skill_activated(self, message): - """ intent service activated a skill - if it was this skill fire the skill activation event""" + def _handle_skill_activated(self, message: Message): + """ + Intent service activated a skill. If it was this skill, + emit a skill activation message. + @param message: `intent.service.skills.activated` Message + """ if message.data.get("skill_id") == self.skill_id: self.bus.emit(message.forward(f"{self.skill_id}.activate")) # method not present in mycroft-core - def handle_activate(self, message): - """ skill is now considered active by the intent service - converse method will be called, skills might want to prepare/resume + def handle_activate(self, message: Message): + """ + Called when this skill is considered active by the intent service; + converse method will be called with every utterance. + Override this method to do any optional preparation. + @param message: `{self.skill_id}.activate` Message """ # method not present in mycroft-core def _handle_skill_deactivated(self, message): - """ intent service deactivated a skill - if it was this skill fire the skill deactivation event""" + """ + Intent service deactivated a skill. If it was this skill, + emit a skill deactivation message. + @param message: `intent.service.skills.deactivated` Message + """ if message.data.get("skill_id") == self.skill_id: self.bus.emit(message.forward(f"{self.skill_id}.deactivate")) # method not present in mycroft-core def handle_deactivate(self, message): - """ skill is no longer considered active by the intent service - converse method will not be called, skills might want to reset state here + """ + Called when this skill is no longer considered active by the intent + service; converse method will not be called until skill is active again. + Override this method to do any optional cleanup. + @param message: `{self.skill_id}.deactivate` Message """ # named make_active in mycroft-core def _activate(self): - """Bump skill to active_skill list in intent_service. + """ + Mark this skill as active and push to the top of the active skills list. This enables converse method to be called even without skill being used in last 5 minutes. """ msg = dig_for_message() or Message("") if "skill_id" not in msg.context: msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward("intent.service.skills.activate", - data={"skill_id": self.skill_id})) + + m1 = msg.forward("intent.service.skills.activate", + data={"skill_id": self.skill_id}) + self.bus.emit(m1) + # backwards compat with mycroft-core - self.bus.emit(msg.forward("active_skill_request", - data={"skill_id": self.skill_id})) + # TODO - remove soon + m2 = msg.forward("active_skill_request", + data={"skill_id": self.skill_id}) + self.bus.emit(m2) # method not present in mycroft-core def _deactivate(self): - """remove skill from active_skill list in intent_service. - This stops converse method from being called + """ + Mark this skill as inactive and remove from the active skills list. + This stops converse method from being called. """ msg = dig_for_message() or Message("") if "skill_id" not in msg.context: @@ -729,10 +941,14 @@ def _deactivate(self): self.bus.emit(msg.forward(f"intent.service.skills.deactivate", data={"skill_id": self.skill_id})) - # method not present in mycroft-core - def _handle_converse_ack(self, message): - """Inform skills service if we want to handle converse. - individual skills may override the property self.converse_is_implemented""" + def _handle_converse_ack(self, message: Message): + """ + Inform skills service if we want to handle converse. Individual skills + may override the property self.converse_is_implemented to enable or + disable converse support. Note that this does not affect a skill's + `active` status. + @param message: `skill.converse.ping` Message + """ self.bus.emit(message.reply( "skill.converse.pong", data={"skill_id": self.skill_id, @@ -740,9 +956,11 @@ def _handle_converse_ack(self, message): context={"skill_id": self.skill_id})) # method not present in mycroft-core - def _handle_converse_request(self, message): - """Check if the targeted skill id can handle conversation - If supported, the conversation is invoked. + def _handle_converse_request(self, message: Message): + """ + If this skill is requested and supports converse, handle the user input + with `converse`. + @param message: `skill.converse.request` Message """ skill_id = message.data['skill_id'] if skill_id == self.skill_id: @@ -757,33 +975,27 @@ def _handle_converse_request(self, message): self.bus.emit(message.reply('skill.converse.response', {"skill_id": self.skill_id, "result": result})) - except Exception: + except Exception as e: + LOG.error(e) self.bus.emit(message.reply('skill.converse.response', {"skill_id": self.skill_id, "result": False})) - def converse(self, message=None): - """Handle conversation. - - This method gets a peek at utterances before the normal intent - handling process after a skill has been invoked once. - - To use, override the converse() method and return True to - indicate that the utterance has been handled. - - utterances and lang are depreciated - - Args: - message: a message object containing a message type with an - optional JSON data packet - - Returns: - bool: True if an utterance was handled, otherwise False + def converse(self, message: Optional[Message] = None) -> bool: + """ + Override to handle an utterance before intent parsing while this skill + is active. Active skills are called in order of most recently used to + least recently used until one handles the converse request. If no skill + handles an utterance in `converse`, then the utterance will continue to + normal intent parsing. + @param message: Message containing user utterances to optionally handle + @return: True if the utterance was handled, else False """ return False def __get_response(self): - """Helper to get a response from the user + """ + Helper to get a response from the user NOTE: There is a race condition here. There is a small amount of time between the end of the device speaking and the converse method @@ -797,7 +1009,7 @@ def __get_response(self): Returns: str: user's response or None on a timeout """ - + # TODO: Support `message` signature like default? def converse(utterances, lang=None): converse.response = utterances[0] if utterances else None converse.finished = True @@ -814,6 +1026,7 @@ def converse(utterances, lang=None): # AbortEvent exception to kill the thread start = time.time() while time.time() - start <= 15 and not converse.finished: + # TODO: Refactor to event-based handling time.sleep(0.1) if self.__response is not False: if self.__response is None: @@ -824,40 +1037,27 @@ def converse(utterances, lang=None): self.converse = self.__original_converse return converse.response - def get_response(self, dialog='', data=None, validator=None, - on_fail=None, num_retries=-1): - """Get response from user. - - If a dialog is supplied it is spoken, followed immediately by listening - for a user response. If the dialog is omitted listening is started - directly. - - The response can optionally be validated before returning. - - Example:: - - color = self.get_response('ask.favorite.color') - - Args: - dialog (str): Optional dialog to speak to the user - data (dict): Data used to render the dialog - validator (any): Function with following signature:: - - def validator(utterance): - return utterance != "red" - - on_fail (any): - Dialog or function returning literal string to speak on - invalid input. For example:: - - def on_fail(utterance): - return "nobody likes the color red, pick another" - - num_retries (int): Times to ask user for input, -1 for infinite - NOTE: User can not respond and timeout or say "cancel" to stop - - Returns: - str: User's reply or None if timed out or canceled + def get_response(self, dialog: str = '', data: Optional[dict] = None, + validator: Optional[Callable[[str], bool]] = None, + on_fail: Optional[Union[str, Callable[[str], str]]] = None, + num_retries: int = -1) -> Optional[str]: + """ + Get a response from the user. If a dialog is supplied it is spoken, + followed immediately by listening for a user response. If the dialog is + omitted, listening is started directly. The response may optionally be + validated before returning. + @param dialog: Optional dialog resource or string to speak + @param data: Optional data to render dialog with + @param validator: Optional method to validate user input with. Accepts + the user's utterance as an arg and returns True if it is valid. + @param on_fail: Optional string or method that accepts a failing + utterance and returns a string to be spoken when validation fails. + @param num_retries: Number of times to retry getting a user response; + -1 will retry infinitely. + * If the user asks to "cancel", this method will exit + * If the user doesn't respond and this is `-1` this will only retry + once. + @return: String user response (None if no valid response is given) """ data = data or {} @@ -889,37 +1089,42 @@ def validator_default(utterance): else: msg = dig_for_message() msg = msg.reply('mycroft.mic.listen') if msg else \ - Message('mycroft.mic.listen', context={"skill_id": self.skill_id}) + Message('mycroft.mic.listen', + context={"skill_id": self.skill_id}) self.bus.emit(msg) return self._wait_response(is_cancel, validator, on_fail_fn, num_retries) - def _wait_response(self, is_cancel, validator, on_fail, num_retries): - """Loop until a valid response is received from the user or the retry + def _wait_response(self, is_cancel: callable, validator: callable, + on_fail: callable, num_retries: int) -> Optional[str]: + """ + Loop until a valid response is received from the user or the retry limit is reached. - - Arguments: - is_cancel (callable): function checking cancel criteria - validator (callbale): function checking for a valid response - on_fail (callable): function handling retries - + @param is_cancel: Function that returns `True` if user asked to cancel + @param validator: Function that returns `True` if user input is valid + @param on_fail: Function to call if validator returns `False` + @param num_retries: Number of times to retry getting a response + @returns: User response if validated, else None """ self.__response = False self._real_wait_response(is_cancel, validator, on_fail, num_retries) while self.__response is False: + # TODO: Refactor to Event time.sleep(0.1) - return self.__response + return self.__response or None - # method not present in mycroft-core def _handle_killed_wait_response(self): + """ + Handle "stop" request when getting a response. + """ self.__response = None self.converse = self.__original_converse - # method not present in mycroft-core @killable_event("mycroft.skills.abort_question", exc=AbortQuestion, callback=_handle_killed_wait_response, react_to_stop=True) def _real_wait_response(self, is_cancel, validator, on_fail, num_retries): - """Loop until a valid response is received from the user or the retry + """ + Loop until a valid response is received from the user or the retry limit is reached. Arguments: @@ -972,18 +1177,15 @@ def _real_wait_response(self, is_cancel, validator, on_fail, num_retries): else: self.bus.emit(msg) - def ask_yesno(self, prompt, data=None): - """Read prompt and wait for a yes/no answer - - This automatically deals with translation and common variants, - such as 'yeah', 'sure', etc. - - Args: - prompt (str): a dialog id or string to read - data (dict): response data - Returns: - string: 'yes', 'no' or whatever the user response if not - one of those, including None + def ask_yesno(self, prompt: str, + data: Optional[dict] = None) -> Optional[str]: + """ + Read prompt and wait for a yes/no answer. This automatically deals with + translation and common variants, such as 'yeah', 'sure', etc. + @param prompt: a dialog id or string to read + @param data: optional data to render dialog with + @return: 'yes', 'no' or the user response if not matched to 'yes' or + 'no', including a response of None. """ resp = self.get_response(dialog=prompt, data=data) answer = yes_or_no(resp, lang=self.lang) if resp else resp @@ -994,9 +1196,11 @@ def ask_yesno(self, prompt, data=None): else: return resp - def ask_selection(self, options, dialog='', - data=None, min_conf=0.65, numeric=False): - """Read options, ask dialog question and wait for an answer. + def ask_selection(self, options: List[str], dialog: str = '', + data: Optional[dict] = None, min_conf: float = 0.65, + numeric: bool = False): + """ + Read options, ask dialog question and wait for an answer. This automatically deals with fuzzy matching and selection by number e.g. @@ -1047,8 +1251,31 @@ def ask_selection(self, options, dialog='', resp = match return resp - def voc_match(self, utt, voc_filename, lang=None, exact=False): - """Determine if the given utterance contains the vocabulary provided. + # method not present in mycroft-core + def _voc_list(self, voc_filename: str, + lang: Optional[str] = None) -> List[str]: + """ + Get list of vocab options for the requested resource and cache the + results for future references. + @param voc_filename: Name of vocab resource to get options for + @param lang: language to get vocab for (default self.lang) + @return: list of string vocab options + """ + lang = lang or self.lang + cache_key = lang + voc_filename + + if cache_key not in self._voc_cache: + vocab = self._resources.load_vocabulary_file(voc_filename) or \ + CoreResources(lang).load_vocabulary_file(voc_filename) + if vocab: + self._voc_cache[cache_key] = list(chain(*vocab)) + + return self._voc_cache.get(cache_key) or [] + + def voc_match(self, utt: str, voc_filename: str, lang: Optional[str] = None, + exact: bool = False): + """ + Determine if the given utterance contains the vocabulary provided. By default the method checks if the utterance contains the given vocab thereby allowing the user to say things like "yes, please" and still @@ -1070,26 +1297,23 @@ def voc_match(self, utt, voc_filename, lang=None, exact=False): bool: True if the utterance has the given vocabulary it """ match = False - lang = lang or self.lang - cache_key = lang + voc_filename - if cache_key not in self.voc_match_cache: - vocab = self._resources.load_vocabulary_file(voc_filename) or \ - CoreResources(lang).load_vocabulary_file(voc_filename) - self.voc_match_cache[cache_key] = list(chain(*vocab)) - if utt: + _vocs = self._voc_list(voc_filename, lang) + + if utt and _vocs: if exact: # Check for exact match match = any(i.strip() == utt - for i in self.voc_match_cache[cache_key]) + for i in _vocs) else: # Check for matches against complete words match = any([re.match(r'.*\b' + i + r'\b.*', utt) - for i in self.voc_match_cache[cache_key]]) + for i in _vocs]) return match - def report_metric(self, name, data): - """Report a skill metric to the Mycroft servers. + def report_metric(self, name: str, data: dict): + """ + Report a skill metric to the Mycroft servers. Args: name (str): Name of metric. Must use only letters and hyphens @@ -1099,10 +1323,11 @@ def report_metric(self, name, data): if Configuration().get('opt_in', False): MetricsApi().report_metric(name, data) except Exception as e: - LOG.error(f'Metric couldn\'t be uploaded, due to a network error ({e})') + self.log.error(f'Metric couldn\'t be uploaded, due to a network error ({e})') - def send_email(self, title, body): - """Send an email to the registered user's email. + def send_email(self, title: str, body: str): + """ + Send an email to the registered user's email. Args: title (str): Title of email @@ -1111,10 +1336,11 @@ def send_email(self, title, body): """ EmailApi().send_email(title, body, self.skill_id) - def _handle_collect_resting(self, message=None): - """Handler for collect resting screen messages. + def _handle_collect_resting(self, message: Optional[Message] = None): + """ + Handler for collect resting screen messages. - Sends info on how to trigger this skills resting page. + Sends info on how to trigger this skill's resting page. """ self.log.info('Registering resting screen') msg = message or Message("") @@ -1126,7 +1352,8 @@ def _handle_collect_resting(self, message=None): self.bus.emit(message) def register_resting_screen(self): - """Registers resting screen from the resting_screen_handler decorator. + """ + Registers resting screen from the resting_screen_handler decorator. This only allows one screen and if two is registered only one will be used. @@ -1149,12 +1376,13 @@ def register_resting_screen(self): break def _register_decorated(self): - """Register all intent handlers that are decorated with an intent. + """ + Register all intent handlers that are decorated with an intent. Looks for all functions that have been marked by a decorator and read the intent data from them. The intent handlers aren't the only decorators used. Skip properties as calling getattr on them - executes the code which may have unintended side-effects + executes the code which may have unintended side effects """ for attr_name in get_non_properties(self): method = getattr(self, attr_name) @@ -1166,8 +1394,10 @@ def _register_decorated(self): for intent_file in getattr(method, 'intent_files'): self.register_intent_file(intent_file, method) - def find_resource(self, res_name, res_dirname=None, lang=None): - """Find a resource file. + def find_resource(self, res_name: str, res_dirname: Optional[str] = None, + lang: Optional[str] = None): + """ + Find a resource file. Searches for the given filename using this scheme: 1. Search the resource lang directory: @@ -1198,8 +1428,11 @@ def find_resource(self, res_name, res_dirname=None, lang=None): f"'{lang}' not found in skill") # method not present in mycroft-core - def _on_event_start(self, message, handler_info, skill_data): - """Indicate that the skill handler is starting.""" + def _on_event_start(self, message: Message, handler_info: str, + skill_data: dict): + """ + Indicate that the skill handler is starting. + """ if handler_info: # Indicate that the skill handler is starting if requested msg_type = handler_info + '.start' @@ -1207,8 +1440,11 @@ def _on_event_start(self, message, handler_info, skill_data): self.bus.emit(message.forward(msg_type, skill_data)) # method not present in mycroft-core - def _on_event_end(self, message, handler_info, skill_data): - """Store settings and indicate that the skill handler has completed + def _on_event_end(self, message: Message, handler_info: str, + skill_data: dict): + """ + Store settings (if changed) and indicate that the skill handler has + completed. """ if self.settings != self._initial_settings: self.settings.store() @@ -1219,7 +1455,8 @@ def _on_event_end(self, message, handler_info, skill_data): self.bus.emit(message.forward(msg_type, skill_data)) # method not present in mycroft-core - def _on_event_error(self, error, message, handler_info, skill_data, speak_errors): + def _on_event_error(self, error: str, message: Message, handler_info: str, + skill_data: dict, speak_errors: bool): """Speak and log the error.""" # Convert "MyFancySkill" to "My Fancy Skill" for speaking handler_name = camel_case_split(self.name) @@ -1227,7 +1464,7 @@ def _on_event_error(self, error, message, handler_info, skill_data, speak_errors speech = get_dialog('skill.error', self.lang, msg_data) if speak_errors: self.speak(speech) - LOG.exception(error) + self.log.exception(error) # append exception information in message skill_data['exception'] = repr(error) if handler_info: @@ -1237,8 +1474,11 @@ def _on_event_error(self, error, message, handler_info, skill_data, speak_errors message.context["skill_id"] = self.skill_id self.bus.emit(message.forward(msg_type, skill_data)) - def add_event(self, name, handler, handler_info=None, once=False, speak_errors=True): - """Create event handler for executing intent or other event. + def add_event(self, name: str, handler: callable, + handler_info: Optional[str] = None, once: bool = False, + speak_errors: bool = True): + """ + Create event handler for executing intent or other event. Args: name (string): IntentParser name @@ -1255,10 +1495,11 @@ def add_event(self, name, handler, handler_info=None, once=False, speak_errors=T def on_error(error, message): if isinstance(error, AbortEvent): - LOG.info("Skill execution aborted") + self.log.info("Skill execution aborted") self._on_event_end(message, handler_info, skill_data) return - self._on_event_error(error, message, handler_info, skill_data, speak_errors) + self._on_event_error(error, message, handler_info, skill_data, + speak_errors) def on_start(message): self._on_event_start(message, handler_info, skill_data) @@ -1270,8 +1511,9 @@ def on_end(message): on_error) return self.events.add(name, wrapper, once) - def remove_event(self, name): - """Removes an event from bus emitter and events list. + def remove_event(self, name: str) -> bool: + """ + Removes an event from bus emitter and events list. Args: name (string): Name of Intent or Scheduler Event @@ -1280,8 +1522,11 @@ def remove_event(self, name): """ return self.events.remove(name) - def _register_adapt_intent(self, intent_parser, handler): - """Register an adapt intent. + def _register_adapt_intent(self, + intent_parser: Union[IntentBuilder, Intent, str], + handler: callable): + """ + Register an adapt intent. Args: intent_parser: Intent object to parse utterance for the handler. @@ -1307,8 +1552,10 @@ def _register_adapt_intent(self, intent_parser, handler): self.add_event(intent_parser.name, handler, 'mycroft.skill.handler') - def register_intent(self, intent_parser, handler): - """Register an Intent with the intent service. + def register_intent(self, intent_parser: Union[IntentBuilder, Intent, str], + handler: callable): + """ + Register an Intent with the intent service. Args: intent_parser: Intent, IntentBuilder object or padatious intent @@ -1325,7 +1572,7 @@ def register_intent(self, intent_parser, handler): return self._register_adapt_intent(intent_parser, handler) - def register_intent_file(self, intent_file, handler): + def register_intent_file(self, intent_file: str, handler: callable): """Register an Intent file with the intent service. For example: @@ -1352,7 +1599,8 @@ def register_intent_file(self, intent_file, handler): """ for lang in self._native_langs: name = f'{self.skill_id}:{intent_file}' - resource_file = ResourceFile(self._resources.types.intent, intent_file) + resources = self._load_lang(self.res_dir, lang) + resource_file = ResourceFile(resources.types.intent, intent_file) if resource_file.file_path is None: self.log.error(f'Unable to find "{intent_file}"') continue @@ -1361,8 +1609,9 @@ def register_intent_file(self, intent_file, handler): if handler: self.add_event(name, handler, 'mycroft.skill.handler') - def register_entity_file(self, entity_file): - """Register an Entity file with the intent service. + def register_entity_file(self, entity_file: str): + """ + Register an Entity file with the intent service. An Entity file lists the exact values that an entity can hold. For example: @@ -1379,32 +1628,39 @@ def register_entity_file(self, entity_file): if entity_file.endswith('.entity'): entity_file = entity_file.replace('.entity', '') for lang in self._native_langs: - entity = ResourceFile(self._resources.types.entity, entity_file) + resources = self._load_lang(self.res_dir, lang) + entity = ResourceFile(resources.types.entity, entity_file) if entity.file_path is None: self.log.error(f'Unable to find "{entity_file}"') continue filename = str(entity.file_path) - name = f"{self.skill_id}:{basename(entity_file)}_{md5(entity_file.encode('utf-8')).hexdigest()}" + name = f"{self.skill_id}:{basename(entity_file)}_" \ + f"{md5(entity_file.encode('utf-8')).hexdigest()}" self.intent_service.register_padatious_entity(name, filename, lang) - def handle_enable_intent(self, message): - """Listener to enable a registered intent if it belongs to this skill. + def handle_enable_intent(self, message: Message): + """ + Listener to enable a registered intent if it belongs to this skill. + @param message: `mycroft.skill.enable_intent` Message """ intent_name = message.data['intent_name'] for (name, _) in self.intent_service.detached_intents: if name == intent_name: return self.enable_intent(intent_name) - def handle_disable_intent(self, message): - """Listener to disable a registered intent if it belongs to this skill. + def handle_disable_intent(self, message: Message): + """ + Listener to disable a registered intent if it belongs to this skill. + @param message: `mycroft.skill.disable_intent` Message """ intent_name = message.data['intent_name'] for (name, _) in self.intent_service.registered_intents: if name == intent_name: return self.disable_intent(intent_name) - def disable_intent(self, intent_name): - """Disable a registered intent if it belongs to this skill. + def disable_intent(self, intent_name: str) -> bool: + """ + Disable a registered intent if it belongs to this skill. Args: intent_name (string): name of the intent to be disabled @@ -1413,7 +1669,7 @@ def disable_intent(self, intent_name): bool: True if disabled, False if it wasn't registered """ if intent_name in self.intent_service: - LOG.info('Disabling intent ' + intent_name) + self.log.info('Disabling intent ' + intent_name) name = f'{self.skill_id}:{intent_name}' self.intent_service.detach_intent(name) @@ -1423,11 +1679,12 @@ def disable_intent(self, intent_name): self.intent_service.detach_intent(lang_intent_name) return True else: - LOG.error(f'Could not disable {intent_name}, it hasn\'t been registered.') + self.log.error(f'Could not disable {intent_name}, it hasn\'t been registered.') return False - def enable_intent(self, intent_name): - """(Re)Enable a registered intent if it belongs to this skill. + def enable_intent(self, intent_name: str) -> bool: + """ + (Re)Enable a registered intent if it belongs to this skill. Args: intent_name: name of the intent to be enabled @@ -1442,14 +1699,15 @@ def enable_intent(self, intent_name): else: intent.name = intent_name self.register_intent(intent, None) - LOG.debug(f'Enabling intent {intent_name}') + self.log.debug(f'Enabling intent {intent_name}') return True else: - LOG.error(f'Could not enable {intent_name}, it hasn\'t been registered.') + self.log.error(f'Could not enable {intent_name}, it hasn\'t been registered.') return False - def set_context(self, context, word='', origin=''): - """Add context to intent service + def set_context(self, context: str, word: str = '', origin: str = ''): + """ + Add context to intent service Args: context: Keyword @@ -1464,21 +1722,37 @@ def set_context(self, context, word='', origin=''): context = self._alphanumeric_skill_id + context self.intent_service.set_adapt_context(context, word, origin) - def handle_set_cross_context(self, message): - """Add global context to intent service.""" + def remove_context(self, context: str): + """ + Remove a keyword from the context manager. + """ + if not isinstance(context, str): + raise ValueError('context should be a string') + context = self._alphanumeric_skill_id + context + self.intent_service.remove_adapt_context(context) + + def handle_set_cross_context(self, message: Message): + """ + Add global context to the intent service. + @param message: `mycroft.skill.set_cross_context` Message + """ context = message.data.get('context') word = message.data.get('word') origin = message.data.get('origin') self.set_context(context, word, origin) - def handle_remove_cross_context(self, message): - """Remove global context from intent service.""" + def handle_remove_cross_context(self, message: Message): + """ + Remove global context from the intent service. + @param message: `mycroft.skill.remove_cross_context` Message + """ context = message.data.get('context') self.remove_context(context) - def set_cross_skill_context(self, context, word=''): - """Tell all skills to add a context to intent service + def set_cross_skill_context(self, context: str, word: str = ''): + """ + Tell all skills to add a context to the intent service Args: context: Keyword @@ -1491,8 +1765,10 @@ def set_cross_skill_context(self, context, word=''): {'context': context, 'word': word, 'origin': self.skill_id})) - def remove_cross_skill_context(self, context): - """Tell all skills to remove a keyword from the context manager.""" + def remove_cross_skill_context(self, context: str): + """ + Tell all skills to remove a keyword from the context manager. + """ if not isinstance(context, str): raise ValueError('context should be a string') msg = dig_for_message() or Message("") @@ -1501,35 +1777,32 @@ def remove_cross_skill_context(self, context): self.bus.emit(msg.forward('mycroft.skill.remove_cross_context', {'context': context})) - def remove_context(self, context): - """Remove a keyword from the context manager.""" - if not isinstance(context, str): - raise ValueError('context should be a string') - context = self._alphanumeric_skill_id + context - self.intent_service.remove_adapt_context(context) - - def register_vocabulary(self, entity, entity_type, lang=None): - """ Register a word to a keyword - - Args: - entity: word to register - entity_type: Intent handler entity to tie the word to + def register_vocabulary(self, entity: str, entity_type: str, + lang: Optional[str] = None): + """ + Register a word to a keyword + @param entity: word to register + @param entity_type: Intent handler entity name to associate entity to + @param lang: language of `entity` (default self.lang) """ keyword_type = self._alphanumeric_skill_id + entity_type lang = lang or self.lang - self.intent_service.register_adapt_keyword(keyword_type, entity, lang=lang) + self.intent_service.register_adapt_keyword(keyword_type, entity, + lang=lang) - def register_regex(self, regex_str, lang=None): - """Register a new regex. - Args: - regex_str: Regex string + def register_regex(self, regex_str: str, lang: Optional[str] = None): + """ + Register a new regex. + @param regex_str: Regex string to add + @param lang: language of regex_str (default self.lang) """ self.log.debug('registering regex string: ' + regex_str) regex = munge_regex(regex_str, self.skill_id) re.compile(regex) # validate regex self.intent_service.register_adapt_regex(regex, lang=lang or self.lang) - def speak(self, utterance, expect_response=False, wait=False, meta=None): + def speak(self, utterance: str, expect_response: bool = False, + wait: bool = False, meta: Optional[dict] = None): """Speak a sentence. Args: @@ -1549,17 +1822,39 @@ def speak(self, utterance, expect_response=False, wait=False, meta=None): 'expect_response': expect_response, 'meta': meta, 'lang': self.lang} + + # grab message that triggered speech so we can keep context message = dig_for_message() m = message.forward("speak", data) if message \ else Message("speak", data) m.context["skill_id"] = self.skill_id + + # update any auto-translation metadata in message.context + if "translation_data" in meta: + tx_data = merge_dict(m.context.get("translation_data", {}), + meta["translation_data"]) + m.context["translation_data"] = tx_data + self.bus.emit(m) if wait: - wait_while_speaking() + sessid = SessionManager.get(m).session_id + event = Event() + + def handle_output_end(msg): + sess = SessionManager.get(msg) + if sessid == sess.session_id: + event.set() + + self.bus.on("recognizer_loop:audio_output_end", handle_output_end) + event.wait(timeout=15) + self.bus.remove("recognizer_loop:audio_output_end", + handle_output_end) - def speak_dialog(self, key, data=None, expect_response=False, wait=False): - """ Speak a random sentence from a dialog file. + def speak_dialog(self, key: str, data: Optional[dict] = None, + expect_response: bool = False, wait: bool = False): + """ + Speak a random sentence from a dialog file. Args: key (str): dialog file key (e.g. "hello" to speak from the file @@ -1583,8 +1878,10 @@ def speak_dialog(self, key, data=None, expect_response=False, wait=False): ) self.speak(key, expect_response, wait, {}) - def acknowledge(self): - """Acknowledge a successful request. + @staticmethod + def acknowledge(): + """ + Acknowledge a successful request. This method plays a sound to acknowledge a request that does not require a verbal response. This is intended to provide simple feedback @@ -1593,17 +1890,23 @@ def acknowledge(self): return play_acknowledge_sound() # method named init_dialog in mycroft-core - def load_dialog_files(self, root_directory=None): + def load_dialog_files(self, root_directory: Optional[str] = None): + """ + Load dialog files for all configured languages + @param root_directory: Directory to locate resources in + (default self.res_dir) + """ root_directory = root_directory or self.res_dir - # If "/dialog/" exists, load from there. Otherwise + # If "/dialog/" exists, load from there. Otherwise, # load dialog from "/locale/" for lang in self._native_langs: resources = self._load_lang(root_directory, lang) if resources.types.dialog.base_directory is None: self.log.debug(f'No dialog loaded for {lang}') - def load_data_files(self, root_directory=None): - """Called by the skill loader to load intents, dialogs, etc. + def load_data_files(self, root_directory: Optional[str] = None): + """ + Called by the skill loader to load intents, dialogs, etc. Args: root_directory (str): root folder to use when loading files. @@ -1613,7 +1916,7 @@ def load_data_files(self, root_directory=None): self.load_vocab_files(root_directory) self.load_regex_files(root_directory) - def load_vocab_files(self, root_directory=None): + def load_vocab_files(self, root_directory: Optional[str] = None): """ Load vocab files found under skill's root directory.""" root_directory = root_directory or self.res_dir for lang in self._native_langs: @@ -1654,63 +1957,87 @@ def __handle_stop(self, message): {"by": "skill:" + self.skill_id}, {"skill_id": self.skill_id})) except Exception as e: - LOG.exception(e) - LOG.error(f'Failed to stop skill: {self.skill_id}') + self.log.exception(f'Failed to stop skill: {self.skill_id}: {e}') def stop(self): - """Optional method implemented by subclass.""" + """ + Optional method implemented by subclass. Called when system or user + requests `stop` to cancel current execution. + """ pass def shutdown(self): - """Optional shutdown procedure implemented by subclass. + """ + Optional shutdown procedure implemented by subclass. This method is intended to be called during the skill process - termination. The skill implementation must shutdown all processes and + termination. The skill implementation must shut down all processes and operations in execution. """ pass def default_shutdown(self): - """Parent function called internally to shut down everything. - - Shuts down known entities and calls skill specific shutdown method. """ - self.settings_change_callback = None + Parent function called internally to shut down everything. + 1) Call skill.stop() to allow skill to clean up any active processes + 2) Store skill settings and remove file watchers + 3) Shutdown skill GUI to clear any active pages + 4) Shutdown the event_scheduler and remove any pending events + 5) Call skill.shutdown() to allow skill to do any other shutdown tasks + 6) Emit `detach_skill` Message to notify skill is shut down + """ - # Store settings - if self.settings != self._initial_settings: - self.settings.store() - if self._settings_meta: - self._settings_meta.stop() - if self._settings_watchdog: - self._settings_watchdog.shutdown() + try: + # Allow skill to handle `stop` actions before shutting things down + self.stop() + except Exception as e: + self.log.error(f'Failed to stop skill: {self.skill_id}: {e}', + exc_info=True) - # Clear skill from gui - if self.gui: - self.gui.shutdown() + try: + self.settings_change_callback = None + + # Store settings + if self.settings != self._initial_settings: + self.settings.store() + if self._settings_meta: + self._settings_meta.stop() + if self._settings_watchdog: + self._settings_watchdog.shutdown() + except Exception as e: + self.log.error(f"Failed to store settings for {self.skill_id}: {e}") - # removing events - if self.event_scheduler: - self.event_scheduler.shutdown() - self.events.clear() + try: + # Clear skill from gui + if self.gui: + self.gui.shutdown() + except Exception as e: + self.log.error(f"Failed to shutdown gui for {self.skill_id}: {e}") try: - self.stop() - except Exception: - LOG.error(f'Failed to stop skill: {self.skill_id}', exc_info=True) + # removing events + if self.event_scheduler: + self.event_scheduler.shutdown() + self.events.clear() + except Exception as e: + self.log.error(f"Failed to remove events for {self.skill_id}: {e}") try: self.shutdown() except Exception as e: - LOG.error(f'Skill specific shutdown function encountered an error: {e}') + self.log.error(f'Skill specific shutdown function encountered an ' + f'error: {e}') self.bus.emit( - Message('detach_skill', {'skill_id': str(self.skill_id) + ':'}, + Message('detach_skill', {'skill_id': f"{self.skill_id}:"}, {"skill_id": self.skill_id})) - def schedule_event(self, handler, when, data=None, name=None, - context=None): - """Schedule a single-shot event. + def schedule_event(self, handler: callable, + when: Union[int, float, datetime.datetime], + data: Optional[dict] = None, name: Optional[str] = None, + context: Optional[dict] = None): + """ + Schedule a single-shot event. Args: handler: method to be called @@ -1732,9 +2059,14 @@ def schedule_event(self, handler, when, data=None, name=None, return self.event_scheduler.schedule_event(handler, when, data, name, context=context) - def schedule_repeating_event(self, handler, when, frequency, - data=None, name=None, context=None): - """Schedule a repeating event. + def schedule_repeating_event(self, handler: callable, + when: Union[int, float, datetime.datetime], + frequency: Union[int, float], + data: Optional[dict] = None, + name: Optional[str] = None, + context: Optional[dict] = None): + """ + Schedule a repeating event. Args: handler: method to be called @@ -1752,34 +2084,31 @@ def schedule_repeating_event(self, handler, when, frequency, message = dig_for_message() context = context or message.context if message else {} context["skill_id"] = self.skill_id - return self.event_scheduler.schedule_repeating_event( - handler, - when, - frequency, - data, - name, - context=context - ) + self.event_scheduler.schedule_repeating_event(handler, when, frequency, + data, name, + context=context) - def update_scheduled_event(self, name, data=None): - """Change data of event. + def update_scheduled_event(self, name: str, data: Optional[dict] = None): + """ + Change data of event. Args: name (str): reference name of event (from original scheduling) data (dict): event data """ - return self.event_scheduler.update_scheduled_event(name, data) + self.event_scheduler.update_scheduled_event(name, data) - def cancel_scheduled_event(self, name): - """Cancel a pending event. The event will no longer be scheduled + def cancel_scheduled_event(self, name: str): + """ + Cancel a pending event. The event will no longer be scheduled to be executed Args: name (str): reference name of event (from original scheduling) """ - return self.event_scheduler.cancel_scheduled_event(name) + self.event_scheduler.cancel_scheduled_event(name) - def get_scheduled_event_status(self, name): + def get_scheduled_event_status(self, name: str) -> int: """Get scheduled event data and return the amount of time left Args: @@ -1794,5 +2123,26 @@ def get_scheduled_event_status(self, name): return self.event_scheduler.get_scheduled_event_status(name) def cancel_all_repeating_events(self): - """Cancel any repeating events started by the skill.""" - return self.event_scheduler.cancel_all_repeating_events() + """ + Cancel any repeating events started by the skill. + """ + self.event_scheduler.cancel_all_repeating_events() + + +class SkillGUI(GUIInterface): + def __init__(self, skill: BaseSkill): + """ + Wraps `GUIInterface` for use with a skill. + """ + self._skill = skill + skill_id = skill.skill_id + bus = skill.bus + config = skill.config_core.get('gui') + ui_directories = get_ui_directories(skill.root_dir) + GUIInterface.__init__(self, skill_id=skill_id, bus=bus, config=config, + ui_directories=ui_directories) + + @property + @deprecated("`skill` should not be referenced directly", "0.1.0") + def skill(self): + return self._skill diff --git a/ovos_workshop/skills/common_play.py b/ovos_workshop/skills/common_play.py index 031d8300..884569c2 100644 --- a/ovos_workshop/skills/common_play.py +++ b/ovos_workshop/skills/common_play.py @@ -1,11 +1,16 @@ from inspect import signature from threading import Event -from ovos_workshop.decorators.ocp import * from ovos_workshop.skills.ovos import OVOSSkill, MycroftSkill -from mycroft_bus_client import Message +from ovos_bus_client import Message from ovos_utils.log import LOG +# backwards compat imports, do not delete, skills import from here +from ovos_workshop.decorators.ocp import ocp_play, ocp_next, ocp_pause, ocp_resume, ocp_search, \ + ocp_previous, ocp_featured_media, MediaType, MediaState, MatchConfidence, \ + PlaybackType, PlaybackMode, PlayerState, LoopState, TrackState + + def get_non_properties(obj): """Get attibutes that are not properties from object. @@ -45,8 +50,7 @@ def ... vocab for starting playback is needed. """ - def __init__(self, name=None, bus=None): - super().__init__(name, bus) + def __init__(self, name=None, bus=None, **kwargs): # NOTE: derived skills will likely want to override this list self.supported_media = [MediaType.GENERIC, MediaType.AUDIO] @@ -61,7 +65,10 @@ def __init__(self, name=None, bus=None): self._stop_event = Event() self._playing = Event() # TODO replace with new default - self.skill_icon = "https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/raw/master/ovos_plugin_common_play/ocp/res/ui/images/ocp.png" + self.skill_icon = \ + "https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/raw/master/" \ + "ovos_plugin_common_play/ocp/res/ui/images/ocp.png" + OVOSSkill.__init__(self, name, bus, **kwargs) def bind(self, bus): """Overrides the normal bind method. diff --git a/ovos_workshop/skills/common_query_skill.py b/ovos_workshop/skills/common_query_skill.py new file mode 100644 index 00000000..9fdd052b --- /dev/null +++ b/ovos_workshop/skills/common_query_skill.py @@ -0,0 +1,250 @@ +# 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. + +from abc import abstractmethod +from enum import IntEnum +from os.path import dirname + +from ovos_utils.file_utils import resolve_resource_file +from ovos_utils.log import LOG +from ovos_workshop.skills.ovos import OVOSSkill, is_classic_core + + +class CQSMatchLevel(IntEnum): + EXACT = 1 # Skill could find a specific answer for the question + CATEGORY = 2 # Skill could find an answer from a category in the query + GENERAL = 3 # The query could be processed as a general quer + + +# Copy of CQSMatchLevel to use if the skill returns visual media +CQSVisualMatchLevel = IntEnum('CQSVisualMatchLevel', + [e.name for e in CQSMatchLevel]) + +"""these are for the confidence calculation""" +# how much each topic word is worth +# when found in the answer +TOPIC_MATCH_RELEVANCE = 5 + +# elevate relevance above all else +RELEVANCE_MULTIPLIER = 2 + +# we like longer articles but only so much +MAX_ANSWER_LEN_FOR_CONFIDENCE = 50 + +# higher number - less bias for word length +WORD_COUNT_DIVISOR = 100 + + +class CommonQuerySkill(OVOSSkill): + """Question answering skills should be based on this class. + + The skill author needs to implement `CQS_match_query_phrase` returning an + answer and can optionally implement `CQS_action` to perform additional + actions if the skill's answer is selected. + + This class works in conjunction with skill-query which collects + answers from several skills presenting the best one available. + """ + + def __init__(self, name=None, bus=None, **kwargs): + # these should probably be configurable + self.level_confidence = { + CQSMatchLevel.EXACT: 0.9, + CQSMatchLevel.CATEGORY: 0.6, + CQSMatchLevel.GENERAL: 0.5 + } + OVOSSkill.__init__(self, name, bus, **kwargs) + + noise_words_filepath = f"text/{self.lang}/noise_words.list" + default_res = f"{dirname(dirname(__file__))}/res/text/{self.lang}" \ + f"/noise_words.list" + noise_words_filename = \ + resolve_resource_file(noise_words_filepath, + config=self.config_core) or \ + resolve_resource_file(default_res, config=self.config_core) + + self._translated_noise_words = {} + if noise_words_filename: + with open(noise_words_filename) as f: + translated_noise_words = f.read().strip() + self._translated_noise_words[self.lang] = \ + translated_noise_words.split() + + @property + def translated_noise_words(self): + LOG.warning("self.translated_noise_words will become a private variable in next release") + return self._translated_noise_words.get(self.lang, []) + + @translated_noise_words.setter + def translated_noise_words(self, val): + LOG.warning("self.translated_noise_words will become a private variable in next release") + self._translated_noise_words[self.lang] = val + + def bind(self, bus): + """Overrides the default bind method of MycroftSkill. + + This registers messagebus handlers for the skill during startup + but is nothing the skill author needs to consider. + """ + if bus: + super().bind(bus) + self.add_event('question:query', self.__handle_question_query, speak_errors=False) + self.add_event('question:action', self.__handle_query_action, speak_errors=False) + + def __handle_question_query(self, message): + search_phrase = message.data["phrase"] + message.context["skill_id"] = self.skill_id + # First, notify the requestor that we are attempting to handle + # (this extends a timeout while this skill looks for a match) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": True})) + + result = self.__get_cq(search_phrase) + + if result: + match = result[0] + level = result[1] + answer = result[2] + callback = result[3] if len(result) > 3 else {} + confidence = self.__calc_confidence(match, search_phrase, level, answer) + callback["answer"] = answer # ensure we get it back in CQS_action + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "answer": answer, + "handles_speech": True, # signal we performed speech in the skill + "callback_data": callback, + "conf": confidence})) + else: + # Signal we are done (can't handle it) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": False})) + + def __get_cq(self, search_phrase): + # Now invoke the CQS handler to let the skill perform its search + try: + result = self.CQS_match_query_phrase(search_phrase) + except: + LOG.exception(f"error matching {search_phrase} with {self.skill_id}") + result = None + return result + + def remove_noise(self, phrase, lang=None): + """remove noise to produce essence of question""" + lang = lang or self.lang + phrase = ' ' + phrase + ' ' + for word in self._translated_noise_words.get(lang, []): + mtch = ' ' + word + ' ' + if phrase.find(mtch) > -1: + phrase = phrase.replace(mtch, " ") + phrase = ' '.join(phrase.split()) + return phrase.strip() + + def __calc_confidence(self, match, phrase, level, answer): + # Assume the more of the words that get consumed, the better the match + consumed_pct = len(match.split()) / len(phrase.split()) + if consumed_pct > 1.0: + consumed_pct = 1.0 + consumed_pct /= 10 + + # bonus for more sentences + num_sentences = float(float(len(answer.split("."))) / float(10)) + + # extract topic + topic = self.remove_noise(match) + + # calculate relevance + answer = answer.lower() + matches = 0 + for word in topic.split(' '): + if answer.find(word) > -1: + matches += TOPIC_MATCH_RELEVANCE + + answer_size = len(answer.split(" ")) + answer_size = min(MAX_ANSWER_LEN_FOR_CONFIDENCE, answer_size) + + relevance = 0.0 + if answer_size > 0: + relevance = float(float(matches) / float(answer_size)) + + relevance = relevance * RELEVANCE_MULTIPLIER + + # extra credit for more words up to a point + wc_mod = float(float(answer_size) / float(WORD_COUNT_DIVISOR)) * 2 + + confidence = self.level_confidence[level] + \ + consumed_pct + num_sentences + relevance + wc_mod + + return confidence + + def __handle_query_action(self, message): + """Message handler for question:action. + + Extracts phrase and data from message forward this to the skills + CQS_action method. + """ + if message.data["skill_id"] != self.skill_id: + # Not for this skill! + return + phrase = message.data["phrase"] + data = message.data.get("callback_data") or {} + if data.get("answer"): + # check core version, ovos-core does this speak call itself up to version 0.0.8a4 + core_speak = is_classic_core() + if not core_speak: + try: + from mycroft.version import OVOS_VERSION_MAJOR, OVOS_VERSION_MINOR, OVOS_VERSION_BUILD, OVOS_VERSIOM_ALPHA + if OVOS_VERSION_MAJOR == 0 and OVOS_VERSION_MINOR == 0 and OVOS_VERSION_BUILD < 8: + core_speak = True + elif OVOS_VERSION_MAJOR == 0 and OVOS_VERSION_MINOR == 0 and OVOS_VERSION_BUILD == 8 and \ + OVOS_VERSIOM_ALPHA < 5: + core_speak = True + except ImportError: + pass + if not core_speak: + self.speak(data["answer"]) + # Invoke derived class to provide playback data + self.CQS_action(phrase, data) + + @abstractmethod + def CQS_match_query_phrase(self, phrase): + """Analyze phrase to see if it is a answer-able phrase with this skill. + + Needs to be implemented by the skill. + + Args: + phrase (str): User phrase, "What is an aardwark" + + Returns: + (match, CQSMatchLevel[, callback_data]) or None: Tuple containing + a string with the appropriate matching phrase, the PlayMatch + type, and optionally data to return in the callback if the + match is selected. + """ + # Derived classes must implement this, e.g. + return None + + def CQS_action(self, phrase, data): + """Take additional action IF the skill is selected. + + The speech is handled by the common query but if the chosen skill + wants to display media, set a context or prepare for sending + information info over e-mail this can be implemented here. + + Args: + phrase (str): User phrase uttered after "Play", e.g. "some music" + data (dict): Callback data specified in match_query_phrase() + """ + # Derived classes may implement this if they use additional media + # or wish to set context after being called. + return None diff --git a/ovos_workshop/skills/decorators/__init__.py b/ovos_workshop/skills/decorators/__init__.py index dc89c06d..18588273 100644 --- a/ovos_workshop/skills/decorators/__init__.py +++ b/ovos_workshop/skills/decorators/__init__.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/decorators/converse.py b/ovos_workshop/skills/decorators/converse.py index 1c994fe9..b75b6224 100644 --- a/ovos_workshop/skills/decorators/converse.py +++ b/ovos_workshop/skills/decorators/converse.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators.converse import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/decorators/fallback_handler.py b/ovos_workshop/skills/decorators/fallback_handler.py index acfcefdc..003e994f 100644 --- a/ovos_workshop/skills/decorators/fallback_handler.py +++ b/ovos_workshop/skills/decorators/fallback_handler.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators.fallback_handler import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/decorators/killable.py b/ovos_workshop/skills/decorators/killable.py index e25241f8..d7de18d7 100644 --- a/ovos_workshop/skills/decorators/killable.py +++ b/ovos_workshop/skills/decorators/killable.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators.killable import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/decorators/layers.py b/ovos_workshop/skills/decorators/layers.py index 690a9368..2497168f 100644 --- a/ovos_workshop/skills/decorators/layers.py +++ b/ovos_workshop/skills/decorators/layers.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators.layers import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/decorators/ocp.py b/ovos_workshop/skills/decorators/ocp.py index f20850fd..32a563c5 100644 --- a/ovos_workshop/skills/decorators/ocp.py +++ b/ovos_workshop/skills/decorators/ocp.py @@ -1,2 +1,4 @@ from ovos_workshop.decorators.ocp import * # backwards compat import +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators", "0.1.0") diff --git a/ovos_workshop/skills/fallback.py b/ovos_workshop/skills/fallback.py index 23dda3b5..5bb80148 100644 --- a/ovos_workshop/skills/fallback.py +++ b/ovos_workshop/skills/fallback.py @@ -11,58 +11,107 @@ # 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. -# -"""The fallback skill implements a special type of skill handling -utterances not handled by the intent system. -""" + import operator +from typing import Optional, List, Callable, Tuple +from ovos_bus_client import MessageBusClient from ovos_utils.log import LOG -from ovos_utils.messagebus import get_handler_name +from ovos_utils.messagebus import get_handler_name, Message from ovos_utils.metrics import Stopwatch from ovos_utils.skills import get_non_properties -from ovos_workshop.skills.ovos import OVOSSkill +from ovos_config import Configuration from ovos_workshop.permissions import FallbackMode +from ovos_workshop.skills.ovos import OVOSSkill, is_classic_core + + +class _MutableFallback(type(OVOSSkill)): + """ To override isinstance checks we need to use a metaclass """ + + def __instancecheck__(self, instance): + if isinstance(instance, _MetaFB): + return True + return super().__instancecheck__(instance) + +class _MetaFB(OVOSSkill): + pass -class FallbackSkill(OVOSSkill): - """Fallbacks come into play when no skill matches an Adapt or closely with + +class FallbackSkill(_MetaFB, metaclass=_MutableFallback): + """ + Fallbacks come into play when no skill matches an Adapt or closely with a Padatious intent. All Fallback skills work together to give them a view of the user's utterance. Fallback handlers are called in an order - determined the priority provided when the the handler is registered. - - ======== ======== ================================================ - Priority Who? Purpose - ======== ======== ================================================ - 1-4 RESERVED Unused for now, slot for pre-Padatious if needed - 5 MYCROFT Padatious near match (conf > 0.8) - 6-88 USER General - 89 MYCROFT Padatious loose match (conf > 0.5) - 90-99 USER Uncaught intents - 100+ MYCROFT Fallback Unknown or other future use - ======== ======== ================================================ + determined the priority provided when the handler is registered. + + ======== =========================================================== + Priority Purpose + ======== =========================================================== + 0-4 High-priority fallbacks before medium-confidence Padatious + 5-89 Medium-priority fallbacks between medium and low Padatious + 90-100 Low-priority fallbacks after all other intent matches Handlers with the numerically lowest priority are invoked first. Multiple fallbacks can exist at the same priority, but no order is guaranteed. A Fallback can either observe or consume an utterance. A consumed - utterance will not be see by any other Fallback handlers. + utterance will not be seen by any other Fallback handlers. """ + def __new__(cls, *args, **kwargs): + if cls is FallbackSkill: + # direct instantiation of class, dynamic wizardry or unittests + # return V2 as expected, V1 will eventually be dropped + return FallbackSkillV2(*args, **kwargs) + + is_old = is_classic_core() + if not is_old: + try: + from mycroft.version import OVOS_VERSION_MAJOR, \ + OVOS_VERSION_MINOR, OVOS_VERSION_BUILD, OVOS_VERSION_ALPHA + if OVOS_VERSION_MAJOR == 0 and OVOS_VERSION_MINOR == 0 and \ + OVOS_VERSION_BUILD < 8: + is_old = True + elif OVOS_VERSION_MAJOR == 0 and OVOS_VERSION_MINOR == 0 and \ + OVOS_VERSION_BUILD == 8 and 0 < OVOS_VERSION_ALPHA < 5: + is_old = True + except ImportError: + pass + if is_old: + LOG.debug("Using V1 Fallback") + cls.__bases__ = (FallbackSkillV1, FallbackSkill, _MetaFB) + else: + LOG.debug("Using V2 Fallback") + cls.__bases__ = (FallbackSkillV2, FallbackSkill, _MetaFB) + return super().__new__(cls, *args, **kwargs) + + @classmethod + def make_intent_failure_handler(cls, bus: MessageBusClient): + """ + backwards compat, old version of ovos-core call this method to bind + the bus to old class + """ + return FallbackSkillV1.make_intent_failure_handler(bus) + + +class FallbackSkillV1(_MetaFB, metaclass=_MutableFallback): fallback_handlers = {} - wrapper_map = [] # Map containing (handler, wrapper) tuples + wrapper_map: List[Tuple[callable, callable]] = [] # [(handler, wrapper)] - def __init__(self, name=None, bus=None, use_settings=True): - super().__init__(name, bus, use_settings) + def __init__(self, name=None, bus=None, use_settings=True, **kwargs): # list of fallback handlers registered by this instance self.instance_fallback_handlers = [] + super().__init__(name, bus, use_settings, **kwargs) # "skill_id": priority (int) overrides self.fallback_config = self.config_core["skills"].get("fallbacks", {}) @classmethod - def make_intent_failure_handler(cls, bus): - """Goes through all fallback handlers until one returns True""" + def make_intent_failure_handler(cls, bus: MessageBusClient): + """ + Goes through all fallback handlers until one returns True + """ def handler(message): # No hard limit to 100, while not officially supported @@ -114,15 +163,16 @@ def handler(message): return handler @staticmethod - def _report_timing(ident, system, timing, additional_data=None): - """Create standardized message for reporting timing. - - Args: - ident (str): identifier of user interaction - system (str): system the that's generated the report - timing (stopwatch): Stopwatch object with recorded timing - additional_data (dict): dictionary with related data + def _report_timing(ident: str, system: str, timing: Stopwatch, + additional_data: Optional[dict] = None): """ + Create standardized message for reporting timing. + @param ident: identifier for user interaction + @param system: identifier for system being timed + @param timing: Stopwatch object with recorded timing + @param additional_data: Optional dict data to include with metric + """ + # TODO: Move to an imported function and deprecate this try: from mycroft.metrics import report_timing report_timing(ident, system, timing, additional_data) @@ -130,19 +180,13 @@ def _report_timing(ident, system, timing, additional_data=None): pass @classmethod - def _register_fallback(cls, handler, wrapper, priority): - """Register a function to be called as a general info fallback - Fallback should receive message and return - a boolean (True if succeeded or False if failed) - - Lower priority gets run first - 0 for high priority 100 for low priority - - Args: - handler (callable): original handler, used as a reference when - removing - wrapper (callable): wrapped version of handler - priority (int): fallback priority + def _register_fallback(cls, handler: callable, wrapper: callable, + priority: int): + """ + Add a fallback handler to the class + @param handler: original handler method used for reference + @param wrapper: wrapped handler used to handle fallback requests + @param priority: fallback priority """ while priority in cls.fallback_handlers: priority += 1 @@ -150,9 +194,14 @@ def _register_fallback(cls, handler, wrapper, priority): cls.fallback_handlers[priority] = wrapper cls.wrapper_map.append((handler, wrapper)) - def register_fallback(self, handler, priority): - """Register a fallback with the list of fallback handlers and with the - list of handlers registered by this instance + def register_fallback(self, handler: Callable[[Message], None], + priority: int): + """ + Register a fallback handler method with a given priority. This will + account for configuration overrides of fallback priority, as well as + configured fallback skill whitelist/blacklist. + @param handler: fallback handler method that accepts a `Message` arg + @param priority: fallback priority """ opmode = self.fallback_config.get("fallback_mode", FallbackMode.ACCEPT_ALL) @@ -180,14 +229,11 @@ def wrapper(*args, **kwargs): self._register_fallback(handler, wrapper, priority) @classmethod - def _remove_registered_handler(cls, wrapper_to_del): - """Remove a registered wrapper. - - Args: - wrapper_to_del (callable): wrapped handler to be removed - - Returns: - (bool) True if one or more handlers were removed, otherwise False. + def _remove_registered_handler(cls, wrapper_to_del: callable) -> bool: + """ + Remove a registered wrapper. + @param wrapper_to_del: wrapped handler to be removed + @return: True if one or more handlers were removed, otherwise False. """ found_handler = False for priority, handler in list(cls.fallback_handlers.items()): @@ -200,23 +246,22 @@ def _remove_registered_handler(cls, wrapper_to_del): return found_handler @classmethod - def remove_fallback(cls, handler_to_del): - """Remove a fallback handler. - - Args: - handler_to_del: reference to handler - Returns: - (bool) True if at least one handler was removed, otherwise False + def remove_fallback(cls, handler_to_del: callable) -> bool: + """ + Remove a fallback handler. + @param handler_to_del: registered callback handler (or wrapped handler) + @return: True if at least one handler was removed, otherwise False """ # Find wrapper from handler or wrapper wrapper_to_del = None for h, w in cls.wrapper_map: if handler_to_del in (h, w): + handler_to_del = h wrapper_to_del = w break if wrapper_to_del: - cls.wrapper_map.remove((h, w)) + cls.wrapper_map.remove((handler_to_del, wrapper_to_del)) remove_ok = cls._remove_registered_handler(wrapper_to_del) else: LOG.warning('Could not find matching fallback handler') @@ -224,24 +269,174 @@ def remove_fallback(cls, handler_to_del): return remove_ok def remove_instance_handlers(self): - """Remove all fallback handlers registered by the fallback skill.""" - self.log.info('Removing all handlers...') + """ + Remove all fallback handlers registered by the fallback skill. + """ + LOG.info('Removing all handlers...') while len(self.instance_fallback_handlers): handler = self.instance_fallback_handlers.pop() self.remove_fallback(handler) def default_shutdown(self): - """Remove all registered handlers and perform skill shutdown.""" + """ + Remove all registered handlers and perform skill shutdown. + """ self.remove_instance_handlers() - super(FallbackSkill, self).default_shutdown() + super().default_shutdown() def _register_decorated(self): - """Register all intent handlers that are decorated with an intent. + """ + Register all decorated fallback handlers. + + Looks for all functions that have been marked by a decorator + and read the fallback priority from them. The handlers aren't the + only decorators used. Skip properties as calling getattr on them + executes the code which may have unintended side effects. + """ + super()._register_decorated() + for attr_name in get_non_properties(self): + method = getattr(self, attr_name) + if hasattr(method, 'fallback_priority'): + self.register_fallback(method, method.fallback_priority) + + +class FallbackSkillV2(_MetaFB, metaclass=_MutableFallback): + # "skill_id": priority (int) overrides + fallback_config = Configuration().get("skills", {}).get("fallbacks", {}) + + @classmethod + def make_intent_failure_handler(cls, bus: MessageBusClient): + """ + backwards compat, old version of ovos-core call this method to bind + the bus to old class + """ + return FallbackSkillV1.make_intent_failure_handler(bus) + + def __init__(self, bus=None, skill_id="", **kwargs): + self._fallback_handlers = [] + super().__init__(bus=bus, skill_id=skill_id, **kwargs) + + @property + def priority(self) -> int: + """ + Get this skill's minimum priority. Priority is determined as: + 1) Configured fallback skill priority + 2) Highest fallback handler priority + 3) Default `101` (no fallback handlers are registered) + """ + priority_overrides = self.fallback_config.get("fallback_priorities", {}) + if self.skill_id in priority_overrides: + return priority_overrides.get(self.skill_id) + if len(self._fallback_handlers): + return min([p[0] for p in self._fallback_handlers]) + return 101 + + def can_answer(self, utterances: List[str], lang: str) -> bool: + """ + Check if the skill can answer the particular question. Override this + method to validate whether a query can possibly be handled. By default, + assumes a skill can answer if it has any registered handlers + @param utterances: list of possible transcriptions to parse + @param lang: BCP-47 language code associated with utterances + @return: True if skill can handle the query + """ + return len(self._fallback_handlers) > 0 + + def _register_system_event_handlers(self): + """ + Register messagebus event handlers and emit a message to register this + fallback skill. + """ + super()._register_system_event_handlers() + self.add_event('ovos.skills.fallback.ping', self._handle_fallback_ack, + speak_errors=False) + self.add_event(f"ovos.skills.fallback.{self.skill_id}.request", + self._handle_fallback_request, speak_errors=False) + self.bus.emit(Message("ovos.skills.fallback.register", + {"skill_id": self.skill_id, + "priority": self.priority})) + + def _handle_fallback_ack(self, message: Message): + """ + Inform skills service we can handle fallbacks. + """ + utts = message.data.get("utterances", []) + lang = message.data.get("lang") + self.bus.emit(message.reply( + "ovos.skills.fallback.pong", + data={"skill_id": self.skill_id, + "can_handle": self.can_answer(utts, lang)}, + context={"skill_id": self.skill_id})) + + def _handle_fallback_request(self, message: Message): + """ + Handle a fallback request, calling any registered handlers in priority + order until one is successful. emits a response indicating whether the + request was handled. + @param message: `ovos.skills.fallback..request` message + """ + # indicate fallback handling start + self.bus.emit(message.forward( + f"ovos.skills.fallback.{self.skill_id}.start")) + + handler_name = None + + # each skill can register multiple handlers with different priorities + sorted_handlers = sorted(self._fallback_handlers, + key=operator.itemgetter(0)) + for prio, handler in sorted_handlers: + try: + if handler(message): + # indicate completion + status = True + handler_name = get_handler_name(handler) + break + except Exception: + LOG.exception('Exception in fallback.') + else: + status = False + + self.bus.emit(message.forward( + f"ovos.skills.fallback.{self.skill_id}.response", + data={"result": status, "fallback_handler": handler_name})) + + def register_fallback(self, handler: callable, priority: int): + """ + Register a fallback handler and add a messagebus handler to call it on + any fallback request. + @param handler: Fallback handler + @param priority: priority of the registered handler + """ + + LOG.info(f"registering fallback handler -> " + f"ovos.skills.fallback.{self.skill_id}") + + def wrapper(*args, **kwargs): + if handler(*args, **kwargs): + self.activate() + return True + return False + + self._fallback_handlers.append((priority, wrapper)) + self.bus.on(f"ovos.skills.fallback.{self.skill_id}", wrapper) + + def default_shutdown(self): + """ + Remove all registered handlers and perform skill shutdown. + """ + self.bus.emit(Message("ovos.skills.fallback.deregister", + {"skill_id": self.skill_id})) + self.bus.remove_all_listeners(f"ovos.skills.fallback.{self.skill_id}") + super().default_shutdown() + + def _register_decorated(self): + """ + Register all decorated fallback handlers. Looks for all functions that have been marked by a decorator - and read the intent data from them. The intent handlers aren't the - only decorators used. Skip properties as calling getattr on them - executes the code which may have unintended side-effects + and read the fallback priority from them. The handlers aren't the + only decorators used. Skip properties as calling getattr on them + executes the code which may have unintended side effects. """ super()._register_decorated() for attr_name in get_non_properties(self): diff --git a/ovos_workshop/skills/idle_display_skill.py b/ovos_workshop/skills/idle_display_skill.py index d239659c..7f680482 100644 --- a/ovos_workshop/skills/idle_display_skill.py +++ b/ovos_workshop/skills/idle_display_skill.py @@ -11,68 +11,76 @@ # 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. -"""Provide a common API for skills that define idle screen functionality. - -The idle display should show when no other skill is using the display. Some -skills use the display for a defined period of time before returning to the -idle display (e.g. Weather Skill). Some skills take control of the display -indefinitely (e.g. Timer Skill). - -The display could be a touch screen (such as on the Mark II), or an -Arduino LED array (such as on the Mark I), or any other type of display. This -base class is meant to be agnostic to the type of display, with the -implementation details defined within the skill that uses this as a base class. -""" -from ovos_utils.log import LOG + +from ovos_utils.log import LOG, log_deprecation from ovos_utils.messagebus import Message -from ovos_workshop.skills.mycroft_skill import MycroftSkill +from ovos_workshop.skills.base import BaseSkill -class IdleDisplaySkill(MycroftSkill): - """Base class for skills that define an idle display. +class IdleDisplaySkill(BaseSkill): + """ + Base class for skills that define an idle display. An idle display is what shows on a device's screen when it is not in use - by other skills. For example, Mycroft's Home Screen Skill. + by other skills. i.e. a Home Screen skill. + + The idle display should show when no other skill is using the display. Some + skills use the display for a defined period of time before returning to the + idle display (e.g. Weather Skill). Some skills take control of the display + indefinitely (e.g. Timer Skill). + + The display could be a touch screen (such as on the Mark II), or an + Arduino LED array (such as on the Mark I), or any other type of display. + This base class is meant to be agnostic to the type of display. """ def __init__(self, *args, **kwargs): - super(IdleDisplaySkill, self).__init__(*args, **kwargs) + BaseSkill.__init__(self, *args, **kwargs) self._homescreen_entry = None - def bind(self, bus): - """Tasks to complete during skill load but after bus initialization.""" - if bus: - super().bind(bus) - self._define_message_bus_handlers() - self._build_homescreen_entry() - def handle_idle(self): - """Override this method to display the idle screen.""" + """ + Override this method to display the idle screen. + """ raise NotImplementedError( - "Subclass must override the handle_idle method" - ) + "Subclass must override the handle_idle method") - def _define_message_bus_handlers(self): - """Defines the bus events handled in this skill and their handlers.""" + def _register_system_event_handlers(self): + """ + Defines the bus events handled in this skill and their handlers. + """ + BaseSkill._register_system_event_handlers(self) self.add_event("mycroft.ready", self._handle_mycroft_ready) - self.add_event("homescreen.manager.activate.display", self._display_homescreen_requested) - self.add_event("homescreen.manager.reload.list", self._reload_homescreen_entry) - self.add_event("mycroft.skills.shutdown", self._remove_homescreen_on_shutdown) + self.add_event("homescreen.manager.activate.display", + self._display_homescreen_requested) + self.add_event("homescreen.manager.reload.list", + self._reload_homescreen_entry) + self.add_event("mycroft.skills.shutdown", + self._remove_homescreen_on_shutdown) + self._build_homescreen_entry() def _handle_mycroft_ready(self, message): - """Shows idle screen when device is ready for use.""" + """ + Shows idle screen when device is ready for use. + """ self._show_idle_screen() self.bus.emit(message.reply("skill.idle.displayed")) LOG.debug("Homescreen ready") def _show_idle_screen(self): - """Method for compat with mycroft-core mark2/qa branch equivalent class - Skills made for mk2 will override this private method instead of the public handle_idle """ + Backwards-compat method for pre-Dinkum Mycroft Mark2 skills + """ + log_deprecation("Call `self.handle_idle()` directly", "0.1.0") self.handle_idle() - def _build_homescreen_entry(self, message=None): + def _build_homescreen_entry(self, message: Message = None): + """ + Update the internal _homescreen_entry object + for this skill and send it to the Home Screen Manager. + @param message: optional Message associated with request + """ # get the super class this inherits from super_class_name = "IdleDisplaySkill" super_class_object = self.__class__.__name__ @@ -81,27 +89,46 @@ def _build_homescreen_entry(self, message=None): "id": self.skill_id} self._add_available_homescreen(message) - def _add_available_homescreen(self, message=None): + def _add_available_homescreen(self, message: Message = None): + """ + Add this skill's homescreen_entry to the Home Screen Manager. + @param message: optional Message associated with request + """ message = message or Message("homescreen.manager.reload.list") LOG.debug(f"Registering Homescreen {self._homescreen_entry}") msg = message.forward("homescreen.manager.add", self._homescreen_entry) self.bus.emit(msg) - def _remove_homescreen(self, message): + def _remove_homescreen(self, message: Message): + """ + Remove this skill's homescreen_entry from the Home Screen Manager + @param message: `mycroft.skills.shutdown` message + """ LOG.debug(f"Requesting removal of {self._homescreen_entry}") msg = message.forward("homescreen.manager.remove", self._homescreen_entry) self.bus.emit(msg) - def _reload_homescreen_entry(self, message): + def _reload_homescreen_entry(self, message: Message): + """ + Reload this skill's homescreen_entry and send it to the + Home Screen Manager. + @param message: `homescreen.manager.reload.list` message + """ self._build_homescreen_entry(message) - def _remove_homescreen_on_shutdown(self, message): - shutdown_for_id = message.data["id"] - if shutdown_for_id == self.skill_id: + def _remove_homescreen_on_shutdown(self, message: Message): + """ + Remove this homescreen from the Home Screen Manager if requested + @param message: `mycroft.skills.shutdown` message + """ + if message.data["id"] == self.skill_id: self._remove_homescreen(message) - def _display_homescreen_requested(self, message): - request_for_id = message.data["homescreen_id"] - if request_for_id == self.skill_id: + def _display_homescreen_requested(self, message: Message): + """ + Display this home screen if requested by the Home Screen Manager + @param message: `homescreen.manager.activate.display` message + """ + if message.data["homescreen_id"] == self.skill_id: self._show_idle_screen() self.bus.emit(message.reply("skill.idle.displayed")) diff --git a/ovos_workshop/skills/intent_provider.py b/ovos_workshop/skills/intent_provider.py index 5a230669..f85a389e 100644 --- a/ovos_workshop/skills/intent_provider.py +++ b/ovos_workshop/skills/intent_provider.py @@ -1,14 +1,14 @@ from threading import Event from time import time as get_time, sleep -from ovos_utils.log import LOG +from ovos_utils.log import LOG, log_deprecation from ovos_utils.messagebus import Message -from ovos_workshop.skills.ovos import OVOSFallbackSkill +from ovos_workshop.skills.fallback import FallbackSkill from ovos_config.config import read_mycroft_config, update_mycroft_config class BaseIntentEngine: - # TODO move to OPM def __init__(self, name, config=None): + log_deprecation("This base class is not supported", "0.1.0") self.name = name.lower() config = config or read_mycroft_config() self.config = config.get(self.name, {}) @@ -49,8 +49,9 @@ def calc_intent(self, query): return data -class IntentEngineSkill(OVOSFallbackSkill): +class IntentEngineSkill(FallbackSkill): def __init__(self, *args, **kwargs): + log_deprecation("This base class is not supported", "0.1.0") super().__init__(*args, **kwargs) self.engine = None self.config = {} diff --git a/ovos_workshop/skills/layers.py b/ovos_workshop/skills/layers.py index 32a9f766..dcedc619 100644 --- a/ovos_workshop/skills/layers.py +++ b/ovos_workshop/skills/layers.py @@ -1 +1,3 @@ from ovos_workshop.decorators.layers import IntentLayers +from ovos_utils.log import log_deprecation +log_deprecation("Import from `ovos_workshop.decorators.layers`", "0.1.0") diff --git a/ovos_workshop/skills/mycroft_skill.py b/ovos_workshop/skills/mycroft_skill.py index 8b60d1cb..c7cfcd2f 100644 --- a/ovos_workshop/skills/mycroft_skill.py +++ b/ovos_workshop/skills/mycroft_skill.py @@ -11,65 +11,129 @@ # 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. -# -"""Common functionality relating to the implementation of mycroft skills.""" import shutil + from abc import ABCMeta from os.path import join, exists +from typing import Optional -from ovos_config.locations import get_xdg_config_save_path -from ovos_utils.log import LOG +from ovos_bus_client import MessageBusClient, Message +from ovos_utils.log import LOG, log_deprecation +from ovos_workshop.skills.base import BaseSkill, is_classic_core -from ovos_workshop.skills.base import BaseSkill +class _SkillMetaclass(ABCMeta): + """ + This metaclass ensures we can load skills like regular python objects. + mycroft-core required a skill loader helper class, which created the skill + and then finished object init. This meant skill_id and bus were not + available in init method, so mycroft introduced a method named `initialize` + that was called after `skill_id` and `bus` were defined. -def is_classic_core(): - """ Check if the current core is the classic mycroft-core """ - try: - from mycroft.version import OVOS_VERSION_STR - return False - except ImportError: - return True + To make skills pythonic and standalone, this metaclass is used to auto init + old skills and help in migrating to new standards. + To override isinstance checks we also need to use a metaclass -class _SkillMetaclass(ABCMeta): - """ To override isinstance checks we need to use a metaclass """ + TODO: remove compat ovos-core 0.2.0, including MycroftSkill class + """ + + def __call__(cls, *args, **kwargs): + from ovos_bus_client import MessageBusClient + from ovos_utils.messagebus import FakeBus + bus = None + skill_id = None + + if "bus" not in kwargs: + for a in args: + if isinstance(a, MessageBusClient) or isinstance(a, FakeBus): + bus = a + LOG.warning( + f"bus should be a kwarg, guessing {a} is the bus") + break + else: + LOG.warning("skill initialized without bus!! this is legacy " + "behaviour and requires you to call skill.bind(bus)" + " or skill._startup(skill_id, bus)\n" + "bus will be required starting on ovos-core 0.1.0") + return super().__call__(*args, **kwargs) + + if "skill_id" in kwargs: + skill_id = kwargs.pop("skill_id") + if "bus" in kwargs: + bus = kwargs.pop("bus") + if not skill_id: + LOG.warning(f"skill_id should be a kwarg, please update " + f"{cls.__name__}") + if args and isinstance(args[0], str): + a = args[0] + if a[0].isupper(): + # in mycroft name is CamelCase by convention, not skill_id + LOG.debug(f"ambiguous skill_id, ignoring {a} as it appears " + f"to be a CamelCase name") + else: + LOG.warning(f"ambiguous skill_id, assuming positional " + f"argument: {a}") + skill_id = a + + if not skill_id: + LOG.warning("skill initialized without bus!! this is legacy " + "behaviour and requires you to call skill.bind(bus)" + " or skill._startup(skill_id, bus)\n" + "bus will be required starting on ovos-core 0.1.0") + return super().__call__(*args, **kwargs) + + # by convention skill_id is the folder name + # usually repo.author + # TODO - uncomment once above is deprecated + # skill_id = dirname(inspect.getfile(cls)).split("/")[-1] + # LOG.warning(f"missing skill_id, assuming folder name " + # f"convention: {skill_id}") + + try: + # skill follows latest best practices, + # accepts kwargs and does its own init + return super().__call__(skill_id=skill_id, bus=bus, **kwargs) + except TypeError: + LOG.warning(f"Legacy skill signature detected for {skill_id};" + f" attempting to init skill manually, self.bus and " + f"self.skill_id will only be available in " + f"self.initialize. `__init__` method needs to accept " + f"`skill_id` and `bus` to resolve this.") + + # skill did not update its init method, init it manually + # NOTE: no try/except because all skills must accept this initialization + # this is what skill loader does internally + skill = super().__call__(*args, **kwargs) + skill._startup(bus, skill_id) + return skill def __instancecheck__(self, instance): if is_classic_core(): # instance imported from vanilla mycroft - try: - from mycroft.skills import MycroftSkill as _CoreSkill - if issubclass(self.__class__, _CoreSkill): - return True - except ImportError: - # not running in core - standalone skill - pass - - # instance imported from workshop - # we can not patch mycroft-core class to make isinstance return True + from mycroft.skills import MycroftSkill as _CoreSkill + if issubclass(self.__class__, _CoreSkill): + return True return super().__instancecheck__(instance) class MycroftSkill(BaseSkill, metaclass=_SkillMetaclass): - """Base class for mycroft skills providing common behaviour and parameters - to all Skill implementations. - - For information on how to get started with creating mycroft skills see - https://mycroft.ai/documentation/skills/introduction-developing-skills/ - - New methods added here are always private, public apis for Skill class are added in OVOSSkill - This is done to ensure no syntax errors when a MycroftSkill object comes from mycroft-core - - Args: - name (str): skill name - bus (MycroftWebsocketClient): Optional bus connection - use_settings (bool): Set to false to not use skill settings at all (DEPRECATED) + """ + Base class for mycroft skills providing common behaviour and parameters + to all Skill implementations. This class is kept for backwards-compat. It is + recommended to implement `OVOSSkill` to properly implement new methods. """ - def __init__(self, name=None, bus=None, use_settings=True, *args, **kwargs): + def __init__(self, name: str = None, bus: MessageBusClient = None, + use_settings: bool = True, *args, **kwargs): + """ + Create a MycroftSkill object. + @param name: DEPRECATED skill_name + @param bus: MessageBusClient to bind to skill + @param use_settings: DEPRECATED option to disable settings sync + """ super().__init__(name=name, bus=bus, *args, **kwargs) self._initial_settings = {} @@ -78,50 +142,67 @@ def __init__(self, name=None, bus=None, use_settings=True, *args, **kwargs): # old kludge from fallback skills, unused according to grep if use_settings is False: - LOG.warning("use_settings has been deprecated! skill settings are always enabled") + log_deprecation("use_settings has been deprecated! " + "skill settings are always enabled", "0.1.0") if is_classic_core(): self.settings_write_path = self.root_dir def _init_settings_manager(self): - try: - from mycroft.skills.settings import SkillSettingsManager - from mycroft.deprecated.skills.settings import SettingsMetaUploader - self.settings_manager = SkillSettingsManager(self) - # backwards compat - self.settings_meta has been deprecated in favor of settings manager - self._settings_meta = SettingsMetaUploader(self.root_dir, self.skill_id) - except ImportError: - pass + super()._init_settings_manager() + # backwards compat - self.settings_meta has been deprecated + # in favor of settings manager + if is_classic_core(): + from mycroft.skills.settings import SettingsMetaUploader + else: + try: # ovos-core compat layer + from mycroft.deprecated.skills.settings import \ + SettingsMetaUploader + self._settings_meta = SettingsMetaUploader(self.root_dir, + self.skill_id) + except ImportError: + pass # standalone skill, skip backwards compat property def _init_settings(self): """Setup skill settings.""" - # migrate settings if needed - if not exists(self._settings_path) and exists(self._old_settings_path): - LOG.warning("Found skill settings at pre-xdg location, migrating!") - shutil.copy(self._old_settings_path, self._settings_path) - LOG.info(f"{self._old_settings_path} moved to {self._settings_path}") + if is_classic_core(): + # migrate settings if needed + if not exists(self._settings_path) and \ + exists(self._old_settings_path): + LOG.warning("Found skill settings at pre-xdg location, " + "migrating!") + shutil.copy(self._old_settings_path, self._settings_path) + LOG.info(f"{self._old_settings_path} moved to " + f"{self._settings_path}") super()._init_settings() # renamed in base class for naming consistency - def init_dialog(self, root_directory=None): - """ DEPRECATED: use load_dialog_files instead """ + def init_dialog(self, root_directory: Optional[str] = None): + """ + DEPRECATED: use load_dialog_files instead + """ + log_deprecation("Use `load_dialog_files`", "0.1.0") self.load_dialog_files(root_directory) # renamed in base class for naming consistency def make_active(self): - """Bump skill to active_skill list in intent_service. + """ + Bump skill to active_skill list in intent_service. This enables converse method to be called even without skill being used in last 5 minutes. - deprecated: use self.activate() instead + deprecated: use self._activate() instead """ + log_deprecation("Use `_activate`", "0.1.0") self._activate() # patched due to functional (internal) differences under mycroft-core - def _on_event_end(self, message, handler_info, skill_data): - """Store settings and indicate that the skill handler has completed + def _on_event_end(self, message: Message, handler_info: str, + skill_data: dict): + """ + Store settings and indicate that the skill handler has completed """ if not is_classic_core(): return super()._on_event_end(message, handler_info, skill_data) @@ -133,7 +214,7 @@ def _on_event_end(self, message, handler_info, skill_data): save_settings(self.settings_write_path, self.settings) self._initial_settings = dict(self.settings) except Exception as e: - LOG.exception("Failed to save skill settings") + LOG.exception(f"Failed to save skill settings: {e}") if handler_info: msg_type = handler_info + '.complete' message.context["skill_id"] = self.skill_id @@ -141,47 +222,61 @@ def _on_event_end(self, message, handler_info, skill_data): # renamed in base class for naming consistency # refactored to use new resource utils - def translate(self, text, data=None): - """Deprecated method for translating a dialog file. - use self._resources.render_dialog(text, data) instead""" + def translate(self, text: str, data: Optional[dict] = None): + """ + Deprecated method for translating a dialog file. + use self._resources.render_dialog(text, data) instead + """ + log_deprecation("Use `_resources.render_dialog`", "0.1.0") return self._resources.render_dialog(text, data) # renamed in base class for naming consistency # refactored to use new resource utils - def translate_namedvalues(self, name, delim=','): - """Deprecated method for translating a name/value file. - use elf._resources.load_named_value_filetext, data) instead""" + def translate_namedvalues(self, name: str, delim: str = ','): + """ + Deprecated method for translating a name/value file. + use self._resources.load_named_value_filetext, data) instead + """ + log_deprecation("Use `_resources.load_named_value_file`", "0.1.0") return self._resources.load_named_value_file(name, delim) # renamed in base class for naming consistency # refactored to use new resource utils - def translate_list(self, list_name, data=None): - """Deprecated method for translating a list. - use delf._resources.load_list_file(text, data) instead""" + def translate_list(self, list_name: str, data: Optional[dict] = None): + """ + Deprecated method for translating a list. + use delf._resources.load_list_file(text, data) instead + """ + log_deprecation("Use `_resources.load_list_file`", "0.1.0") return self._resources.load_list_file(list_name, data) # renamed in base class for naming consistency # refactored to use new resource utils - def translate_template(self, template_name, data=None): - """Deprecated method for translating a template file - use delf._resources.template_file(text, data) instead""" + def translate_template(self, template_name: str, + data: Optional[dict] = None): + """ + Deprecated method for translating a template file + use delf._resources.template_file(text, data) instead + """ + log_deprecation("Use `_resources.template_file`", "0.1.0") return self._resources.load_template_file(template_name, data) # refactored - backwards compat + log warnings @property def settings_meta(self): - LOG.warning("self.settings_meta has been deprecated! please use self.settings_manager instead") + log_deprecation("Use `self.settings_manager`", "0.1.0") return self._settings_meta # refactored - backwards compat + log warnings @settings_meta.setter def settings_meta(self, val): - LOG.warning("self.settings_meta has been deprecated! please use self.settings_manager instead") + log_deprecation("Use `self.settings_manager`", "0.1.0") self._settings_meta = val # internal - deprecated under ovos-core @property def _old_settings_path(self): + log_deprecation("This path is no longer used", "0.1.0") old_dir = self.config_core.get("data_dir") or "/opt/mycroft" old_folder = self.config_core.get("skills", {}).get("msm", {}) \ .get("directory") or "skills" @@ -190,8 +285,10 @@ def _old_settings_path(self): # patched due to functional (internal) differences under mycroft-core @property def _settings_path(self): - if self.settings_write_path and self.settings_write_path != self.root_dir: - LOG.warning("self.settings_write_path has been deprecated! " - "Support will be dropped in a future release") - return join(self.settings_write_path, 'settings.json') + if is_classic_core(): + if self.settings_write_path and \ + self.settings_write_path != self.root_dir: + log_deprecation("`self.settings_write_path` is no longer used", + "0.1.0") + return join(self.settings_write_path, 'settings.json') return super()._settings_path diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index dfe68624..8fa7c5cb 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -1,18 +1,20 @@ import re -import time +from threading import Event +from typing import List, Optional, Union + +from ovos_bus_client import MessageBusClient +from ovos_bus_client.message import Message, dig_for_message from ovos_utils.intents import IntentBuilder, Intent -from ovos_utils.log import LOG -from ovos_utils.messagebus import Message, dig_for_message +from ovos_utils.log import LOG, log_deprecation from ovos_utils.skills import get_non_properties -from ovos_utils.skills.audioservice import AudioServiceInterface +from ovos_utils.skills.audioservice import OCPInterface from ovos_utils.skills.settings import PrivateSettings from ovos_utils.sound import play_audio +from ovos_workshop.resource_files import SkillResources -from ovos_workshop.decorators.killable import killable_event, \ - AbortQuestion -from ovos_workshop.skills.layers import IntentLayers -from ovos_workshop.skills.mycroft_skill import MycroftSkill +from ovos_workshop.decorators.layers import IntentLayers +from ovos_workshop.skills.mycroft_skill import MycroftSkill, is_classic_core class OVOSSkill(MycroftSkill): @@ -25,131 +27,182 @@ class OVOSSkill(MycroftSkill): """ def __init__(self, *args, **kwargs): - super(OVOSSkill, self).__init__(*args, **kwargs) + # note - define these before super() because of self.bind() self.private_settings = None self._threads = [] self._original_converse = self.converse self.intent_layers = IntentLayers() self.audio_service = None + super(OVOSSkill, self).__init__(*args, **kwargs) - def bind(self, bus): + def bind(self, bus: MessageBusClient): super().bind(bus) if bus: # here to ensure self.skill_id is populated self.private_settings = PrivateSettings(self.skill_id) self.intent_layers.bind(self) - self.audio_service = AudioServiceInterface(self.bus) + self.audio_service = OCPInterface(self.bus) # new public api, these are not available in MycroftSkill @property - def is_fully_initialized(self): - """Determines if the skill has been fully loaded and setup. - When True all data has been loaded and all internal state and events setup""" + def is_fully_initialized(self) -> bool: + """ + Determines if the skill has been fully loaded and setup. + When True, all data has been loaded and all internal state + and events set up. + """ return self._is_fully_initialized @property - def stop_is_implemented(self): + def stop_is_implemented(self) -> bool: + """ + True if this skill implements a `stop` method + """ return self._stop_is_implemented @property - def converse_is_implemented(self): - return self._converse_is_implemented - - def activate(self): - """Bump skill to active_skill list in intent_service. - This enables converse method to be called even without skill being - used in last 5 minutes. + def converse_is_implemented(self) -> bool: """ - self._activate() - - def deactivate(self): - """remove skill from active_skill list in intent_service. - This stops converse method from being called + True if this skill implements a `converse` method """ - self._deactivate() - - def play_audio(self, filename): - try: - from mycroft.version import OVOS_VERSION_BUILD, OVOS_VERSION_MINOR, OVOS_VERSION_MAJOR - if OVOS_VERSION_MAJOR >= 1 or \ - OVOS_VERSION_MINOR > 0 or \ - OVOS_VERSION_BUILD >= 4: - self.bus.emit(Message("mycroft.audio.queue", - {"filename": filename})) - return - except: - pass - LOG.warning("self.play_audio requires ovos-core >= 0.0.4a45, falling back to local skill playback") - play_audio(filename).wait() + return self._converse_is_implemented @property - def core_lang(self): - """Get the configured default language.""" + def core_lang(self) -> str: + """ + Get the configured default language as a BCP-47 language code. + """ return self._core_lang @property - def secondary_langs(self): - """Get the configured secondary languages, mycroft is not - considered to be in these languages but i will load it's resource - files. This provides initial support for multilingual input""" + def secondary_langs(self) -> List[str]: + """ + Get the configured secondary languages; resources will be loaded for + these languages to provide support for multilingual input, in addition + to `core_lang`. A skill may override this method to specify which + languages intents are registered in. + """ return self._secondary_langs @property - def native_langs(self): - """Languages natively supported by core - ie, resource files available and explicitly supported + def native_langs(self) -> List[str]: + """ + Languages natively supported by this skill (ie, resource files available + and explicitly supported). This is equivalent to normalized + secondary_langs + core_lang. """ return self._native_langs @property - def alphanumeric_skill_id(self): - """skill id converted to only alphanumeric characters - Non alpha-numeric characters are converted to "_" - - Returns: - (str) String of letters + def alphanumeric_skill_id(self) -> str: + """ + Skill id converted to only alphanumeric characters and "_". + Non alphanumeric characters are converted to "_" """ return self._alphanumeric_skill_id @property - def resources(self): - """Instantiates a ResourceFileLocator instance when needed. - a new instance is always created to ensure self.lang - reflects the active language and not the default core language + def resources(self) -> SkillResources: + """ + Get a SkillResources object for the current language. Objects are + initialized for the current language as needed. """ return self._resources - def load_lang(self, root_directory=None, lang=None): - """Instantiates a ResourceFileLocator instance when needed. - a new instance is always created to ensure lang - reflects the active language and not the default core language + def activate(self): + """ + Mark this skill as active and push to the top of the active skills list. + This enables converse method to be called even without skill being + used in last 5 minutes. + """ + self._activate() + + def deactivate(self): + """ + Mark this skill as inactive and remove from the active skills list. + This stops converse method from being called. + """ + self._deactivate() + + def play_audio(self, filename: str): + """ + Queue and audio file for playback + @param filename: File to play + """ + core_supported = False + if not is_classic_core(): + try: + from mycroft.version import OVOS_VERSION_BUILD, \ + OVOS_VERSION_MINOR, OVOS_VERSION_MAJOR + if OVOS_VERSION_MAJOR >= 1 or \ + OVOS_VERSION_MINOR > 0 or \ + OVOS_VERSION_BUILD >= 4: + core_supported = True # min version of ovos-core + except ImportError: + # skills don't require core anymore, running standalone + core_supported = True + + if core_supported: + message = dig_for_message() or Message("") + self.bus.emit(message.forward("mycroft.audio.queue", + {"filename": filename})) + else: + LOG.warning("self.play_audio requires ovos-core >= 0.0.4a45, " + "falling back to local skill playback") + play_audio(filename).wait() + + def load_lang(self, root_directory: Optional[str] = None, + lang: Optional[str] = None): + """ + Get a SkillResources object for this skill in the requested `lang` for + resource files in the requested `root_directory`. + @param root_directory: root path to find resources (default res_dir) + @param lang: language to get resources for (default self.lang) + @return: SkillResources object """ return self._load_lang(root_directory, lang) - def voc_match(self, *args, **kwargs): + def voc_match(self, *args, **kwargs) -> Union[str, bool]: + """ + Wraps the default `voc_match` method, but returns `False` instead of + raising FileNotFoundError when a resource can't be resolved + """ try: return super().voc_match(*args, **kwargs) except FileNotFoundError: return False - def remove_voc(self, utt, voc_filename, lang=None): - """ removes any entry in .voc file from the utterance """ - lang = lang or self.lang - cache_key = lang + voc_filename - - if cache_key not in self.voc_match_cache: - self.voc_match(utt, voc_filename, lang) + def voc_list(self, voc_filename: str, + lang: Optional[str] = None) -> List[str]: + """ + Get list of vocab options for the requested resource and cache the + results for future references. + @param voc_filename: Name of vocab resource to get options for + @param lang: language to get vocab for (default self.lang) + @return: list of string vocab options + """ + return self._voc_list(voc_filename, lang) + def remove_voc(self, utt: str, voc_filename: str, + lang: Optional[str] = None) -> str: + """ + Removes any vocab match from the utterance. + @param utt: Utterance to evaluate + @param voc_filename: vocab resource to remove from utt + @param lang: Optional language associated with vocab and utterance + @return: string with vocab removed + """ if utt: # Check for matches against complete words - for i in self.voc_match_cache.get(cache_key) or []: + for i in self.voc_list(voc_filename, lang): # Substitute only whole words matching the token utt = re.sub(r'\b' + i + r"\b", "", utt) return utt def _register_decorated(self): - """Register all intent handlers that are decorated with an intent. + """ + Register all intent handlers that are decorated with an intent. Looks for all functions that have been marked by a decorator and read the intent data from them. The intent handlers aren't the @@ -168,7 +221,14 @@ def _register_decorated(self): if hasattr(method, 'converse'): self.converse = method - def register_intent_layer(self, layer_name, intent_list): + def register_intent_layer(self, layer_name: str, + intent_list: List[Union[IntentBuilder, Intent, + str]]): + """ + Register a named intent layer. + @param layer_name: Name of intent layer to add + @param intent_list: List of intents associated with the intent layer + """ for intent_file in intent_list: if IntentBuilder is not None and isinstance(intent_file, IntentBuilder): intent = intent_file.build() @@ -180,7 +240,12 @@ def register_intent_layer(self, layer_name, intent_list): self.intent_layers.update_layer(layer_name, [name]) # killable_events support - def send_stop_signal(self, stop_event=None): + def send_stop_signal(self, stop_event: Optional[str] = None): + """ + Notify services to stop current execution + @param stop_event: optional `stop` event name to forward + """ + waiter = Event() msg = dig_for_message() or Message("mycroft.stop") # stop event execution if stop_event: @@ -193,21 +258,23 @@ def send_stop_signal(self, stop_event=None): self.bus.emit(msg.forward('recognizer_loop:record_stop')) # special non-ovos handling - try: - from mycroft.version import OVOS_VERSION_STR - except ImportError: + if is_classic_core(): # NOTE: mycroft does not have an event to stop recording # this attempts to force a stop by sending silence to end STT step self.bus.emit(Message('mycroft.mic.mute')) - time.sleep(1.5) # the silence from muting should make STT stop recording + waiter.wait(1.5) # the silence from muting should make STT stop recording self.bus.emit(Message('mycroft.mic.unmute')) - time.sleep(0.5) # if TTS had not yet started + # TODO: register TTS events to track state instead of guessing + waiter.wait(0.5) # if TTS had not yet started self.bus.emit(msg.forward("mycroft.audio.speech.stop")) # backwards compat alias, no functional difference class OVOSFallbackSkill(OVOSSkill): def __new__(cls, *args, **kwargs): + log_deprecation("Implement " + "`ovos_workshop.skills.fallback.FallbackSkill`", + "0.1.0") from ovos_workshop.skills.fallback import FallbackSkill return FallbackSkill(*args, **kwargs) diff --git a/ovos_workshop/version.py b/ovos_workshop/version.py index a9969ff5..04f75bc2 100644 --- a/ovos_workshop/version.py +++ b/ovos_workshop/version.py @@ -2,6 +2,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 0 -VERSION_BUILD = 11 +VERSION_BUILD = 12 VERSION_ALPHA = 0 # END_VERSION_BLOCK diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 4b765447..ed721d82 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,6 +1,6 @@ -ovos-utils~=0.0, >=0.0.28 -ovos_config~=0.0,>=0.0.4 +ovos-utils < 0.1.0, >=0.0.35 +ovos_config < 0.1.0,>=0.0.10 ovos-lingua-franca~=0.4,>=0.4.6 - -# optional but improves fuzzy matching and silences logs -rapidfuzz \ No newline at end of file +ovos-bus-client < 0.1.0, >=0.0.5 +ovos_backend_client~=0.0,>=0.0.6 +rapidfuzz diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 00000000..b8b902ed --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,5 @@ +ovos-core~=0.0.7 +neon-lang-plugin-libretranslate~=0.2 +adapt-parser~=0.5 +pytest +pytest-cov diff --git a/scripts/bump_alpha.py b/scripts/bump_alpha.py deleted file mode 100644 index 39a81aa4..00000000 --- a/scripts/bump_alpha.py +++ /dev/null @@ -1,18 +0,0 @@ -import fileinput -from os.path import join, dirname - - -version_file = join(dirname(dirname(__file__)), "ovos_workshop", "version.py") -version_var_name = "VERSION_ALPHA" - -with open(version_file, "r", encoding="utf-8") as v: - for line in v.readlines(): - if line.startswith(version_var_name): - version = int(line.split("=")[-1]) - new_version = int(version) + 1 - -for line in fileinput.input(version_file, inplace=True): - if line.startswith(version_var_name): - print(f"{version_var_name} = {new_version}") - else: - print(line.rstrip('\n')) diff --git a/scripts/bump_build.py b/scripts/bump_build.py deleted file mode 100644 index 5704edf9..00000000 --- a/scripts/bump_build.py +++ /dev/null @@ -1,21 +0,0 @@ -import fileinput -from os.path import join, dirname - - -version_file = join(dirname(dirname(__file__)), "ovos_workshop", "version.py") -version_var_name = "VERSION_BUILD" -alpha_var_name = "VERSION_ALPHA" - -with open(version_file, "r", encoding="utf-8") as v: - for line in v.readlines(): - if line.startswith(version_var_name): - version = int(line.split("=")[-1]) - new_version = int(version) + 1 - -for line in fileinput.input(version_file, inplace=True): - if line.startswith(version_var_name): - print(f"{version_var_name} = {new_version}") - elif line.startswith(alpha_var_name): - print(f"{alpha_var_name} = 0") - else: - print(line.rstrip('\n')) diff --git a/scripts/bump_major.py b/scripts/bump_major.py deleted file mode 100644 index 20b86609..00000000 --- a/scripts/bump_major.py +++ /dev/null @@ -1,27 +0,0 @@ -import fileinput -from os.path import join, dirname - - -version_file = join(dirname(dirname(__file__)), "ovos_workshop", "version.py") -version_var_name = "VERSION_MAJOR" -minor_var_name = "VERSION_MINOR" -build_var_name = "VERSION_BUILD" -alpha_var_name = "VERSION_ALPHA" - -with open(version_file, "r", encoding="utf-8") as v: - for line in v.readlines(): - if line.startswith(version_var_name): - version = int(line.split("=")[-1]) - new_version = int(version) + 1 - -for line in fileinput.input(version_file, inplace=True): - if line.startswith(version_var_name): - print(f"{version_var_name} = {new_version}") - elif line.startswith(minor_var_name): - print(f"{minor_var_name} = 0") - elif line.startswith(build_var_name): - print(f"{build_var_name} = 0") - elif line.startswith(alpha_var_name): - print(f"{alpha_var_name} = 0") - else: - print(line.rstrip('\n')) diff --git a/scripts/bump_minor.py b/scripts/bump_minor.py deleted file mode 100644 index a293b4a1..00000000 --- a/scripts/bump_minor.py +++ /dev/null @@ -1,24 +0,0 @@ -import fileinput -from os.path import join, dirname - - -version_file = join(dirname(dirname(__file__)), "ovos_workshop", "version.py") -version_var_name = "VERSION_MINOR" -build_var_name = "VERSION_BUILD" -alpha_var_name = "VERSION_ALPHA" - -with open(version_file, "r", encoding="utf-8") as v: - for line in v.readlines(): - if line.startswith(version_var_name): - version = int(line.split("=")[-1]) - new_version = int(version) + 1 - -for line in fileinput.input(version_file, inplace=True): - if line.startswith(version_var_name): - print(f"{version_var_name} = {new_version}") - elif line.startswith(build_var_name): - print(f"{build_var_name} = 0") - elif line.startswith(alpha_var_name): - print(f"{alpha_var_name} = 0") - else: - print(line.rstrip('\n')) diff --git a/scripts/remove_alpha.py b/scripts/remove_alpha.py deleted file mode 100644 index d7d055f9..00000000 --- a/scripts/remove_alpha.py +++ /dev/null @@ -1,13 +0,0 @@ -import fileinput -from os.path import join, dirname - - -version_file = join(dirname(dirname(__file__)), "ovos_workshop", "version.py") - -alpha_var_name = "VERSION_ALPHA" - -for line in fileinput.input(version_file, inplace=True): - if line.startswith(alpha_var_name): - print(f"{alpha_var_name} = 0") - else: - print(line.rstrip('\n')) diff --git a/setup.py b/setup.py index d36b1f01..03b7e232 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ from setuptools import setup BASEDIR = os.path.abspath(os.path.dirname(__file__)) +os.chdir(BASEDIR) # For relative `packages` spec in setup below def get_version(): @@ -48,6 +49,12 @@ def required(requirements_file): if pkg.strip() and not pkg.startswith("#")] +def get_description(): + with open(os.path.join(BASEDIR, "README.md"), "r") as f: + long_description = f.read() + return long_description + + setup( name='ovos_workshop', version=get_version(), @@ -65,5 +72,12 @@ def required(requirements_file): author='jarbasAi', author_email='jarbasai@mailfence.com', include_package_data=True, - description='frameworks, templates and patches for the mycroft universe' + description='frameworks, templates and patches for the OpenVoiceOS universe', + long_description=get_description(), + long_description_content_type="text/markdown", + entry_points={ + 'console_scripts': [ + 'ovos-skill-launcher=ovos_workshop.skill_launcher:_launch_script' + ] + } ) diff --git a/test/unittests/skills/intent_file/vocab/en-us/test.intent b/test/__init__.py similarity index 100% rename from test/unittests/skills/intent_file/vocab/en-us/test.intent rename to test/__init__.py diff --git a/test/unittests/skills/intent_file/vocab/en-us/test_ent.entity b/test/unittests/__init__.py similarity index 100% rename from test/unittests/skills/intent_file/vocab/en-us/test_ent.entity rename to test/unittests/__init__.py diff --git a/test/unittests/ovos_tskill_abort/__init__.py b/test/unittests/ovos_tskill_abort/__init__.py index ba8d0456..46e54b6b 100644 --- a/test/unittests/ovos_tskill_abort/__init__.py +++ b/test/unittests/ovos_tskill_abort/__init__.py @@ -1,6 +1,6 @@ from ovos_workshop.decorators import killable_intent from ovos_workshop.skills.ovos import OVOSSkill -from mycroft.skills import intent_file_handler +from ovos_workshop.decorators import intent_file_handler from time import sleep diff --git a/test/unittests/skills/test_active.py b/test/unittests/skills/test_active.py new file mode 100644 index 00000000..0dc1b1e3 --- /dev/null +++ b/test/unittests/skills/test_active.py @@ -0,0 +1,28 @@ +import unittest +from unittest.mock import Mock + +from ovos_utils.messagebus import FakeBus +from ovos_workshop.skills.active import ActiveSkill +from ovos_workshop.skills.base import BaseSkill + + +class ActiveSkillExample(ActiveSkill): + active = Mock() + + def make_active(self): + self.active() + ActiveSkill.make_active(self) + + +class TestActiveSkill(unittest.TestCase): + def test_skill(self): + skill = ActiveSkillExample() + self.assertIsInstance(skill, BaseSkill) + skill.bind(FakeBus()) + skill.active.assert_called_once() + self.assertTrue(skill.active) + skill.deactivate() + self.assertTrue(skill.active) + skill.handle_skill_deactivated() + self.assertTrue(skill.active) + self.assertEqual(skill.active.call_count, 2) diff --git a/test/unittests/skills/test_auto_translatable.py b/test/unittests/skills/test_auto_translatable.py new file mode 100644 index 00000000..6b0271cc --- /dev/null +++ b/test/unittests/skills/test_auto_translatable.py @@ -0,0 +1,45 @@ +import unittest + +from ovos_workshop.skills.common_query_skill import CommonQuerySkill +from ovos_workshop.skills.fallback import FallbackSkill +from ovos_workshop.skills.base import BaseSkill + + +class TestUniversalSkill(unittest.TestCase): + from ovos_workshop.skills.auto_translatable import UniversalSkill + test_skill = UniversalSkill() + + def test_00_init(self): + self.assertIsInstance(self.test_skill, self.UniversalSkill) + self.assertIsInstance(self.test_skill, BaseSkill) + + # TODO: Test other class methods + + +class TestUniversalFallbackSkill(unittest.TestCase): + from ovos_workshop.skills.auto_translatable import UniversalFallback + test_skill = UniversalFallback() + + def test_00_init(self): + self.assertIsInstance(self.test_skill, self.UniversalFallback) + self.assertIsInstance(self.test_skill, BaseSkill) + self.assertIsInstance(self.test_skill, FallbackSkill) + + # TODO: Test other class methods + + +class TestUniversalCommonQuerySkill(unittest.TestCase): + from ovos_workshop.skills.auto_translatable import UniversalCommonQuerySkill + + class UniveralCommonQueryExample(UniversalCommonQuerySkill): + def CQS_match_query_phrase(self, phrase): + pass + + test_skill = UniveralCommonQueryExample() + + def test_00_init(self): + self.assertIsInstance(self.test_skill, self.UniversalCommonQuerySkill) + self.assertIsInstance(self.test_skill, BaseSkill) + self.assertIsInstance(self.test_skill, CommonQuerySkill) + + # TODO: Test other class methods diff --git a/test/unittests/skills/test_base.py b/test/unittests/skills/test_base.py new file mode 100644 index 00000000..6d139dab --- /dev/null +++ b/test/unittests/skills/test_base.py @@ -0,0 +1,584 @@ +import json +import os +import shutil +import unittest + +from logging import Logger +from threading import Event, Thread +from time import time +from unittest.mock import Mock, patch +from os.path import join, dirname, isdir + +from ovos_utils.messagebus import FakeBus + + +class TestBase(unittest.TestCase): + def test_is_classic_core(self): + from ovos_workshop.skills.base import is_classic_core + self.assertIsInstance(is_classic_core(), bool) + + def test_simple_trace(self): + from ovos_workshop.skills.base import simple_trace + trace = ["line_1\n", " line_2 \n", " \n", "line_3 \n"] + self.assertEqual(simple_trace(trace), "Traceback:\nline_1\n line_2 \n") + + +class TestBaseSkill(unittest.TestCase): + test_config_path = join(dirname(__file__), "temp_config") + os.environ["XDG_CONFIG_HOME"] = test_config_path + from ovos_workshop.skills.base import BaseSkill + bus = FakeBus() + skill_id = "test_base_skill" + skill = BaseSkill(bus=bus, skill_id=skill_id) + + @classmethod + def tearDownClass(cls) -> None: + os.environ.pop("XDG_CONFIG_HOME") + shutil.rmtree(cls.test_config_path) + + def test_00_skill_init(self): + from ovos_workshop.settings import SkillSettingsManager + from ovos_workshop.skills.base import SkillGUI + from ovos_utils.events import EventContainer, EventSchedulerInterface + from ovos_utils.intents import IntentServiceInterface + from ovos_utils.process_utils import RuntimeRequirements + from ovos_utils.enclosure.api import EnclosureAPI + from ovos_workshop.filesystem import FileSystemAccess + from ovos_workshop.resource_files import SkillResources + + self.assertIsInstance(self.skill.log, Logger) + self.assertIsInstance(self.skill._enable_settings_manager, bool) + self.assertEqual(self.skill.name, self.skill.__class__.__name__) + self.assertEqual(self.skill.skill_id, self.skill_id) + self.assertIsInstance(self.skill.settings_manager, SkillSettingsManager) + self.assertTrue(isdir(self.skill.root_dir)) + self.assertEqual(self.skill.res_dir, self.skill.root_dir) + self.assertIsInstance(self.skill.gui, SkillGUI) + self.assertIsInstance(self.skill.config_core, dict) + self.assertIsNone(self.skill.settings_change_callback) + self.assertTrue(self.skill.reload_skill) + self.assertIsInstance(self.skill.events, EventContainer) + self.assertEqual(self.skill.events.bus, self.bus) + self.assertIsInstance(self.skill.event_scheduler, + EventSchedulerInterface) + self.assertIsInstance(self.skill.intent_service, IntentServiceInterface) + + self.assertIsInstance(self.skill.runtime_requirements, + RuntimeRequirements) + self.assertIsInstance(self.skill.voc_match_cache, dict) + self.assertTrue(self.skill._is_fully_initialized) + self.assertTrue(isdir(dirname(self.skill._settings_path))) + self.assertIsInstance(self.skill.settings, dict) + self.assertIsNone(self.skill.dialog_renderer) + self.assertIsInstance(self.skill.enclosure, EnclosureAPI) + self.assertIsInstance(self.skill.file_system, FileSystemAccess) + self.assertTrue(isdir(self.skill.file_system.path)) + self.assertEqual(self.skill.bus, self.bus) + self.assertIsInstance(self.skill.location, dict) + self.assertIsInstance(self.skill.location_pretty, str) + self.assertIsInstance(self.skill.location_timezone, str) + self.assertIsInstance(self.skill.lang, str) + self.assertEqual(len(self.skill.lang.split('-')), 2) + self.assertEqual(self.skill._core_lang, self.skill.lang) + self.assertIsInstance(self.skill._secondary_langs, list) + self.assertIsInstance(self.skill._native_langs, list) + self.assertIn(self.skill._core_lang, self.skill._native_langs) + self.assertIsInstance(self.skill._alphanumeric_skill_id, str) + self.assertIsInstance(self.skill._resources, SkillResources) + self.assertEqual(self.skill._resources.language, self.skill.lang) + self.assertFalse(self.skill._stop_is_implemented) + self.assertFalse(self.skill._converse_is_implemented) + + def test_handle_first_run(self): + # TODO + pass + + def test_check_for_first_run(self): + # TODO + pass + + def test_startup(self): + # TODO + pass + + def test_init_settings(self): + # Test initial settings defined and not fully initialized + test_settings = {"init": True} + self.skill._initial_settings = test_settings + self.skill._settings["init"] = False + self.skill._settings["test"] = "value" + self.skill._init_event.clear() + self.skill._init_settings() + self.assertEqual(dict(self.skill.settings), + {**test_settings, + **{"__mycroft_skill_firstrun": False}}) + self.assertEqual(dict(self.skill._initial_settings), + dict(self.skill.settings)) + + # Test settings changed during init + stop_event = Event() + setting_event = Event() + + def _update_skill_settings(): + while not stop_event.is_set(): + self.skill.settings["test_val"] = time() + setting_event.set() + + # Test this a few times since this handles a race condition + for i in range(32): + # Reset to pre-initialized state + self.skill._init_event.clear() + self.skill._settings = None + setting_event.clear() + stop_event.clear() + thread = Thread(target=_update_skill_settings, daemon=True) + thread.start() + setting_event.wait() # settings have some value + self.assertIsNotNone(self.skill._initial_settings["test_val"], + f"run {i}") + self.skill._init_settings() + self.assertIsNotNone(self.skill.settings["test_val"], f"run {i}") + self.assertIsNotNone(self.skill._initial_settings["test_val"], + f"run {i}") + setting_event.clear() + setting_event.wait() # settings updated since init + stop_time = time() + stop_event.set() + thread.join() + self.assertAlmostEquals(self.skill.settings["test_val"], stop_time, + 0, f"run {i}") + self.assertNotEqual(self.skill.settings["test_val"], + self.skill._initial_settings["test_val"], + f"run {i}") + + def test_init_skill_gui(self): + # TODO + pass + + def test_init_settings_manager(self): + # TODO + pass + + def test_start_filewatcher(self): + test_skill_id = "test_settingschanged.skill" + test_skill = self.BaseSkill(bus=self.bus, skill_id=test_skill_id) + settings_changed = Event() + on_file_change = Mock(side_effect=lambda x: settings_changed.set()) + test_skill._handle_settings_file_change = on_file_change + test_skill._settings_watchdog = None + test_skill._start_filewatcher() + self.assertIsNotNone(test_skill._settings_watchdog) + skill_settings = test_skill.settings + skill_settings["changed_on_disk"] = True + with open(test_skill.settings.path, 'w') as f: + json.dump(skill_settings, f, indent=2) + + self.assertTrue(settings_changed.wait(5)) + on_file_change.assert_called_once_with(test_skill.settings.path) + + def test_upload_settings(self): + # TODO + pass + + def test_handle_settings_file_change(self): + real_upload = self.skill._upload_settings + self.skill._upload_settings = Mock() + settings_file = self.skill.settings.path + + # Handle change with no callback + self.skill._handle_settings_file_change(settings_file) + self.skill._upload_settings.assert_called_once() + + # Handle change with callback + self.skill._upload_settings.reset_mock() + self.skill.settings_change_callback = Mock() + self.skill._handle_settings_file_change(settings_file) + self.skill._upload_settings.assert_called_once() + self.skill.settings_change_callback.assert_called_once() + + # Handle non-settings file change + self.skill._handle_settings_file_change(join(dirname(settings_file), + "test.file")) + self.skill._upload_settings.assert_called_once() + self.skill.settings_change_callback.assert_called_once() + + self.skill._upload_settings = real_upload + + def test_load_lang(self): + # TODO + pass + + def test_bind(self): + # TODO + pass + + def test_register_public_api(self): + # TODO + pass + + def test_register_system_event_handlers(self): + # TODO + pass + + def test_handle_settings_change(self): + # TODO + pass + + def test_detach(self): + # TODO + pass + + def test_send_public_api(self): + # TODO + pass + + def test_get_intro_message(self): + self.assertIsInstance(self.skill.get_intro_message(), str) + self.assertFalse(self.skill.get_intro_message()) + + def test_handle_skill_activated(self): + # TODO + pass + + def test_handle_skill_deactivated(self): + # TODO + pass + + def test_activate(self): + # TODO + pass + + def test_deactivate(self): + # TODO + pass + + def test_handle_converse_ack(self): + # TODO + pass + + def test_handle_converse_request(self): + # TODO + pass + + def test_converse(self): + # TODO + self.assertFalse(self.skill.converse()) + + # TODO port get_response methods per #69 + + def test_ask_yesno(self): + # TODO + pass + + def test_ask_selection(self): + # TODO + pass + + def test_voc_list(self): + # TODO + pass + + def test_voc_match(self): + # TODO + pass + + def test_report_metric(self): + # TODO + pass + + def test_send_email(self): + # TODO + pass + + def test_handle_collect_resting(self): + # TODO + pass + + def test_register_resting_screen(self): + # TODO + pass + + def test_register_decorated(self): + # TODO + pass + + def test_find_resource(self): + # TODO + pass + + def test_on_event_start(self): + # TODO + pass + + def test_on_event_end(self): + # TODO + pass + + def test_on_event_error(self): + # TODO + pass + + def test_add_event(self): + # TODO + pass + + def test_remove_event(self): + # TODO + pass + + def test_register_adapt_intent(self): + # TODO + pass + + def test_register_intent(self): + # TODO + pass + + def test_register_intent_file(self): + from ovos_workshop.skills.base import BaseSkill + skill = BaseSkill(bus=self.bus, skill_id=self.skill_id) + skill._lang_resources = dict() + skill.intent_service = Mock() + skill.res_dir = join(dirname(__file__), "test_locale") + en_intent_file = join(skill.res_dir, "locale", "en-us", "time.intent") + uk_intent_file = join(skill.res_dir, "locale", "uk-ua", "time.intent") + + # No secondary languages + skill.config_core["lang"] = "en-us" + skill.config_core["secondary_langs"] = [] + skill.register_intent_file("time.intent", Mock(__name__="test")) + skill.intent_service.register_padatious_intent.assert_called_once_with( + f"{skill.skill_id}:time.intent", en_intent_file, "en-us") + + # With secondary language + skill.intent_service.register_padatious_intent.reset_mock() + skill.config_core["secondary_langs"] = ["en-us", "uk-ua"] + skill.register_intent_file("time.intent", Mock(__name__="test")) + self.assertEqual( + skill.intent_service.register_padatious_intent.call_count, 2) + skill.intent_service.register_padatious_intent.assert_any_call( + f"{skill.skill_id}:time.intent", en_intent_file, "en-us") + skill.intent_service.register_padatious_intent.assert_any_call( + f"{skill.skill_id}:time.intent", uk_intent_file, "uk-ua") + + def test_register_entity_file(self): + from ovos_workshop.skills.base import BaseSkill + skill = BaseSkill(bus=self.bus, skill_id=self.skill_id) + skill._lang_resources = dict() + skill.intent_service = Mock() + skill.res_dir = join(dirname(__file__), "test_locale") + en_file = join(skill.res_dir, "locale", "en-us", "dow.entity") + uk_file = join(skill.res_dir, "locale", "uk-ua", "dow.entity") + + # No secondary languages + skill.config_core["lang"] = "en-us" + skill.config_core["secondary_langs"] = [] + skill.register_entity_file("dow") + skill.intent_service.register_padatious_entity.assert_called_once_with( + f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9", + en_file, "en-us") + + # With secondary language + skill.intent_service.register_padatious_entity.reset_mock() + skill.config_core["secondary_langs"] = ["en-us", "uk-ua"] + skill.register_entity_file("dow") + self.assertEqual( + skill.intent_service.register_padatious_entity.call_count, 2) + skill.intent_service.register_padatious_entity.assert_any_call( + f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9", + en_file, "en-us") + skill.intent_service.register_padatious_entity.assert_any_call( + f"{skill.skill_id}:dow_d446b2a6e46e7d94cdf7787e21050ff9", + uk_file, "uk-ua") + + def test_handle_enable_intent(self): + # TODO + pass + + def test_handle_disable_intent(self): + # TODO + pass + + def test_disable_intent(self): + # TODO + pass + + def test_enable_intent(self): + # TODO + pass + + def test_set_context(self): + # TODO + pass + + def test_remove_context(self): + # TODO + pass + + def test_handle_set_cross_context(self): + # TODO + pass + + def test_handle_remove_cross_context(self): + # TODO + pass + + def test_set_cross_skill_contest(self): + # TODO + pass + + def test_remove_cross_skill_context(self): + # TODO + pass + + def test_register_vocabulary(self): + # TODO + pass + + def test_register_regex(self): + # TODO + pass + + def test_speak(self): + # TODO + pass + + def test_speak_dialog(self): + # TODO + pass + + def test_acknowledge(self): + # TODO + pass + + def test_load_dialog_files(self): + # TODO + pass + + def test_load_data_files(self): + # TODO + pass + + def test_load_vocab_files(self): + # TODO + pass + + def test_load_regex_files(self): + # TODO + pass + + def test_handle_stop(self): + # TODO + pass + + def test_stop(self): + self.skill.stop() + + def test_shutdown(self): + self.skill.shutdown() + + def test_default_shutdown(self): + test_skill_id = "test_shutdown.skill" + test_skill = self.BaseSkill(bus=self.bus, skill_id=test_skill_id) + test_skill.settings["changed"] = True + test_skill.stop = Mock() + test_skill.shutdown = Mock() + test_skill.settings_change_callback = Mock() + test_skill.settings.store = Mock() + test_skill._settings_watchdog = Mock() + test_skill.gui.shutdown = Mock() + test_skill.event_scheduler = Mock() + test_skill.events = Mock() + message = None + + def _handle_detach_skill(msg): + nonlocal message + message = msg + + self.bus.on("detach_skill", _handle_detach_skill) + + test_skill.default_shutdown() + + test_skill.stop.assert_called_once() + + self.assertIsNone(test_skill.settings_change_callback) + test_skill.settings.store.assert_called_once() + test_skill._settings_watchdog.shutdown.assert_called_once() + + test_skill.gui.shutdown.assert_called_once() + + test_skill.event_scheduler.shutdown.assert_called_once() + test_skill.events.clear.assert_called_once() + + test_skill.shutdown.assert_called_once() + + from ovos_bus_client import Message + self.assertIsInstance(message, Message) + self.assertEqual(message.msg_type, "detach_skill") + self.assertTrue(message.data["skill_id"].startswith(test_skill_id)) + self.assertEqual(message.context["skill_id"], test_skill_id) + + def test_schedule_event(self): + # TODO + pass + + def test_schedule_repeating_event(self): + # TODO + pass + + def test_update_scheduled_event(self): + # TODO + pass + + def test_cancel_scheduled_event(self): + # TODO + pass + + def test_get_scheduled_event_status(self): + # TODO + pass + + def test_cancel_all_repeating_events(self): + # TODO + pass + + +class TestSkillGui(unittest.TestCase): + class LegacySkill(Mock): + skill_id = "old_skill" + bus = FakeBus() + config_core = {"gui": {"test": True, + "legacy": True}} + root_dir = join(dirname(__file__), "test_gui/gui") + + class GuiSkill(Mock): + skill_id = "new_skill" + bus = FakeBus() + config_core = {"gui": {"test": True, + "legacy": False}} + root_dir = join(dirname(__file__), "test_gui") + + @patch("ovos_workshop.skills.base.GUIInterface.__init__") + def test_skill_gui(self, interface_init): + from ovos_utils.gui import GUIInterface + from ovos_workshop.skills.base import SkillGUI + + # Old skill with `ui` directory in root + old_skill = self.LegacySkill() + old_gui = SkillGUI(old_skill) + self.assertEqual(old_gui.skill, old_skill) + self.assertIsInstance(old_gui, GUIInterface) + interface_init.assert_called_once_with( + old_gui, skill_id=old_skill.skill_id, bus=old_skill.bus, + config=old_skill.config_core['gui'], + ui_directories={"qt5": join(old_skill.root_dir, "ui")}) + + # New skill with `gui` directory in root + new_skill = self.GuiSkill() + new_gui = SkillGUI(new_skill) + self.assertEqual(new_gui.skill, new_skill) + self.assertIsInstance(new_gui, GUIInterface) + interface_init.assert_called_with( + new_gui, skill_id=new_skill.skill_id, bus=new_skill.bus, + config=new_skill.config_core['gui'], + ui_directories={"all": join(new_skill.root_dir, "gui")}) diff --git a/test/unittests/skills/test_fallback_skill.py b/test/unittests/skills/test_fallback_skill.py index ddb434d1..0188a82b 100644 --- a/test/unittests/skills/test_fallback_skill.py +++ b/test/unittests/skills/test_fallback_skill.py @@ -1,53 +1,234 @@ -from unittest import TestCase, mock +from unittest import TestCase +from unittest.mock import patch -from ovos_workshop.skills.fallback import FallbackSkill +from ovos_utils.messagebus import FakeBus +from ovos_workshop.decorators import fallback_handler +from ovos_workshop.skills.base import BaseSkill +from ovos_workshop.skills.fallback import FallbackSkillV1, FallbackSkillV2, \ + FallbackSkill -def setup_fallback(fb_class): - fb_skill = fb_class() - fb_skill.bind(mock.Mock(name='bus')) - fb_skill.initialize() - return fb_skill +class SimpleFallback(FallbackSkillV1): + """Simple fallback skill used for test.""" + def initialize(self): + self.register_fallback(self.fallback_handler, 42) + + def fallback_handler(self, _): + pass + + +class V2FallbackSkill(FallbackSkillV2): + def __init__(self): + FallbackSkillV2.__init__(self, FakeBus(), "fallback_v2") + + @fallback_handler + def handle_fallback(self, message): + pass + + @fallback_handler(10) + def high_prio_fallback(self, message): + pass class TestFallbackSkill(TestCase): - def test_life_cycle(self): - """Test startup and shutdown of a fallback skill. + # TODO: Test `__new__` logic + pass + + def test_class_inheritance(self): + from ovos_workshop.skills.ovos import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill + fallback = FallbackSkill("test") + self.assertIsInstance(fallback, BaseSkill) + self.assertIsInstance(fallback, OVOSSkill) + self.assertIsInstance(fallback, MycroftSkill) + self.assertIsInstance(fallback, FallbackSkillV1) + self.assertIsInstance(fallback, FallbackSkillV2) + self.assertIsInstance(fallback, FallbackSkill) + + +class TestFallbackSkillV1(TestCase): + @staticmethod + def setup_fallback(fb_class): + fb_skill = fb_class() + fb_skill.bind(FakeBus()) + fb_skill.initialize() + return fb_skill + + def test_inheritance(self): + from ovos_workshop.skills.ovos import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill + fallback = FallbackSkillV1("test") + self.assertIsInstance(fallback, BaseSkill) + self.assertIsInstance(fallback, OVOSSkill) + self.assertIsInstance(fallback, MycroftSkill) + self.assertIsInstance(fallback, FallbackSkillV1) + self.assertIsInstance(fallback, FallbackSkillV2) + self.assertIsInstance(fallback, FallbackSkill) + + def test_make_intent_failure_handler(self): + # TODO + pass + + def test_report_timing(self): + # TODO + pass + + def test__register_fallback(self): + # TODO + pass + + def test_register_fallback(self): + # TODO + pass + + def test_remove_registered_handler(self): + # TODO + pass + + @patch("ovos_workshop.skills.fallback.FallbackSkillV1." + "_remove_registered_handler") + def test_remove_fallback(self, remove_handler): + def wrapper(handler): + def wrapped(): + if handler(): + return True + return False + return wrapped + + def _mock_1(): + pass + + def _mock_2(): + pass + + FallbackSkillV1.wrapper_map.append((_mock_1, wrapper(_mock_1))) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 1) + + FallbackSkillV1.wrapper_map.append((_mock_2, wrapper(_mock_2))) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 2) + + # Successful remove existing wrapper + remove_handler.return_value = True + self.assertTrue(FallbackSkillV1.remove_fallback(_mock_1)) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 1) + self.assertFalse(FallbackSkillV1.remove_fallback(_mock_1)) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 1) + # Failed remove existing wrapper + remove_handler.return_value = False + self.assertFalse(FallbackSkillV1.remove_fallback( + FallbackSkillV1.wrapper_map[0][1])) + self.assertEqual(FallbackSkillV1.wrapper_map, []) + + def test_remove_instance_handlers(self): + # TODO + pass + + def test_default_shutdown(self): + # TODO + pass + + def test_register_decorated(self): + # TODO + pass + + def test_life_cycle(self): + """ + Test startup and shutdown of a fallback skill. Ensure that an added handler is removed as part of default shutdown. """ - self.assertEqual(len(FallbackSkill.fallback_handlers), 0) - fb_skill = setup_fallback(SimpleFallback) - self.assertEqual(len(FallbackSkill.fallback_handlers), 1) - self.assertEqual(FallbackSkill.wrapper_map[0][0], + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 0) + fb_skill = self.setup_fallback(SimpleFallback) + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 1) + self.assertEqual(FallbackSkillV1.wrapper_map[0][0], fb_skill.fallback_handler) - self.assertEqual(len(FallbackSkill.wrapper_map), 1) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 1) fb_skill.default_shutdown() - self.assertEqual(len(FallbackSkill.fallback_handlers), 0) - self.assertEqual(len(FallbackSkill.wrapper_map), 0) + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 0) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 0) def test_manual_removal(self): - """Test that the call to remove_fallback() removes the handler""" - self.assertEqual(len(FallbackSkill.fallback_handlers), 0) + """ + Test that the call to remove_fallback() removes the handler + """ + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 0) # Create skill adding a single handler - fb_skill = setup_fallback(SimpleFallback) - self.assertEqual(len(FallbackSkill.fallback_handlers), 1) + fb_skill = self.setup_fallback(SimpleFallback) + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 1) self.assertTrue(fb_skill.remove_fallback(fb_skill.fallback_handler)) # Both internal trackers of handlers should be cleared now - self.assertEqual(len(FallbackSkill.fallback_handlers), 0) - self.assertEqual(len(FallbackSkill.wrapper_map), 0) + self.assertEqual(len(FallbackSkillV1.fallback_handlers), 0) + self.assertEqual(len(FallbackSkillV1.wrapper_map), 0) # Removing after it's already been removed should fail self.assertFalse(fb_skill.remove_fallback(fb_skill.fallback_handler)) -class SimpleFallback(FallbackSkill): - """Simple fallback skill used for test.""" - def initialize(self): - self.register_fallback(self.fallback_handler, 42) +class TestFallbackSkillV2(TestCase): + fallback_skill = FallbackSkillV2(FakeBus(), "test_fallback_v2") + + def test_class_inheritance(self): + from ovos_workshop.skills.ovos import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill + self.assertIsInstance(self.fallback_skill, BaseSkill) + self.assertIsInstance(self.fallback_skill, OVOSSkill) + self.assertIsInstance(self.fallback_skill, MycroftSkill) + self.assertIsInstance(self.fallback_skill, FallbackSkillV1) + self.assertIsInstance(self.fallback_skill, FallbackSkillV2) + self.assertIsInstance(self.fallback_skill, FallbackSkill) + + def test_00_init(self): + self.assertIsInstance(self.fallback_skill, FallbackSkillV2) + self.assertIsInstance(self.fallback_skill, FallbackSkill) + self.assertIsInstance(self.fallback_skill, BaseSkill) + + def test_priority(self): + FallbackSkillV2.fallback_config = {} + + # No config or handlers + self.assertEqual(self.fallback_skill.priority, 101) + # Config override + FallbackSkillV2.fallback_config = \ + {"fallback_priorities": {"test_fallback_v2": 10}} + self.assertEqual(self.fallback_skill.priority, 10, + self.fallback_skill.fallback_config) + + fallback_skill = V2FallbackSkill() + + # Minimum handler + self.assertEqual(fallback_skill.priority, 10) + # Config override + FallbackSkillV2.fallback_config['fallback_priorities'][ + fallback_skill.skill_id] = 80 + self.assertEqual(fallback_skill.priority, 80) + + def test_can_answer(self): + self.assertFalse(self.fallback_skill.can_answer([""], "en-us")) + # TODO + + def test_register_system_event_handlers(self): + # TODO + pass + + def test_handle_fallback_ack(self): + # TODO + pass + + def test_handle_fallback_request(self): + # TODO + pass + + def test_register_fallback(self): + # TODO + pass + + def test_default_shutdown(self): + # TODO + pass - def fallback_handler(self): + def test_register_decorated(self): + # TODO pass diff --git a/test/unittests/skills/test_gui/gui/ui/test.qml b/test/unittests/skills/test_gui/gui/ui/test.qml new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/skills/test_idle_display_skill.py b/test/unittests/skills/test_idle_display_skill.py new file mode 100644 index 00000000..93b6026e --- /dev/null +++ b/test/unittests/skills/test_idle_display_skill.py @@ -0,0 +1,14 @@ +import unittest + +from ovos_utils.messagebus import FakeBus +from ovos_workshop.skills.base import BaseSkill +from ovos_workshop.skills.idle_display_skill import IdleDisplaySkill + + +class TestIdleDisplaySkill(unittest.TestCase): + skill = IdleDisplaySkill(bus=FakeBus(), skill_id="test_idle_skill") + + def test_00_skill_init(self): + self.assertIsInstance(self.skill, BaseSkill) + self.assertIsInstance(self.skill, IdleDisplaySkill) + # TODO: Implement more tests \ No newline at end of file diff --git a/test/unittests/skills/test_locale/locale/en-us/dow.entity b/test/unittests/skills/test_locale/locale/en-us/dow.entity new file mode 100644 index 00000000..7cbf6bfc --- /dev/null +++ b/test/unittests/skills/test_locale/locale/en-us/dow.entity @@ -0,0 +1,3 @@ +monday +tuesday +wednesday \ No newline at end of file diff --git a/test/unittests/skills/test_locale/locale/en-us/time.intent b/test/unittests/skills/test_locale/locale/en-us/time.intent new file mode 100644 index 00000000..5ebaa610 --- /dev/null +++ b/test/unittests/skills/test_locale/locale/en-us/time.intent @@ -0,0 +1 @@ +what time is it \ No newline at end of file diff --git a/test/unittests/skills/test_locale/locale/uk-ua/dow.entity b/test/unittests/skills/test_locale/locale/uk-ua/dow.entity new file mode 100644 index 00000000..7cbf6bfc --- /dev/null +++ b/test/unittests/skills/test_locale/locale/uk-ua/dow.entity @@ -0,0 +1,3 @@ +monday +tuesday +wednesday \ No newline at end of file diff --git a/test/unittests/skills/test_locale/locale/uk-ua/time.intent b/test/unittests/skills/test_locale/locale/uk-ua/time.intent new file mode 100644 index 00000000..ac9796fe --- /dev/null +++ b/test/unittests/skills/test_locale/locale/uk-ua/time.intent @@ -0,0 +1 @@ +котра година \ No newline at end of file diff --git a/test/unittests/skills/test_mycroft_skill/__init__.py b/test/unittests/skills/test_mycroft_skill/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/skills/test_mycroft_skill/intent_file/vocab/en-us/test.intent b/test/unittests/skills/test_mycroft_skill/intent_file/vocab/en-us/test.intent new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/skills/test_mycroft_skill/intent_file/vocab/en-us/test_ent.entity b/test/unittests/skills/test_mycroft_skill/intent_file/vocab/en-us/test_ent.entity new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/skills/locale/en-us/turn_off2_test.voc b/test/unittests/skills/test_mycroft_skill/locale/en-us/turn_off2_test.voc similarity index 100% rename from test/unittests/skills/locale/en-us/turn_off2_test.voc rename to test/unittests/skills/test_mycroft_skill/locale/en-us/turn_off2_test.voc diff --git a/test/unittests/skills/locale/en-us/turn_off_test.voc b/test/unittests/skills/test_mycroft_skill/locale/en-us/turn_off_test.voc similarity index 100% rename from test/unittests/skills/locale/en-us/turn_off_test.voc rename to test/unittests/skills/test_mycroft_skill/locale/en-us/turn_off_test.voc diff --git a/test/unittests/skills/mocks.py b/test/unittests/skills/test_mycroft_skill/mocks.py similarity index 96% rename from test/unittests/skills/mocks.py rename to test/unittests/skills/test_mycroft_skill/mocks.py index 6b3ff1f9..2d292975 100644 --- a/test/unittests/skills/mocks.py +++ b/test/unittests/skills/test_mycroft_skill/mocks.py @@ -15,7 +15,7 @@ from copy import deepcopy from unittest.mock import Mock -from mycroft.configuration.config import LocalConf, DEFAULT_CONFIG +from ovos_config import LocalConf, DEFAULT_CONFIG __CONFIG = LocalConf(DEFAULT_CONFIG) diff --git a/test/unittests/skills/test_mycroft_skill.py b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py similarity index 89% rename from test/unittests/skills/test_mycroft_skill.py rename to test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py index 53c7c76b..f0962fed 100644 --- a/test/unittests/skills/test_mycroft_skill.py +++ b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py @@ -16,15 +16,16 @@ import sys import unittest -import pytest - from datetime import datetime from os.path import join, dirname, abspath from unittest.mock import MagicMock, patch -from adapt.intent import IntentBuilder +from ovos_utils.intents import IntentBuilder +from ovos_bus_client import Message from ovos_config.config import Configuration -from mycroft_bus_client import Message + +from ovos_workshop.decorators import intent_handler, resting_screen_handler, \ + intent_file_handler from ovos_workshop.skills.mycroft_skill import MycroftSkill from .mocks import base_config @@ -59,6 +60,21 @@ def vocab_base_path(): return join(dirname(__file__), '..', 'vocab_test') +class TestFunction(unittest.TestCase): + def test_resting_screen_handler(self): + class T(MycroftSkill): + def __init__(self): + self.name = 'TestObject' + + @resting_screen_handler('humbug') + def f(self): + pass + + test_class = T() + self.assertTrue('resting_handler' in dir(test_class.f)) + self.assertEqual(test_class.f.resting_handler, 'humbug') + + class TestMycroftSkill(unittest.TestCase): emitter = MockEmitter() regex_path = abspath(join(dirname(__file__), '../regex_test')) @@ -212,13 +228,15 @@ def _test_intent_file(self, s): 'file_name': join(dirname(__file__), 'intent_file', 'vocab', 'en-us', 'test.intent'), 'lang': 'en-us', - 'name': str(s.skill_id) + ':test.intent' + 'name': str(s.skill_id) + ':test.intent', + 'samples': [] }, { 'file_name': join(dirname(__file__), 'intent_file', 'vocab', 'en-us', 'test_ent.entity'), 'lang': 'en-us', - 'name': str(s.skill_id) + ':test_ent_87af9db6c8402bcfaa8ebc719ae4427c' + 'name': str(s.skill_id) + ':test_ent_87af9db6c8402bcfaa8ebc719ae4427c', + 'samples': [] } ] self.check_register_object_file(expected_types, expected_results) @@ -229,12 +247,11 @@ def check_register_decorators(self, result_list): sorted(result_list, key=lambda d: sorted(d.items()))) self.emitter.reset() - @pytest.mark.skip def test_register_decorators(self): """ Test decorated intents """ path_orig = sys.path sys.path.append(abspath(dirname(__file__))) - SimpleSkill5 = __import__('decorator_test_skill').TestSkill + s = SimpleSkill5() s.res_dir = abspath(join(dirname(__file__), 'intent_file')) s._startup(self.emitter, "A") @@ -247,7 +264,9 @@ def test_register_decorators(self): 'file_name': join(dirname(__file__), 'intent_file', 'vocab', 'en-us', 'test.intent'), 'lang': 'en-us', - 'name': str(s.skill_id) + ':test.intent'}] + 'samples': [], + 'name': str(s.skill_id) + ':test.intent'} + ] self.check_register_decorators(expected) # Restore sys.path @@ -438,6 +457,15 @@ def test_voc_match_exact(self): self.assertFalse(s.voc_match("would you please turn off the lights", "turn_off_test", exact=True)) + def test_voc_list(self): + s = SimpleSkill1() + s.root_dir = abspath(dirname(__file__)) + + self.assertEqual(s._voc_list("turn_off_test"), + ["turn off", "switch off"]) + cache_key = s.lang + "turn_off_test" + self.assertIn(cache_key, s._voc_cache) + def test_translate_locations(self): """Assert that the a translatable list can be loaded from dialog and locale. @@ -502,9 +530,28 @@ def test_native_langs(self): s.config_core['secondary_langs'] = secondary +class TestIntentCollisions(unittest.TestCase): + def test_two_intents_with_same_name(self): + emitter = MockEmitter() + skill = SameIntentNameSkill() + skill.bind(emitter) + with self.assertRaises(ValueError): + skill.initialize() + + def test_two_anonymous_intent_decorators(self): + """Two anonymous intent handlers should be ok.""" + emitter = MockEmitter() + skill = SameAnonymousIntentDecoratorsSkill() + skill.bind(emitter) + skill._register_decorated() + self.assertEqual(len(skill.intent_service.registered_intents), 2) + + class _TestSkill(MycroftSkill): def __init__(self): super().__init__() + self.config_core['lang'] = "en-us" + self.config_core['secondary_langs'] = [] self.skill_id = 'A' @@ -570,6 +617,21 @@ def stop(self): pass +class SimpleSkill5(MycroftSkill): + """ Test skill for intent_handler decorator. """ + + @intent_handler(IntentBuilder('a').require('Keyword').build()) + def handler(self, message): + pass + + @intent_file_handler('test.intent') + def handler2(self, message): + pass + + def stop(self): + pass + + class SimpleSkill6(_TestSkill): """ Test skill for padatious intent """ skill_id = 'A' @@ -594,3 +656,13 @@ def initialize(self): def handler(self, message): pass + + +class SameAnonymousIntentDecoratorsSkill(_TestSkill): + """Test skill for duplicate anonymous intent handlers.""" + skill_id = 'A' + + @intent_handler(IntentBuilder('').require('Keyword')) + @intent_handler(IntentBuilder('').require('OtherKeyword')) + def handler(self, message): + pass diff --git a/test/unittests/skills/test_mycroft_skill_get_response.py b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py similarity index 99% rename from test/unittests/skills/test_mycroft_skill_get_response.py rename to test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py index 2468a050..9924fca9 100644 --- a/test/unittests/skills/test_mycroft_skill_get_response.py +++ b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py @@ -8,7 +8,7 @@ from lingua_franca import load_language from ovos_workshop.skills.mycroft_skill import MycroftSkill -from mycroft_bus_client import Message +from ovos_bus_client import Message from .mocks import base_config, AnyCallable diff --git a/test/unittests/skills/test_skill/__init__.py b/test/unittests/skills/test_mycroft_skill/test_skill/__init__.py similarity index 93% rename from test/unittests/skills/test_skill/__init__.py rename to test/unittests/skills/test_mycroft_skill/test_skill/__init__.py index e31f4997..2dff65b1 100644 --- a/test/unittests/skills/test_skill/__init__.py +++ b/test/unittests/skills/test_mycroft_skill/test_skill/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from mycroft.skills.core import MycroftSkill +from ovos_workshop.skills import MycroftSkill class LoadTestSkill(MycroftSkill): diff --git a/test/unittests/skills/test_skill/dialog/en-us/what do you want.dialog b/test/unittests/skills/test_mycroft_skill/test_skill/dialog/en-us/what do you want.dialog similarity index 100% rename from test/unittests/skills/test_skill/dialog/en-us/what do you want.dialog rename to test/unittests/skills/test_mycroft_skill/test_skill/dialog/en-us/what do you want.dialog diff --git a/test/unittests/skills/translate/in-dialog/dialog/en-us/good_things.list b/test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/good_things.list similarity index 100% rename from test/unittests/skills/translate/in-dialog/dialog/en-us/good_things.list rename to test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/good_things.list diff --git a/test/unittests/skills/translate/in-dialog/dialog/en-us/named_things.value b/test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/named_things.value similarity index 100% rename from test/unittests/skills/translate/in-dialog/dialog/en-us/named_things.value rename to test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/named_things.value diff --git a/test/unittests/skills/translate/in-dialog/dialog/en-us/test.template b/test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/test.template similarity index 100% rename from test/unittests/skills/translate/in-dialog/dialog/en-us/test.template rename to test/unittests/skills/test_mycroft_skill/translate/in-dialog/dialog/en-us/test.template diff --git a/test/unittests/skills/translate/in-locale/locale/de-de/good_things.list b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/good_things.list similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/de-de/good_things.list rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/good_things.list diff --git a/test/unittests/skills/translate/in-locale/locale/de-de/named_things.value b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/named_things.value similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/de-de/named_things.value rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/named_things.value diff --git a/test/unittests/skills/translate/in-locale/locale/de-de/test.template b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/test.template similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/de-de/test.template rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/de-de/test.template diff --git a/test/unittests/skills/translate/in-locale/locale/en-us/good_things.list b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/good_things.list similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/en-us/good_things.list rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/good_things.list diff --git a/test/unittests/skills/translate/in-locale/locale/en-us/named_things.value b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/named_things.value similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/en-us/named_things.value rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/named_things.value diff --git a/test/unittests/skills/translate/in-locale/locale/en-us/not_in_german.list b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/not_in_german.list similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/en-us/not_in_german.list rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/not_in_german.list diff --git a/test/unittests/skills/translate/in-locale/locale/en-us/test.template b/test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/test.template similarity index 100% rename from test/unittests/skills/translate/in-locale/locale/en-us/test.template rename to test/unittests/skills/test_mycroft_skill/translate/in-locale/locale/en-us/test.template diff --git a/test/unittests/skills/test_ovos.py b/test/unittests/skills/test_ovos.py new file mode 100644 index 00000000..d43ab520 --- /dev/null +++ b/test/unittests/skills/test_ovos.py @@ -0,0 +1,156 @@ +import unittest + +from ovos_utils.process_utils import RuntimeRequirements +from ovos_utils.messagebus import FakeBus +from ovos_utils import classproperty +from ovos_workshop import IntentLayers +from ovos_workshop.resource_files import SkillResources + +from ovos_workshop.settings import SkillSettingsManager +from ovos_workshop.skills.ovos import OVOSSkill + + +class OfflineSkill(OVOSSkill): + @classproperty + def runtime_requirements(self): + return RuntimeRequirements(internet_before_load=False, + network_before_load=False, + requires_internet=False, + requires_network=False, + no_internet_fallback=True, + no_network_fallback=True) + + +class LANSkill(OVOSSkill): + @classproperty + def runtime_requirements(self): + scans_on_init = True + return RuntimeRequirements(internet_before_load=False, + network_before_load=scans_on_init, + requires_internet=False, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=False) + + +class MockSkill(OVOSSkill): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TestOVOSSkill(unittest.TestCase): + bus = FakeBus() + skill = OVOSSkill(bus=bus, skill_id="test_ovos_skill") + + def test_00_skill_init(self): + from ovos_utils.skills.audioservice import AudioServiceInterface + self.assertIsInstance(self.skill.private_settings, dict) + self.assertIsInstance(self.skill._threads, list) + self.assertIsNotNone(self.skill._original_converse) + self.assertIsInstance(self.skill.intent_layers, IntentLayers) + self.assertIsInstance(self.skill.audio_service, AudioServiceInterface) + self.assertTrue(self.skill.is_fully_initialized) + self.assertFalse(self.skill.stop_is_implemented) + self.assertFalse(self.skill.converse_is_implemented) + self.assertIsInstance(self.skill.core_lang, str) + self.assertIsInstance(self.skill.secondary_langs, list) + self.assertIsInstance(self.skill.native_langs, list) + self.assertIsInstance(self.skill.alphanumeric_skill_id, str) + self.assertIsInstance(self.skill.resources, SkillResources) + + def test_activate(self): + # TODO + pass + + def test_deactivate(self): + # TODO + pass + + def test_play_audio(self): + # TODO + pass + + def test_load_lang(self): + # TODO + pass + + def test_voc_match(self): + # TODO + pass + + def test_voc_list(self): + # TODO + pass + + def test_remove_voc(self): + # TODO + pass + + def test_register_decorated(self): + # TODO + pass + + def test_register_intent_layer(self): + # TODO + pass + + def test_send_stop_signal(self): + # TODO + pass + + def test_settings_manager_init(self): + bus = FakeBus() + skill_default = MockSkill(bus=bus) + skill_default._startup(bus) + + self.assertIsInstance(skill_default.settings_manager, + SkillSettingsManager) + + skill_disabled_settings = MockSkill(bus=bus, + enable_settings_manager=False) + skill_disabled_settings._startup(bus) + self.assertIsNone(skill_disabled_settings.settings_manager) + + def test_bus_setter(self): + bus = FakeBus() + skill = MockSkill() + skill._startup(bus) + self.assertEqual(skill.bus, bus) + new_bus = FakeBus() + skill.bus = new_bus + self.assertEqual(skill.bus, new_bus) + with self.assertRaises(TypeError): + skill.bus = None + + def test_runtime_requirements(self): + self.assertEqual(OfflineSkill.runtime_requirements, + RuntimeRequirements(internet_before_load=False, + network_before_load=False, + requires_internet=False, + requires_network=False, + no_internet_fallback=True, + no_network_fallback=True) + ) + self.assertEqual(LANSkill.runtime_requirements, + RuntimeRequirements(internet_before_load=False, + network_before_load=True, + requires_internet=False, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=False) + ) + self.assertEqual(OVOSSkill.runtime_requirements, + RuntimeRequirements()) + + def test_class_inheritance(self): + from ovos_workshop.skills.base import BaseSkill + from ovos_workshop.skills.ovos import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill + from ovos_workshop.app import OVOSAbstractApplication + + skill = MockSkill() + self.assertIsInstance(skill, BaseSkill) + self.assertIsInstance(skill, OVOSSkill) + self.assertIsInstance(skill, MycroftSkill) + self.assertNotIsInstance(skill, OVOSAbstractApplication) + diff --git a/test/unittests/test_abstract_app.py b/test/unittests/test_abstract_app.py index 17773288..1cf65145 100644 --- a/test/unittests/test_abstract_app.py +++ b/test/unittests/test_abstract_app.py @@ -2,6 +2,7 @@ from os.path import join, dirname from os import remove +from unittest.mock import Mock, patch from ovos_utils.gui import GUIInterface from ovos_utils.messagebus import FakeBus @@ -25,11 +26,11 @@ class TestApp(unittest.TestCase): gui = GUIInterface("TestApplication") - @classmethod - def setUpClass(cls) -> None: - cls.app = Application(skill_id="TestApplication", - settings=cls.settings_obj, gui=cls.gui) - cls.app._startup(cls.bus) + app = Application(skill_id="TestApplication", settings=settings_obj, + gui=gui, bus=bus) + + def test_settings_manager_init(self): + self.assertIsNone(self.app.settings_manager) def test_settings_init(self): self.assertNotEqual(self.app.settings, self.settings_obj) @@ -40,9 +41,8 @@ def test_settings_init(self): self.assertFalse(self.app.settings['updated']) def test_settings_init_invalid_arg(self): - app = Application(skill_id="TestApplication", + app = Application(skill_id="TestApplication", bus=self.bus, settings=self.settings) - app._startup(self.bus) self.assertNotEqual(app.settings, self.settings) self.assertFalse(app.settings['__mycroft_skill_firstrun']) @@ -53,14 +53,10 @@ def test_settings_path(self): self.assertIn("/apps/", self.app._settings_path) # Test settings path conflicts - test_app = OVOSAbstractApplication(skill_id="test") + test_app = OVOSAbstractApplication(skill_id="test", bus=self.bus) from ovos_workshop.skills import OVOSSkill, MycroftSkill - test_skill = OVOSSkill() - mycroft_skill = MycroftSkill() - - test_app._startup(self.bus, "test") - test_skill._startup(self.bus, "test") - mycroft_skill._startup(self.bus, "test") + test_skill = OVOSSkill(skill_id="test", bus=self.bus) + mycroft_skill = MycroftSkill(skill_id="test", bus=self.bus) # Test app vs skill base directories self.assertIn("/apps/", test_app._settings_path) @@ -80,3 +76,36 @@ def test_settings_path(self): # Cleanup test files remove(test_app._settings_path) remove(test_skill._settings_path) + + @patch("ovos_workshop.app.OVOSSkill.default_shutdown") + def test_default_shutdown(self, skill_shutdown): + real_clear_intents = self.app.clear_intents + real_bus_close = self.app.bus.close + self.app.bus.close = Mock() + self.app.clear_intents = Mock() + self.app.default_shutdown() + self.app.clear_intents.assert_called_once() + self.app.bus.close.assert_not_called() # No dedicated bus here + skill_shutdown.assert_called_once() + + self.app.bus.close = real_bus_close + self.app.clear_intents = real_clear_intents + + def test_get_language_dir(self): + # TODO + pass + + def test_clear_intents(self): + # TODO + pass + + def test_class_inheritance(self): + from ovos_workshop.skills.base import BaseSkill + from ovos_workshop.skills.ovos import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill + from ovos_workshop.app import OVOSAbstractApplication + + self.assertIsInstance(self.app, BaseSkill) + self.assertIsInstance(self.app, OVOSSkill) + self.assertIsInstance(self.app, MycroftSkill) + self.assertIsInstance(self.app, OVOSAbstractApplication) diff --git a/test/unittests/test_abort.py b/test/unittests/test_decorators.py similarity index 58% rename from test/unittests/test_abort.py rename to test/unittests/test_decorators.py index b1fc7fce..18fec9bf 100644 --- a/test/unittests/test_abort.py +++ b/test/unittests/test_decorators.py @@ -1,12 +1,89 @@ import json import unittest from os.path import dirname +from unittest.mock import Mock from time import sleep -from mycroft.skills.skill_loader import SkillLoader +from ovos_workshop.skill_launcher import SkillLoader from ovos_utils.messagebus import FakeBus, Message -from ovos_workshop.skills.mycroft_skill import is_classic_core + +class TestDecorators(unittest.TestCase): + def test_adds_context(self): + from ovos_workshop.decorators import adds_context + # TODO + + def test_removes_context(self): + from ovos_workshop.decorators import removes_context + # TODO + + def test_intent_handler(self): + from ovos_workshop.decorators import intent_handler + mock_intent = Mock() + called = False + + @intent_handler(mock_intent) + @intent_handler("test_intent") + def test_handler(): + nonlocal called + called = True + + self.assertEqual(test_handler.intents, ["test_intent", mock_intent]) + self.assertFalse(called) + + def test_resting_screen_handler(self): + from ovos_workshop.decorators import resting_screen_handler + called = False + + @resting_screen_handler("test_homescreen") + def show_homescreen(): + nonlocal called + called = True + + self.assertEqual(show_homescreen.resting_handler, "test_homescreen") + self.assertFalse(called) + + def test_skill_api_method(self): + from ovos_workshop.decorators import skill_api_method + called = False + + @skill_api_method + def api_method(): + nonlocal called + called = True + + self.assertTrue(api_method.api_method) + self.assertFalse(called) + + def test_converse_handler(self): + from ovos_workshop.decorators import converse_handler + called = False + + @converse_handler + def handle_converse(): + nonlocal called + called = True + + self.assertTrue(handle_converse.converse) + self.assertFalse(called) + + def test_fallback_handler(self): + from ovos_workshop.decorators import fallback_handler + called = False + + @fallback_handler() + def medium_prio_fallback(): + nonlocal called + called = True + + @fallback_handler(1) + def high_prio_fallback(): + nonlocal called + called = True + + self.assertEqual(medium_prio_fallback.fallback_priority, 50) + self.assertEqual(high_prio_fallback.fallback_priority, 1) + self.assertFalse(called) class TestKillableIntents(unittest.TestCase): @@ -117,7 +194,8 @@ def test_get_response(self): confirm only get_response is aborted, speech after is still spoken""" self.bus.emitted_msgs = [] # skill will enter a infinite loop unless aborted - self.bus.emit(Message(f"{self.skill.skill_id}:test2.intent")) + self.bus.emit(Message(f"{self.skill.skill_id}:test2.intent", + context={"session": {"session_id": "123"}})) sleep(2) # check that intent triggered start_msg = {'type': 'mycroft.skill.handler.start', @@ -127,10 +205,12 @@ def test_get_response(self): 'expect_response': True, 'meta': {'dialog': 'question', 'data': {}, 'skill': 'abort.test'}, 'lang': 'en-us'}} - if not is_classic_core(): - activate_msg = {'type': 'intent.service.skills.activate', 'data': {'skill_id': 'abort.test'}} - else: - activate_msg = {'type': 'active_skill_request', 'data': {'skill_id': 'abort.test'}} + activate_msg = {'type': 'intent.service.skills.activate', 'data': {'skill_id': 'abort.test'}} + + sleep(0.5) # fake wait_while_speaking + self.bus.emit(Message(f"recognizer_loop:audio_output_end", + context={"session": {"session_id": "123"}})) + sleep(1) # get_response is in a thread so it can be killed, let it capture msg above self.assertIn(start_msg, self.bus.emitted_msgs) self.assertIn(speak_msg, self.bus.emitted_msgs) @@ -204,3 +284,127 @@ def test_developer_stop_msg(self): self.bus.emitted_msgs = [] sleep(2) self.assertTrue(self.bus.emitted_msgs == []) + + def test_killable_event(self): + from ovos_workshop.decorators.killable import killable_event + # TODO + + +class TestLayers(unittest.TestCase): + def test_dig_for_skill(self): + from ovos_workshop.decorators.layers import dig_for_skill + # TODO + + def test_enables_layer(self): + from ovos_workshop.decorators.layers import enables_layer + # TODO + + def test_disables_layer(self): + from ovos_workshop.decorators.layers import disables_layer + # TODO + + def test_replaces_layer(self): + from ovos_workshop.decorators.layers import replaces_layer + # TODO + + def test_removes_layer(self): + from ovos_workshop.decorators.layers import removes_layer + # TODO + + def test_resets_layers(self): + from ovos_workshop.decorators.layers import resets_layers + # TODO + + def test_layer_intent(self): + from ovos_workshop.decorators.layers import layer_intent + # TODO + + def test_intent_layers(self): + from ovos_workshop.decorators.layers import IntentLayers + # TODO + + +class TestOCP(unittest.TestCase): + def test_ocp_search(self): + from ovos_workshop.decorators.ocp import ocp_search + called = False + + @ocp_search() + def test_search(): + nonlocal called + called = True + + self.assertTrue(test_search.is_ocp_search_handler) + self.assertFalse(called) + + def test_ocp_play(self): + from ovos_workshop.decorators.ocp import ocp_play + called = False + + @ocp_play() + def test_play(): + nonlocal called + called = True + + self.assertTrue(test_play.is_ocp_playback_handler) + self.assertFalse(called) + + def test_ocp_previous(self): + from ovos_workshop.decorators.ocp import ocp_previous + called = False + + @ocp_previous() + def test_previous(): + nonlocal called + called = True + + self.assertTrue(test_previous.is_ocp_prev_handler) + self.assertFalse(called) + + def test_ocp_next(self): + from ovos_workshop.decorators.ocp import ocp_next + called = False + + @ocp_next() + def test_next(): + nonlocal called + called = True + + self.assertTrue(test_next.is_ocp_next_handler) + self.assertFalse(called) + + def test_ocp_pause(self): + from ovos_workshop.decorators.ocp import ocp_pause + called = False + + @ocp_pause() + def test_pause(): + nonlocal called + called = True + + self.assertTrue(test_pause.is_ocp_pause_handler) + self.assertFalse(called) + + def test_ocp_resume(self): + from ovos_workshop.decorators.ocp import ocp_resume + called = False + + @ocp_resume() + def test_resume(): + nonlocal called + called = True + + self.assertTrue(test_resume.is_ocp_resume_handler) + self.assertFalse(called) + + def test_ocp_featured_media(self): + from ovos_workshop.decorators.ocp import ocp_featured_media + called = False + + @ocp_featured_media() + def test_featured_media(): + nonlocal called + called = True + + self.assertTrue(test_featured_media.is_ocp_featured_handler) + self.assertFalse(called) diff --git a/test/unittests/test_filesystem.py b/test/unittests/test_filesystem.py new file mode 100644 index 00000000..d91a8278 --- /dev/null +++ b/test/unittests/test_filesystem.py @@ -0,0 +1,38 @@ +import unittest +import shutil +from os import environ +from os.path import join, dirname, isdir +from ovos_workshop.filesystem import FileSystemAccess + + +class TestFilesystem(unittest.TestCase): + test_data_path = join(dirname(__file__), "xdg_data") + + @classmethod + def setUpClass(cls) -> None: + environ['XDG_DATA_HOME'] = cls.test_data_path + + @classmethod + def tearDownClass(cls) -> None: + data_path = environ.pop('XDG_DATA_HOME') + if isdir(data_path): + shutil.rmtree(data_path) + + def test_filesystem(self): + fs = FileSystemAccess("test") + + # FS path init + self.assertEqual(fs.path, join(self.test_data_path, "mycroft", + "filesystem", "test")) + self.assertTrue(isdir(fs.path)) + + # Invalid open + with self.assertRaises(FileNotFoundError): + fs.open("test.txt", "r") + self.assertFalse(fs.exists("test.txt")) + + # Valid file creation + file = fs.open("test.txt", "w+") + self.assertIsNotNone(file) + file.close() + self.assertTrue(fs.exists("test.txt")) diff --git a/test/unittests/test_permissions.py b/test/unittests/test_permissions.py new file mode 100644 index 00000000..c7095231 --- /dev/null +++ b/test/unittests/test_permissions.py @@ -0,0 +1,21 @@ +import unittest + +from ovos_workshop.permissions import ConverseMode, FallbackMode, ConverseActivationMode + + +class TestPermissions(unittest.TestCase): + def test_converse_mode(self): + self.assertIsInstance(ConverseMode.ACCEPT_ALL, str) + self.assertIsInstance(ConverseMode.WHITELIST, str) + self.assertIsInstance(ConverseMode.BLACKLIST, str) + + def test_fallback_mode(self): + self.assertIsInstance(FallbackMode.ACCEPT_ALL, str) + self.assertIsInstance(FallbackMode.WHITELIST, str) + self.assertIsInstance(FallbackMode.BLACKLIST, str) + + def test_converse_activation_mode(self): + self.assertIsInstance(ConverseActivationMode.ACCEPT_ALL, str) + self.assertIsInstance(ConverseActivationMode.PRIORITY, str) + self.assertIsInstance(ConverseActivationMode.WHITELIST, str) + self.assertIsInstance(ConverseActivationMode.BLACKLIST, str) diff --git a/test/unittests/test_resource_files.py b/test/unittests/test_resource_files.py new file mode 100644 index 00000000..fefb7339 --- /dev/null +++ b/test/unittests/test_resource_files.py @@ -0,0 +1,190 @@ +import unittest +import shutil + +from os import environ +from os.path import isdir, join, dirname + + +class TestResourceFiles(unittest.TestCase): + def test_locate_base_directories(self): + from ovos_workshop.resource_files import locate_base_directories + # TODO + + def test_locate_lang_directories(self): + from ovos_workshop.resource_files import locate_lang_directories + # TODO + + def test_resolve_resource_file(self): + from ovos_workshop.resource_files import resolve_resource_file + # TODO + + def test_find_resource(self): + from ovos_workshop.resource_files import find_resource + + +class TestResourceType(unittest.TestCase): + from ovos_workshop.resource_files import ResourceType + # TODO + + +class TestResourceFile(unittest.TestCase): + def test_resource_file(self): + from ovos_workshop.resource_files import ResourceFile + # TODO + + def test_qml_file(self): + from ovos_workshop.resource_files import QmlFile, ResourceFile + self.assertTrue(issubclass(QmlFile, ResourceFile)) + # TODO: test locate/load + + def test_dialog_file(self): + from ovos_workshop.resource_files import DialogFile, ResourceFile + self.assertTrue(issubclass(DialogFile, ResourceFile)) + # TODO: test load/render + + def test_vocab_file(self): + from ovos_workshop.resource_files import VocabularyFile, ResourceFile + self.assertTrue(issubclass(VocabularyFile, ResourceFile)) + # TODO test load + + def test_named_value_file(self): + from ovos_workshop.resource_files import NamedValueFile, ResourceFile + self.assertTrue(issubclass(NamedValueFile, ResourceFile)) + # TODO test load/_load_line + + def test_list_file(self): + from ovos_workshop.resource_files import ListFile, ResourceFile + self.assertTrue(issubclass(ListFile, ResourceFile)) + + def test_template_file(self): + from ovos_workshop.resource_files import TemplateFile, ResourceFile + self.assertTrue(issubclass(TemplateFile, ResourceFile)) + + def test_regex_file(self): + from ovos_workshop.resource_files import RegexFile, ResourceFile + self.assertTrue(issubclass(RegexFile, ResourceFile)) + # TODO: Test load + + def test_word_file(self): + from ovos_workshop.resource_files import WordFile, ResourceFile + self.assertTrue(issubclass(WordFile, ResourceFile)) + # TODO: Test load + + +class TestSkillResources(unittest.TestCase): + from ovos_workshop.resource_files import SkillResources + test_data_path = join(dirname(__file__), "xdg_data") + + @classmethod + def setUpClass(cls) -> None: + environ['XDG_DATA_HOME'] = cls.test_data_path + + @classmethod + def tearDownClass(cls) -> None: + data_path = environ.pop('XDG_DATA_HOME') + if isdir(data_path): + shutil.rmtree(data_path) + + def test_load_dialog_renderer(self): + # TODO + pass + + def test_define_resource_types(self): + # TODO + pass + + def test_load_dialog_file(self): + # TODO + pass + + def test_locate_qml_file(self): + # TODO + pass + + def test_load_list_file(self): + # TODO + pass + + def test_load_named_value_file(self): + # TODO + pass + + def test_load_regex_file(self): + # TODO + pass + + def test_load_template_file(self): + # TODO + pass + + def test_load_vocabulary_file(self): + # TODO + pass + + def test_load_word_file(self): + # TODO + pass + + def test_render_dialog(self): + # TODO + pass + + def test_load_skill_vocabulary(self): + # TODO + pass + + def test_load_skill_regex(self): + # TODO + pass + + def test_make_unique_regex_group(self): + # TODO + pass + + +class TestCoreResources(unittest.TestCase): + test_data_path = join(dirname(__file__), "xdg_data") + + @classmethod + def setUpClass(cls) -> None: + environ['XDG_DATA_HOME'] = cls.test_data_path + @classmethod + def tearDownClass(cls) -> None: + data_path = environ.pop('XDG_DATA_HOME') + if isdir(data_path): + shutil.rmtree(data_path) + + def test_core_resources(self): + from ovos_workshop.resource_files import CoreResources, SkillResources + core_res = CoreResources("en-us") + self.assertIsInstance(core_res, SkillResources) + self.assertEqual(core_res.language, "en-us") + self.assertTrue(isdir(core_res.skill_directory)) + + +class TestUserResources(unittest.TestCase): + test_data_path = join(dirname(__file__), "xdg_data") + + @classmethod + def setUpClass(cls) -> None: + environ['XDG_DATA_HOME'] = cls.test_data_path + + @classmethod + def tearDownClass(cls) -> None: + data_path = environ.pop('XDG_DATA_HOME') + if isdir(data_path): + shutil.rmtree(data_path) + + def test_user_resources(self): + from ovos_workshop.resource_files import UserResources, SkillResources + user_res = UserResources("en-us", "test.skill") + self.assertIsInstance(user_res, SkillResources) + self.assertEqual(user_res.language, "en-us") + self.assertEqual(user_res.skill_directory, + join(self.test_data_path, "mycroft", "resources", + "test.skill")) + + +class TestRegexExtractor(unittest.TestCase): + from ovos_workshop.resource_files import RegexExtractor + # TODO diff --git a/test/unittests/test_settings.py b/test/unittests/test_settings.py new file mode 100644 index 00000000..e27173bb --- /dev/null +++ b/test/unittests/test_settings.py @@ -0,0 +1,7 @@ +import unittest + + +class TestSettings(unittest.TestCase): + from ovos_workshop.settings import SkillSettingsManager + # TODO + diff --git a/test/unittests/test_skill.py b/test/unittests/test_skill.py index f0cccc7f..c1f25234 100644 --- a/test/unittests/test_skill.py +++ b/test/unittests/test_skill.py @@ -2,14 +2,74 @@ import unittest from unittest.mock import Mock -from mycroft_bus_client import Message +from ovos_bus_client import Message from ovos_workshop.skills.ovos import OVOSSkill from ovos_workshop.skills.mycroft_skill import MycroftSkill, is_classic_core from mycroft.skills import MycroftSkill as CoreSkill from ovos_utils.messagebus import FakeBus from os.path import dirname -from mycroft.skills.skill_loader import SkillLoader +from ovos_workshop.skill_launcher import SkillLoader + + +class LegacySkill(CoreSkill): + def __init__(self, skill_name="LegacySkill", bus=None, **kwargs): + self.inited = True + self.initialized = False + self.startup_called = False + super().__init__(skill_name, bus, **kwargs) + # __new__ calls `_startup` so this should be defined in __init__ + assert self.skill_id is not None + + def initialize(self): + self.initialized = True + + def _startup(self, bus, skill_id=""): + self.startup_called = True + self.initialize() + + +class BadLegacySkill(LegacySkill): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + print(self.bus) # not set, exception in property + + +class GoodLegacySkill(CoreSkill): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + print(self.bus) # maybe not set, exception in property + + +class SpecificArgsSkill(OVOSSkill): + def __init__(self, skill_id="SpecificArgsSkill", bus=None, **kwargs): + self.inited = True + self.initialized = False + self.startup_called = False + super().__init__(skill_id=skill_id, bus=bus, **kwargs) + self.kwargs = kwargs + + def initialize(self): + self.initialized = True + + def _startup(self, bus, skill_id=""): + self.startup_called = True + self.initialize() + + +class KwargSkill(OVOSSkill): + def __init__(self, **kwargs): + self.inited = True + self.initialized = False + self.startup_called = False + super().__init__(**kwargs) + + def initialize(self): + self.initialized = True + + def _startup(self, bus, skill_id=""): + self.startup_called = True + self.initialize() class TestSkill(unittest.TestCase): @@ -101,3 +161,49 @@ def test_stop(self): def tearDown(self) -> None: self.skill.unload() + + +class TestSkillNew(unittest.TestCase): + def test_legacy(self): + bus = FakeBus() + + # a legacy skill accepts wrong args, but accepts kwargs + legacy = LegacySkill("LegacyName", bus, skill_id="legacy.mycroft") + self.assertTrue(legacy.inited) + self.assertTrue(legacy.initialized) + self.assertTrue(legacy.startup_called) + self.assertIsNotNone(legacy.skill_id) + self.assertEqual(legacy.bus, bus) + + # a legacy skill not accepting args at all + with self.assertRaises(Exception) as ctxt: + BadLegacySkill() # accesses self.bus in __init__ + self.assertTrue("Accessed MycroftSkill.bus in __init__" in str(ctxt.exception)) + + legacynoargs = LegacySkill() # no exception this time because bus is not used in init + self.assertTrue(legacynoargs.inited) + self.assertFalse(legacynoargs.initialized) + self.assertFalse(legacynoargs.startup_called) + + # a legacy skill fully inited at once + legacy = GoodLegacySkill(skill_id="legacy.mycroft", bus=bus) # accesses self.bus in __init__ + self.assertEqual(legacy.skill_id, "legacy.mycroft") + self.assertEqual(legacy.bus, bus) + + def test_load(self): + bus = FakeBus() + kwarg = KwargSkill(skill_id="kwarg", bus=bus) + self.assertTrue(kwarg.inited) + self.assertTrue(kwarg.initialized) + self.assertTrue(kwarg.startup_called) + self.assertEqual(kwarg.skill_id, "kwarg") + self.assertEqual(kwarg.bus, bus) + + gui = Mock() + args = SpecificArgsSkill("args", bus, gui=gui) + self.assertTrue(args.inited) + self.assertTrue(args.initialized) + self.assertTrue(args.startup_called) + self.assertEqual(args.skill_id, "args") + self.assertEqual(args.bus, bus) + self.assertEqual(args.gui, gui) diff --git a/test/unittests/test_skill_classes.py b/test/unittests/test_skill_classes.py deleted file mode 100644 index 15eb0164..00000000 --- a/test/unittests/test_skill_classes.py +++ /dev/null @@ -1,128 +0,0 @@ -import unittest - -from unittest.mock import Mock -from ovos_workshop import OVOSAbstractApplication -from ovos_workshop.decorators import classproperty -from ovos_workshop.skills.ovos import OVOSSkill -from ovos_utils.process_utils import RuntimeRequirements -from ovos_workshop.skills.mycroft_skill import is_classic_core - - -class OfflineSkill(OVOSSkill): - @classproperty - def runtime_requirements(self): - return RuntimeRequirements(internet_before_load=False, - network_before_load=False, - requires_internet=False, - requires_network=False, - no_internet_fallback=True, - no_network_fallback=True) - - -class LANSkill(OVOSSkill): - @classproperty - def runtime_requirements(self): - scans_on_init = True - return RuntimeRequirements(internet_before_load=False, - network_before_load=scans_on_init, - requires_internet=False, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=False) - - -class TestSkill(OVOSSkill): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class TestApplication(OVOSAbstractApplication): - def __init__(self, *args, **kwargs): - super().__init__(skill_id="Test Application", *args, **kwargs) - - -class TestSkills(unittest.TestCase): - - def test_settings_manager_init(self): - from ovos_utils.messagebus import FakeBus - bus = FakeBus() - skill_default = TestSkill(bus=bus) - skill_default._startup(bus) - # This doesn't apply to `mycroft-core`, only `ovos-core` - if not is_classic_core(): - from mycroft.skills.settings import SkillSettingsManager - self.assertIsInstance(skill_default.settings_manager, SkillSettingsManager) - - skill_disabled_settings = TestSkill(bus=bus, - enable_settings_manager=False) - skill_disabled_settings._startup(bus) - self.assertIsNone(skill_disabled_settings.settings_manager) - - plugin = TestApplication(bus=bus) - plugin._startup(bus) - self.assertIsNone(plugin.settings_manager) - - def test_bus_setter(self): - from ovos_utils.messagebus import FakeBus - bus = FakeBus() - skill = TestSkill() - skill._startup(bus) - self.assertEqual(skill.bus, bus) - new_bus = FakeBus() - skill.bus = new_bus - self.assertEqual(skill.bus, new_bus) - with self.assertRaises(TypeError): - skill.bus = None - - def test_class_property(self): - self.assertEqual(OfflineSkill.runtime_requirements, - RuntimeRequirements(internet_before_load=False, - network_before_load=False, - requires_internet=False, - requires_network=False, - no_internet_fallback=True, - no_network_fallback=True) - ) - self.assertEqual(LANSkill.runtime_requirements, - RuntimeRequirements(internet_before_load=False, - network_before_load=True, - requires_internet=False, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=False) - ) - self.assertEqual(OVOSSkill.runtime_requirements, - RuntimeRequirements() - ) - - def test_class_inheritance(self): - from ovos_workshop.skills.base import BaseSkill - from ovos_workshop.skills.ovos import OVOSSkill - from ovos_workshop.skills.mycroft_skill import MycroftSkill - from ovos_workshop.skills.fallback import FallbackSkill - from ovos_workshop.app import OVOSAbstractApplication - - skill = TestSkill() - self.assertIsInstance(skill, BaseSkill) - self.assertIsInstance(skill, OVOSSkill) - self.assertIsInstance(skill, MycroftSkill) - self.assertNotIsInstance(skill, OVOSAbstractApplication) - - app = TestApplication() - self.assertIsInstance(app, BaseSkill) - self.assertIsInstance(app, OVOSSkill) - self.assertIsInstance(app, MycroftSkill) - self.assertIsInstance(app, OVOSAbstractApplication) - - mycroft_skill = MycroftSkill() - self.assertIsInstance(mycroft_skill, BaseSkill) - self.assertIsInstance(mycroft_skill, MycroftSkill) - self.assertNotIsInstance(mycroft_skill, OVOSSkill) - self.assertNotIsInstance(mycroft_skill, OVOSAbstractApplication) - - fallback = FallbackSkill("test") - self.assertIsInstance(fallback, BaseSkill) - self.assertIsInstance(fallback, OVOSSkill) - self.assertIsInstance(fallback, MycroftSkill) - self.assertIsInstance(fallback, FallbackSkill) - self.assertNotIsInstance(fallback, OVOSAbstractApplication) diff --git a/test/unittests/test_skill_launcher.py b/test/unittests/test_skill_launcher.py new file mode 100644 index 00000000..e69280e9 --- /dev/null +++ b/test/unittests/test_skill_launcher.py @@ -0,0 +1,203 @@ +import shutil +import unittest +import sys + +from os import environ +from os.path import basename, join, dirname, isdir + +from ovos_utils.messagebus import FakeBus + + +class TestSkillLauncherFunctions(unittest.TestCase): + test_data_path = join(dirname(__file__), "xdg_data") + + @classmethod + def setUpClass(cls) -> None: + environ['XDG_DATA_HOME'] = cls.test_data_path + + @classmethod + def tearDownClass(cls) -> None: + data_path = environ.pop('XDG_DATA_HOME') + if isdir(data_path): + shutil.rmtree(data_path) + + def test_get_skill_directories(self): + from ovos_workshop.skill_launcher import get_skill_directories + # Default directory + mock_config = {'skills': {}} + default_directories = get_skill_directories(mock_config) + for directory in default_directories: + self.assertEqual(basename(directory), 'skills') + # Configured directory + mock_config['skills']['directory'] = 'test' + test_directories = get_skill_directories(mock_config) + for directory in test_directories: + self.assertEqual(basename(directory), 'test') + self.assertEqual(len(default_directories), len(test_directories)) + + def test_get_default_skills_directory(self): + from ovos_workshop.skill_launcher import get_default_skills_directory + # Default directory + mock_config = {'skills': {}} + default_dir = get_default_skills_directory(mock_config) + self.assertTrue(isdir(default_dir)) + self.assertEqual(basename(default_dir), 'skills') + self.assertEqual(dirname(dirname(default_dir)), self.test_data_path) + # Override directory + mock_config['skills']['directory'] = 'test' + test_dir = get_default_skills_directory(mock_config) + self.assertTrue(isdir(test_dir)) + self.assertEqual(basename(test_dir), 'test') + self.assertEqual(dirname(dirname(test_dir)), self.test_data_path) + + def test_remove_submodule_refs(self): + from ovos_workshop.skill_launcher import remove_submodule_refs + pass + + def test_load_skill_module(self): + from ovos_workshop.skill_launcher import load_skill_module + test_path = join(dirname(__file__), "ovos_tskill_abort", + "__init__.py") + skill_id = "test_skill.test" + module = load_skill_module(test_path, skill_id) + self.assertIn("test_skill_test", sys.modules) + self.assertIsNotNone(module) + self.assertTrue(callable(module.create_skill)) + + def test_get_skill_class(self): + from ovos_workshop.skill_launcher import get_skill_class, \ + load_skill_module + from ovos_workshop.skills.mycroft_skill import _SkillMetaclass + test_path = join(dirname(__file__), "ovos_tskill_abort", + "__init__.py") + skill_id = "test_skill.test" + module = load_skill_module(test_path, skill_id) + skill = get_skill_class(module) + self.assertIsNotNone(skill) + self.assertEqual(skill.__class__, _SkillMetaclass, skill.__class__) + + # Test invalid request + with self.assertRaises(ValueError): + get_skill_class(None) + + def test_get_create_skill_function(self): + from ovos_workshop.skill_launcher import get_create_skill_function, \ + load_skill_module + test_path = join(dirname(__file__), "ovos_tskill_abort", + "__init__.py") + skill_id = "test_skill.test" + module = load_skill_module(test_path, skill_id) + func = get_create_skill_function(module) + self.assertIsNotNone(func) + self.assertEqual(func.__name__, "create_skill") + + def test_launch_script(self): + from ovos_workshop.skill_launcher import _launch_script + # TODO + + +class TestSkillLoader(unittest.TestCase): + bus = FakeBus() + + def test_skill_loader_init(self): + from ovos_workshop.skill_launcher import SkillLoader + from ovos_utils.process_utils import RuntimeRequirements + + loader = SkillLoader(self.bus) + self.assertEqual(loader.bus, self.bus) + self.assertIsNone(loader.loaded) + self.assertIsNone(loader.skill_directory) + self.assertIsNone(loader.skill_id) + self.assertIsNone(loader.skill_class) + self.assertEqual(loader.runtime_requirements, RuntimeRequirements()) + self.assertFalse(loader.is_blacklisted) + self.assertTrue(loader.reload_allowed) + + def test_skill_loader_reload(self): + from ovos_workshop.skill_launcher import SkillLoader + # TODO + + def test_skill_loader_load(self): + from ovos_workshop.skill_launcher import SkillLoader + # TODO + + def test__unload(self): + # TODO + pass + + def test_unload(self): + # TODO + pass + + def test_activate(self): + # TODO + pass + + def test_deactivate(self): + # TODO + pass + + def test_execute_instance_shutdown(self): + # TODO + pass + + def test_garbage_collect(self): + # TODO + pass + + def test_emit_skill_shutdown_event(self): + # TODO + pass + + def test__load(self): + # TODO + pass + + def test_start_filewatcher(self): + # TODO + pass + + def test_handle_filechange(self): + # TODO + pass + + def test_prepare_for_load(self): + # TODO + pass + + def test_skip_load(self): + # TODO + pass + + def test_load_skill_source(self): + # TODO + pass + + def test_create_skill_instance(self): + # TODO + pass + + def test_communicate_load_status(self): + # TODO + pass + + +class TestPluginSkillLoader(unittest.TestCase): + bus = FakeBus() + + def test_plugin_skill_loader_init(self): + from ovos_workshop.skill_launcher import PluginSkillLoader, SkillLoader + loader = PluginSkillLoader(self.bus, "test_skill.test") + self.assertIsInstance(loader, PluginSkillLoader) + self.assertIsInstance(loader, SkillLoader) + self.assertEqual(loader.bus, self.bus) + self.assertEqual(loader.skill_id, "test_skill.test") + + def test_plugin_skill_loader_load(self): + from ovos_workshop.skill_launcher import PluginSkillLoader + # TODO + + +class TestSkillContainer(unittest.TestCase): + # TODO + pass