diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4365205 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + labels: + - "type: dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "type: dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c11cebe..bfdfd95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,7 @@ on: - '.pre-commit-config.yaml' - '.pylintrc' - '.yamllint' - - '.secretlintrc.json' - - '.github/workflows/ci.yml' - - '.github/workflows/matchers/**' + - '.github/workflows/**' pull_request: paths: - 'src/**' @@ -27,349 +25,49 @@ on: - '.pre-commit-config.yaml' - '.pylintrc' - '.yamllint' - - '.secretlintrc.json' - - '.github/workflows/ci.yml' - - '.github/workflows/matchers/**' + - '.github/workflows/**' env: - CACHE_VERSION: 1 DEFAULT_PYTHON: "3.10" PRE_COMMIT_HOME: ~/.cache/pre-commit jobs: - # Separate job to pre-populate the base dependency cache - # This prevent upcoming jobs to do the same individually - prepare-base: - name: Prepare base dependencies + lint: + name: Lint runs-on: ubuntu-latest steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv + - name: Cache pre-commit environments uses: actions/cache@v4 with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}- - - name: Create Python virtual environment - if: steps.cache-venv.outputs.cache-hit != 'true' + path: ${{ env.PRE_COMMIT_HOME }} + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit- + - name: Install dependencies run: | sudo apt-get update && sudo apt-get install -y libxml2-dev libxslt1-dev python3-dev build-essential - python -m venv venv - . venv/bin/activate - pip install -U pip setuptools pip install -e ".[dev]" - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit- - - name: Install pre-commit dependencies - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - . venv/bin/activate - pre-commit install-hooks - - lint-bandit: - name: Check bandit - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Run bandit - run: | - . venv/bin/activate - pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure + - name: Run all pre-commit hooks + run: pre-commit run --all-files --show-diff-on-failure - lint-codespell: - name: Check codespell - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Register codespell problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/codespell.json" - - name: Run codespell - run: | - . venv/bin/activate - pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files - - lint-executable-shebangs: - name: Check executables - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Register check executables problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" - - name: Run executables check - run: | - . venv/bin/activate - pre-commit run --hook-stage manual check-executables-have-shebangs --all-files - - lint-json: - name: Check JSON - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Register check-json problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/check-json.json" - - name: Run check-json - run: | - . venv/bin/activate - pre-commit run --hook-stage manual check-json --all-files - - lint-ruff-check: - name: Check ruff - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Run ruff-check - run: | - . venv/bin/activate - pre-commit run ruff-check --all-files --show-diff-on-failure - - name: Run ruff-format - run: | - . venv/bin/activate - pre-commit run ruff-format --all-files --show-diff-on-failure - - lint-yaml: - name: Check YAML - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('pyproject.toml') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - - name: Register yamllint problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/yamllint.json" - - name: Run yamllint - run: | - . venv/bin/activate - pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure - pylint: - name: Check pylint + tests: + name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v5 - - name: Set up python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version: ${{ matrix.python-version }} + cache: 'pip' - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install pylint - - name: Analysing the code with pylint - run: | - python -m pylint --fail-under=10 `find -regextype egrep -regex '(.*.py)$' -not -path './graphify-out/*'` + sudo apt-get update && sudo apt-get install -y libxml2-dev libxslt1-dev python3-dev build-essential + pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ --tb=short --cov=src --cov-report=term-missing diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..4aa07a8 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -3,26 +3,14 @@ name: Claude Code Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write steps: @@ -38,7 +26,3 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 1e472ec..cd00346 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -23,7 +23,7 @@ jobs: pull-requests: read issues: read id-token: write - actions: read # Required for Claude to read CI results on PRs + actions: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -31,20 +31,9 @@ jobs: fetch-depth: 1 - name: Run Claude Code - id: claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr *)' - + claude_args: '--allowed-tools Bash(gh pr *)' diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index 49a45fa..cf2dee5 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -1,5 +1,4 @@ # Manual workflow to build and publish dev Python packages from non-master branches. -# Publishes to TestPyPI for development/testing purposes. name: Dev Publish diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 534aabe..7ed3420 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package to PyPI when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: @@ -28,14 +20,13 @@ jobs: - name: Build release distributions run: | - # NOTE: put your own distribution build steps here. python -m pip install build python -m build - name: Upload wheel to GitHub Release uses: ncipollo/release-action@v1 with: - artifacts: "dist/*.whl" # Path to your wheel file(s) + artifacts: "dist/*.whl" tag: ${{ github.ref_name }} allowUpdates: true @@ -50,19 +41,10 @@ jobs: needs: - release-build permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - - # Dedicated environments with protections for publishing are strongly recommended. - # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules environment: name: pypi - # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: url: https://pypi.org/project/pyhive-integration - # - # ALTERNATIVE: if your GitHub Release name is the PyPI project version string - # ALTERNATIVE: exactly, uncomment the following line instead: - # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} steps: - name: Retrieve release distributions diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d85ef23..54b1d6a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: args: [--fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell args: @@ -16,7 +16,7 @@ repos: - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/PyCQA/bandit - rev: 1.8.6 + rev: 1.9.4 hooks: - id: bandit args: @@ -26,7 +26,7 @@ repos: additional_dependencies: - pbr - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier @@ -50,13 +50,20 @@ repos: - id: detect-secrets args: ["--baseline", ".secrets.baseline"] - repo: https://github.com/pycqa/pylint - rev: v3.3.1 + rev: v4.0.5 hooks: - id: pylint args: [ "-rn", "-sn", ] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v2.0.0 + hooks: + - id: mypy + additional_dependencies: + - boto3-stubs + - types-requests - repo: local hooks: - id: check-data-pii diff --git a/.pylintrc b/.pylintrc index 8eac790..d0b0115 100644 --- a/.pylintrc +++ b/.pylintrc @@ -64,7 +64,7 @@ ignored-modules= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. -jobs=1 +jobs=0 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or @@ -85,9 +85,6 @@ py-version=3.10 # Discover python modules and packages in the file system subtree. recursive=no -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/.secrets.baseline b/.secrets.baseline index f9ca122..544e5c6 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -90,6 +90,10 @@ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 @@ -264,142 +268,142 @@ "filename": "src/api/hive_auth_async.py", "hashed_secret": "3e619ee0820ecf213c2f38c634e416b53defe3b0", "is_verified": false, - "line_number": 34 + "line_number": 35 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "b8e0d506d969f09a9af89ce89fd9759b72c63262", "is_verified": false, - "line_number": 35 + "line_number": 36 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "e97a751edc71e9afbe0c0f63ec94873392833f9f", "is_verified": false, - "line_number": 36 + "line_number": 37 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "92488c021dd524a2f4e116666b3645308fa0e35c", "is_verified": false, - "line_number": 37 + "line_number": 38 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "d4571e2f026f458aecd2950b0eb6aec190276177", "is_verified": false, - "line_number": 38 + "line_number": 39 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "8109d3c2f659f13cb61fc9e71eed574efe8c8fd8", "is_verified": false, - "line_number": 39 + "line_number": 40 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "08cac7461d7b624b88c53ee47da09cbbb84ea290", "is_verified": false, - "line_number": 40 + "line_number": 41 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "95523fea7e6136c6148299dcc3077debfa2976b3", "is_verified": false, - "line_number": 41 + "line_number": 42 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "c978fb77621e86f5e9077653fe5345ac1616b466", "is_verified": false, - "line_number": 42 + "line_number": 43 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "fc02990268ecf8a35a4912d60dab3754e5f43846", "is_verified": false, - "line_number": 43 + "line_number": 44 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "2c2c0ca491a73e95c8965b6641731057b65f6462", "is_verified": false, - "line_number": 44 + "line_number": 45 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "672b25c6be065170206f3fc6346ebb8e84cbb9d3", "is_verified": false, - "line_number": 45 + "line_number": 46 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "99d02e268ea3ee849fb6e359c6c1b019e4d07efd", "is_verified": false, - "line_number": 46 + "line_number": 47 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "e677fc4cb09d99e1e0d30af31f2e209e541e380e", "is_verified": false, - "line_number": 47 + "line_number": 48 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "05b69b06f40cae0c910a15b1ac75b1f7a847eccb", "is_verified": false, - "line_number": 48 + "line_number": 49 }, { "type": "Hex High Entropy String", "filename": "src/api/hive_auth_async.py", "hashed_secret": "c7f914bac2d66eb3f8ae3888fa47bf1ada6caaf5", "is_verified": false, - "line_number": 49 + "line_number": 50 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", "is_verified": false, - "line_number": 60 + "line_number": 61 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", "is_verified": false, - "line_number": 61 + "line_number": 62 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "351b174ccf89601f6f4bd3f3970a4aba7d17c98e", "is_verified": false, - "line_number": 64 + "line_number": 65 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", "is_verified": false, - "line_number": 120 + "line_number": 121 } ] }, - "generated_at": "2026-05-02T23:21:57Z" + "generated_at": "2026-05-09T10:54:47Z" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44448bc..409924a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,12 @@ ```bash git clone https://github.com/Pyhive/Pyhiveapi.git cd Pyhiveapi +make setup +``` + +Or manually: + +```bash pip install -e ".[dev]" pre-commit install ``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d269a42 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: setup test lint sync + +setup: + pip install -e ".[dev]" + pre-commit install + +test: + pytest tests/ + +lint: + pre-commit run --all-files + +sync: + python setup.py build_py diff --git a/README.md b/README.md index 2218faf..652df80 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pyhive-integration -![CodeQL](https://github.com/Pyhive/Pyhiveapi/workflows/CodeQL/badge.svg) ![Python Linting](https://github.com/Pyhive/Pyhiveapi/workflows/Python%20package/badge.svg) ![PyPI](https://img.shields.io/pypi/v/pyhive-integration) ![Python](https://img.shields.io/pypi/pyversions/pyhive-integration) ![License](https://img.shields.io/github/license/Pyhive/Pyhiveapi) +![CI](https://github.com/Pyhive/Pyhiveapi/actions/workflows/ci.yml/badge.svg) ![PyPI](https://img.shields.io/pypi/v/pyhive-integration) ![Python](https://img.shields.io/pypi/pyversions/pyhive-integration) ![License](https://img.shields.io/github/license/Pyhive/Pyhiveapi) A Python library for interfacing with the [Hive](https://www.hivehome.com/) smart home platform. Provides both async (`apyhiveapi`) and sync (`pyhiveapi`) APIs, and is designed primarily for use with [Home Assistant](https://www.home-assistant.io/) — though it works standalone too. diff --git a/SECURITY.md b/SECURITY.md index 7be7236..5eb51a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,15 @@ ## Supported Versions -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 0.3.x | :white_check_mark: | -| < 0.3 | :x: | +| Version | Supported | +| ------- | --------- | +| 2.x | ✅ | +| < 2.0 | ❌ | ## Reporting a Vulnerability -Use this section to tell people how to report a vulnerability. +Please **do not** open a public GitHub issue for security vulnerabilities. + +Report vulnerabilities privately via [GitHub's security advisory feature](https://github.com/Pyhive/Pyhiveapi/security/advisories/new). -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. +You can expect an acknowledgement within 48 hours and a fix or mitigation within 14 days for confirmed issues. We will credit reporters in the release notes unless you prefer to remain anonymous. diff --git a/pyproject.toml b/pyproject.toml index 7a454ce..37b4e30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,14 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", + "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "boto3>=1.16.10", @@ -25,7 +30,6 @@ dependencies = [ "requests", "aiohttp", "pyquery", - "loguru", ] [project.urls] @@ -37,9 +41,11 @@ Source = "https://github.com/Pyhive/Pyhiveapi" dev = [ "pytest", "pytest-asyncio", + "pytest-cov", "pylint", "ruff", "mypy", + "bandit", "pre-commit", "graphifyy", ] @@ -76,12 +82,19 @@ testpaths = ["tests"] source = ["src"] omit = ["tests/*"] +[tool.coverage.report] +show_missing = true +skip_covered = false + [tool.mypy] python_version = "3.10" show_error_codes = true -ignore_errors = true follow_imports = "silent" ignore_missing_imports = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["boto3", "boto3.*", "botocore", "botocore.*", "requests", "requests.*", "pyquery", "pyquery.*"] +ignore_missing_imports = true diff --git a/src/__init__.py b/src/__init__.py index 7b258a4..85f112b 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,12 +1,25 @@ """__init__.py.""" # pylint: skip-file +# ruff: noqa if __name__ == "pyhiveapi": - from .api.hive_api import HiveApi as API # noqa: F401 - from .api.hive_auth import HiveAuth as Auth # noqa: F401 + from .api.hive_api import HiveApi as API # type: ignore[assignment] + from .api.hive_auth import HiveAuth as Auth # type: ignore[assignment] else: - from .api.hive_async_api import HiveApiAsync as API # noqa: F401 - from .api.hive_auth_async import HiveAuthAsync as Auth # noqa: F401 + from .api.hive_async_api import HiveApiAsync as API # type: ignore[assignment] + from .api.hive_auth_async import HiveAuthAsync as Auth # type: ignore[assignment] -from .helper.const import SMS_REQUIRED # noqa: F401 -from .hive import Hive # noqa: F401 +from .helper.const import SMS_REQUIRED +from .helper.hive_exceptions import ( + HiveApiError, + HiveAuthError, + HiveFailedToRefreshTokens, + HiveInvalid2FACode, + HiveInvalidDeviceAuthentication, + HiveInvalidPassword, + HiveInvalidUsername, + HiveReauthRequired, + HiveRefreshTokenExpired, + HiveUnknownConfiguration, +) +from .hive import Hive diff --git a/src/action.py b/src/action.py index cce2abf..014bca2 100644 --- a/src/action.py +++ b/src/action.py @@ -2,8 +2,10 @@ import json import logging +from typing import Any from .helper.const import HTTP_OK +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -17,7 +19,7 @@ class HiveAction: action_type = "Actions" - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise Action. Args: @@ -25,7 +27,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_action(self, device: dict): + async def get_action(self, device: Device): """Action device to update. Args: @@ -48,7 +50,7 @@ async def get_action(self, device: dict): return self.session.set_cached_device(device) return "REMOVE" - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get action state. Args: @@ -67,7 +69,7 @@ async def get_state(self, device: dict): return final - async def _set_action_state(self, device: dict, enabled: bool) -> bool: + async def _set_action_state(self, device: Device, enabled: bool) -> bool: """Set action enabled/disabled state. Args: @@ -95,7 +97,7 @@ async def _set_action_state(self, device: dict, enabled: bool) -> bool: return final - async def set_status_on(self, device: dict): + async def set_status_on(self, device: Device): """Set action turn on. Args: @@ -106,7 +108,7 @@ async def set_status_on(self, device: dict): """ return await self._set_action_state(device, True) - async def set_status_off(self, device: dict): + async def set_status_off(self, device: Device): """Set action to turn off. Args: @@ -118,14 +120,14 @@ async def set_status_off(self, device: dict): return await self._set_action_state(device, False) # Backwards-compatible camelCase aliases - async def getAction(self, device: dict): # pylint: disable=invalid-name + async def getAction(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_action.""" return await self.get_action(device) - async def setStatusOn(self, device: dict): # pylint: disable=invalid-name + async def setStatusOn(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_status_on.""" return await self.set_status_on(device) - async def setStatusOff(self, device: dict): # pylint: disable=invalid-name + async def setStatusOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_status_off.""" return await self.set_status_off(device) diff --git a/src/api/hive_auth.py b/src/api/hive_auth.py index faba506..246810b 100644 --- a/src/api/hive_auth.py +++ b/src/api/hive_auth.py @@ -75,11 +75,11 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self, username: str, password: str, - device_group_key: str = None, - device_key: str = None, - device_password: str = None, - pool_region: str = None, - client_secret: str = None, + device_group_key: str | None = None, + device_key: str | None = None, + device_password: str | None = None, + pool_region: str | None = None, + client_secret: str | None = None, ): """Initialise Sync Hive Auth. @@ -115,12 +115,12 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self.file_response = {"AuthenticationResult": {"AccessToken": "file"}} self.api = HiveApi() self.data = self.api.get_login_info() - self.__pool_id = self.data.get("UPID") - self.__client_id = self.data.get("CLIID") - self.__region = self.data.get("REGION").split("_")[0] + self._pool_id = self.data.get("UPID") + self._client_id = self.data.get("CLIID") + self._region = self.data.get("REGION").split("_")[0] self.client = boto3.client( "cognito-idp", - self.__region, + self._region, aws_access_key_id="ACCESS_KEY", aws_secret_access_key="SECRET_KEY", aws_session_token="SESSION_TOKEN", @@ -151,7 +151,7 @@ def calculate_a(self): return big_a def get_password_authentication_key( - self, username: str, password: str, server_b_value: int, salt: int + self, username: str, password: str, server_b_value: str, salt: str ): """ Calculates the final hkdf based on computed S value, and computed U value and the key. @@ -162,17 +162,17 @@ def get_password_authentication_key( :param {Long integer} salt Generated salt. :return {Buffer} Computed HKDF value. """ - server_b_value = hex_to_long(server_b_value) - u_value = calculate_u(self.large_a_value, server_b_value) + server_b_long = hex_to_long(server_b_value) + u_value = calculate_u(self.large_a_value, server_b_long) if u_value == 0: raise ValueError("U cannot be zero.") - pool_id = self.__pool_id.split("_")[1] + pool_id = self._pool_id.split("_")[1] # type: ignore[union-attr] username_password = f"{pool_id}{username}:{password}" username_password_hash = hash_sha256(username_password.encode("utf-8")) - x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash)) + x_value = hex_to_long(hex_hash(pad_hex(int(salt, 16)) + username_password_hash)) g_mod_pow_xn = pow(self.g_value, x_value, self.big_n) - int_value2 = server_b_value - self.k * g_mod_pow_xn + int_value2 = server_b_long - self.k * g_mod_pow_xn s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n) hkdf = compute_hkdf( bytearray.fromhex(pad_hex(s_value)), @@ -190,7 +190,7 @@ def get_auth_params(self): auth_params.update( { "SECRET_HASH": self.get_secret_hash( - self.username, self.__client_id, self.client_secret + self.username, self._client_id, self.client_secret ) } ) @@ -287,7 +287,7 @@ def process_device_challenge(self, challenge_parameters): response.update( { "SECRET_HASH": self.get_secret_hash( - username, self.__client_id, self.client_secret + username, self._client_id, self.client_secret ) } ) @@ -310,7 +310,7 @@ def process_challenge(self, challenge_parameters: dict): ) secret_block_bytes = base64.standard_b64decode(secret_block_b64) msg = ( - bytearray(self.__pool_id.split("_")[1], "utf-8") + bytearray(self._pool_id.split("_")[1], "utf-8") + bytearray(self.user_id, "utf-8") + bytearray(secret_block_bytes) + bytearray(timestamp, "utf-8") @@ -327,7 +327,7 @@ def process_challenge(self, challenge_parameters: dict): response.update( { "SECRET_HASH": self.get_secret_hash( - self.username, self.__client_id, self.client_secret + self.username, self._client_id, self.client_secret ) } ) @@ -348,7 +348,7 @@ def login(self): # noqa: PLR0912 response = self.client.initiate_auth( AuthFlow="USER_SRP_AUTH", AuthParameters=auth_params, - ClientId=self.__client_id, + ClientId=self._client_id, ) except botocore.exceptions.ClientError as err: if err.__class__.__name__ == "UserNotFoundException": @@ -364,7 +364,7 @@ def login(self): # noqa: PLR0912 try: result = self.client.respond_to_auth_challenge( - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, ChallengeResponses=challenge_response, ) @@ -396,7 +396,7 @@ def device_login(self): if login_result.get("ChallengeName") == self.DEVICE_VERIFIER_CHALLENGE: try: initial_result = self.client.respond_to_auth_challenge( - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.DEVICE_VERIFIER_CHALLENGE, ChallengeResponses=auth_params, ) @@ -405,7 +405,7 @@ def device_login(self): initial_result["ChallengeParameters"] ) result = self.client.respond_to_auth_challenge( - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName="DEVICE_PASSWORD_VERIFIER", ChallengeResponses=device_challenge_response, ) @@ -428,7 +428,7 @@ def sms_2fa(self, entered_code: str, challenge_parameters: dict): result = None try: result = self.client.respond_to_auth_challenge( - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.SMS_MFA_CHALLENGE, Session=session, ChallengeResponses={ @@ -456,14 +456,14 @@ def sms_2fa(self, entered_code: str, challenge_parameters: dict): return result - def device_registration(self, device_name: str = None): + def device_registration(self, device_name: str | None = None): """Register Device.""" self.confirm_device(device_name) self.update_device_status() def confirm_device( self, - device_name: str = None, + device_name: str | None = None, ): """Confirm Device Hive.""" result = None @@ -515,12 +515,12 @@ def refresh_token( ): """Refresh Hive Tokens.""" result = None - auth_params = ({"REFRESH_TOKEN": token},) + auth_params: dict[str, str] = {"REFRESH_TOKEN": token} if self.device_key is not None: auth_params = {"REFRESH_TOKEN": token, "DEVICE_KEY": self.device_key} try: result = self.client.initiate_auth( - ClientId=self.__client_id, + ClientId=self._client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params, ) @@ -550,15 +550,15 @@ def forget_device(self, access_token, device_key): return result -def hex_to_long(hex_string: str): +def hex_to_long(hex_string: str) -> int: """Convert hex to long.""" return int(hex_string, 16) -def get_random(nbytes): +def get_random(nbytes: int) -> int: """Get random bytes.""" random_hex = binascii.hexlify(os.urandom(nbytes)) - return hex_to_long(random_hex) + return hex_to_long(random_hex.decode()) def hash_sha256(buf): diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 668b22f..e6ed79f 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -12,6 +12,7 @@ import os import re import socket +from typing import Any import boto3 import botocore @@ -67,11 +68,11 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self, username: str, password: str, - device_group_key: str = None, - device_key: str = None, - device_password: str = None, - pool_region: str = None, - client_secret: str = None, + device_group_key: str | None = None, + device_key: str | None = None, + device_password: str | None = None, + pool_region: str | None = None, + client_secret: str | None = None, ): """Initialise async auth.""" if pool_region is not None: @@ -80,42 +81,42 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 "(region should be passed to the boto3 client instead)" ) - self.loop = asyncio.get_event_loop() + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() self.username = username self.password = password - self.device_group_key = device_group_key - self.device_key = device_key - self.device_password = device_password - self.access_token = None + self.device_group_key: str | None = device_group_key + self.device_key: str | None = device_key + self.device_password: str | None = device_password + self.access_token: str | None = None self.api = HiveApi() self.user_id = "user_id" self.client_secret = client_secret - self.big_n = hex_to_long(N_HEX) - self.g_value = hex_to_long(G_HEX) - self.k = hex_to_long(hex_hash(pad_hex(N_HEX) + pad_hex(G_HEX))) - self.small_a_value = self.generate_random_small_a() - self.large_a_value = self.calculate_a() + self.big_n: int = hex_to_long(N_HEX) + self.g_value: int = hex_to_long(G_HEX) + self.k: int = hex_to_long(hex_hash(pad_hex(N_HEX) + pad_hex(G_HEX))) + self.small_a_value: int = self.generate_random_small_a() + self.large_a_value: int = self.calculate_a() self.use_file = bool(self.username == "use@file.com") self.file_response = {"AuthenticationResult": {"AccessToken": "file"}} # The below variables are initialized in the async_init function - self.data = None - self.__pool_id = None - self.__client_id = None - self.__region = None - self.client = None + self.data: dict | None = None + self._pool_id: str | None = None + self._client_id: str | None = None + self._region: str | None = None + self.client: Any = None async def async_init(self): """Initialise async variables.""" self.data = await self.loop.run_in_executor(None, self.api.get_login_info) - self.__pool_id = self.data.get("UPID") - self.__client_id = self.data.get("CLIID") - self.__region = self.data.get("REGION").split("_")[0] + self._pool_id = self.data.get("UPID") + self._client_id = self.data.get("CLIID") + self._region = self.data.get("REGION").split("_")[0] self.client = await self.loop.run_in_executor( None, functools.partial( boto3.client, "cognito-idp", - self.__region, + self._region, aws_access_key_id="ACCESS_KEY", aws_secret_access_key="SECRET_KEY", aws_session_token="SESSION_TOKEN", @@ -167,7 +168,7 @@ def get_password_authentication_key(self, username, password, server_b_value, sa u_value = calculate_u(self.large_a_value, server_b_value) if u_value == 0: raise ValueError("U cannot be zero.") - pool_id = self.__pool_id.split("_")[1] + pool_id = self._pool_id.split("_")[1] username_password = f"{pool_id}{username}:{password}" username_password_hash = hash_sha256(username_password.encode("utf-8")) @@ -193,7 +194,7 @@ async def get_auth_params(self, is_device_login=False): auth_params.update( { "SECRET_HASH": self.get_secret_hash( - self.username, self.__client_id, self.client_secret + self.username, self._client_id, self.client_secret ) } ) @@ -301,7 +302,7 @@ async def process_device_challenge(self, challenge_parameters): response.update( { "SECRET_HASH": self.get_secret_hash( - username, self.__client_id, self.client_secret + username, self._client_id, self.client_secret ) } ) @@ -333,7 +334,7 @@ async def process_challenge(self, challenge_parameters): ) secret_block_bytes = base64.standard_b64decode(secret_block_b64) msg = ( - bytearray(self.__pool_id.split("_")[1], "utf-8") + bytearray(self._pool_id.split("_")[1], "utf-8") + bytearray(self.user_id, "utf-8") + bytearray(secret_block_bytes) + bytearray(timestamp, "utf-8") @@ -350,7 +351,7 @@ async def process_challenge(self, challenge_parameters): response.update( { "SECRET_HASH": self.get_secret_hash( - self.username, self.__client_id, self.client_secret + self.username, self._client_id, self.client_secret ) } ) @@ -380,7 +381,7 @@ async def login(self): # noqa: PLR0912 self.client.initiate_auth, AuthFlow="USER_SRP_AUTH", AuthParameters=auth_params, - ClientId=self.__client_id, + ClientId=self._client_id, ), ) except botocore.exceptions.ClientError as err: @@ -402,7 +403,7 @@ async def login(self): # noqa: PLR0912 None, functools.partial( self.client.respond_to_auth_challenge, - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, ChallengeResponses=challenge_response, ), @@ -463,7 +464,7 @@ async def device_login(self): None, functools.partial( self.client.respond_to_auth_challenge, - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.DEVICE_VERIFIER_CHALLENGE, ChallengeResponses=auth_params, ), @@ -476,7 +477,7 @@ async def device_login(self): None, functools.partial( self.client.respond_to_auth_challenge, - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.DEVICE_PASSWORD_CHALLENGE, ChallengeResponses=device_challenge_response, ), @@ -505,7 +506,7 @@ async def sms_2fa( None, functools.partial( self.client.respond_to_auth_challenge, - ClientId=self.__client_id, + ClientId=self._client_id, ChallengeName=self.SMS_MFA_CHALLENGE, Session=session, ChallengeResponses={ @@ -537,7 +538,7 @@ async def sms_2fa( _LOGGER.debug("sms_2fa - 2FA authentication completed successfully.") return result - async def device_registration(self, device_name: str = None): + async def device_registration(self, device_name: str | None = None): """Register device with Hive.""" _LOGGER.debug("device_registration - Registering device with Hive.") await self.confirm_device(device_name) @@ -545,7 +546,7 @@ async def device_registration(self, device_name: str = None): async def confirm_device( self, - device_name: str = None, + device_name: str | None = None, ): """Confirm Hive Device.""" if self.client is None: @@ -624,7 +625,7 @@ async def refresh_token(self, token): None, functools.partial( self.client.initiate_auth, - ClientId=self.__client_id, + ClientId=self._client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params, ), diff --git a/src/device_attributes.py b/src/device_attributes.py index a659279..462d6fa 100644 --- a/src/device_attributes.py +++ b/src/device_attributes.py @@ -1,6 +1,7 @@ """Hive Device Attribute Module.""" import logging +from typing import Any from .helper.const import HIVETOHA @@ -10,7 +11,7 @@ class HiveAttributes: """Device Attributes Code.""" - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise attributes. Args: diff --git a/src/heating.py b/src/heating.py index 9b71c9a..2dd123f 100644 --- a/src/heating.py +++ b/src/heating.py @@ -5,6 +5,7 @@ from typing import Any from .helper.const import HIVETOHA, HTTP_OK +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,7 @@ class HiveHeating: session: Any heating_type = "Heating" - async def get_min_temperature(self, device: dict): + async def get_min_temperature(self, device: Device): """Get heating minimum target temperature. Args: @@ -32,7 +33,7 @@ async def get_min_temperature(self, device: dict): return self.session.data.products[device.hive_id]["props"]["minHeat"] return 5 - async def get_max_temperature(self, device: dict): + async def get_max_temperature(self, device: Device): """Get heating maximum target temperature. Args: @@ -45,7 +46,7 @@ async def get_max_temperature(self, device: dict): return self.session.data.products[device.hive_id]["props"]["maxHeat"] return 32 - async def get_current_temperature(self, device: dict): + async def get_current_temperature(self, device: Device): """Get heating current temperature. Args: @@ -118,7 +119,7 @@ async def get_current_temperature(self, device: dict): return final - async def get_target_temperature(self, device: dict): + async def get_target_temperature(self, device: Device): """Get heating target temperature. Args: @@ -156,7 +157,7 @@ async def get_target_temperature(self, device: dict): return state - async def get_mode(self, device: dict): + async def get_mode(self, device: Device): """Get heating current mode. Args: @@ -179,7 +180,7 @@ async def get_mode(self, device: dict): return final - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get heating current state. Args: @@ -205,7 +206,7 @@ async def get_state(self, device: dict): return final - async def get_current_operation(self, device: dict): + async def get_current_operation(self, device: Device): """Get heating current operation. Args: @@ -224,7 +225,7 @@ async def get_current_operation(self, device: dict): return state - async def get_boost_status(self, device: dict): + async def get_boost_status(self, device: Device): """Get heating boost current status. Args: @@ -243,7 +244,7 @@ async def get_boost_status(self, device: dict): return state - async def get_boost_time(self, device: dict): + async def get_boost_time(self, device: Device): """Get heating boost time remaining. Args: @@ -264,7 +265,7 @@ async def get_boost_time(self, device: dict): return state return None - async def get_heat_on_demand(self, device): + async def get_heat_on_demand(self, device: Device): """Get heat on demand status. Args: @@ -292,7 +293,7 @@ async def get_operation_modes(): """ return ["SCHEDULE", "MANUAL", "OFF"] - async def set_target_temperature(self, device: dict, new_temp: str): + async def set_target_temperature(self, device: Device, new_temp: str): """Set heating target temperature. Args: @@ -347,7 +348,7 @@ async def set_target_temperature(self, device: dict, new_temp: str): return final - async def set_mode(self, device: dict, new_mode: str): + async def set_mode(self, device: Device, new_mode: str): """Set heating mode. Args: @@ -399,7 +400,7 @@ async def set_mode(self, device: dict, new_mode: str): return final - async def set_boost_on(self, device: dict, mins: str, temp: float): + async def set_boost_on(self, device: Device, mins: str, temp: float): """Turn heating boost on. Args: @@ -441,7 +442,7 @@ async def set_boost_on(self, device: dict, mins: str, temp: float): return final return None - async def set_boost_off(self, device: dict): + async def set_boost_off(self, device: Device): """Turn heating boost off. Args: @@ -482,7 +483,7 @@ async def set_boost_off(self, device: dict): return final - async def set_heat_on_demand(self, device: dict, state: str): + async def set_heat_on_demand(self, device: Device, state: str): """Enable or disable Heat on Demand for a Thermostat. Args: @@ -523,7 +524,7 @@ class Climate(HiveHeating): Heating (object): Heating class """ - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise heating. Args: @@ -531,7 +532,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_climate(self, device: dict): + async def get_climate(self, device: Device): """Get heating data. Args: @@ -592,7 +593,7 @@ async def get_climate(self, device: dict): } return device - async def get_schedule_now_next_later(self, device: dict): + async def get_schedule_now_next_later(self, device: Device): """Hive get heating schedule now, next and later. Args: @@ -614,7 +615,7 @@ async def get_schedule_now_next_later(self, device: dict): return state - async def minmax_temperature(self, device: dict): + async def minmax_temperature(self, device: Device): """Min/Max Temp. Args: @@ -634,22 +635,22 @@ async def minmax_temperature(self, device: dict): return final - async def setMode(self, device: dict, new_mode: str): # pylint: disable=invalid-name + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name """Backwards-compatible alias for set_mode.""" return await self.set_mode(device, new_mode) - async def setTargetTemperature(self, device: dict, new_temp: str): # pylint: disable=invalid-name + async def setTargetTemperature(self, device: Device, new_temp: str): # pylint: disable=invalid-name """Backwards-compatible alias for set_target_temperature.""" return await self.set_target_temperature(device, new_temp) - async def setBoostOn(self, device: dict, mins: str, temp: float): # pylint: disable=invalid-name + async def setBoostOn(self, device: Device, mins: str, temp: float): # pylint: disable=invalid-name """Backwards-compatible alias for set_boost_on.""" return await self.set_boost_on(device, mins, temp) - async def setBoostOff(self, device: dict): # pylint: disable=invalid-name + async def setBoostOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_boost_off.""" return await self.set_boost_off(device) - async def getClimate(self, device: dict): # pylint: disable=invalid-name + async def getClimate(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_climate.""" return await self.get_climate(device) diff --git a/src/helper/const.py b/src/helper/const.py index 55513e6..77ded7c 100644 --- a/src/helper/const.py +++ b/src/helper/const.py @@ -1,5 +1,7 @@ """Constants for Pyhiveapi.""" +from typing import Any + from .hivedataclasses import EntityConfig SYNC_PACKAGE_NAME = "pyhiveapi" @@ -26,7 +28,7 @@ HTTP_SERVICE_UNAVAILABLE = 503 -HIVETOHA = { +HIVETOHA: dict[str, Any] = { "Attribute": {True: "Online", False: "Offline"}, "Boost": {None: "OFF", False: "OFF"}, "Heating": {False: "OFF", "ENABLED": True, "DISABLED": False}, diff --git a/src/helper/debugger.py b/src/helper/debugger.py index 48331b1..6ab6d73 100644 --- a/src/helper/debugger.py +++ b/src/helper/debugger.py @@ -12,14 +12,10 @@ def __init__(self, name, enabled): self.name = name self.enabled = enabled self.logging = logging.getLogger(__name__) - self.debug_out_folder = "" - self.debug_out_file = "" - self.debug_enabled = False - self.debug_list = [] def __enter__(self): """Set trace calls on entering debugger.""" - print("Entering Debug Decorated func") + self.logging.debug("Entering debug context for %s", self.name) sys.settrace(self.trace_calls) return self @@ -38,10 +34,6 @@ def trace_calls(self, frame, event, _arg): def trace_lines(self, frame, event, _arg): """Print out lines for function.""" - # If you want to print local variables each line - # keep the check for the event 'line' - # If you want to print local variables only on return - # check only for the 'return' event if event not in ["line", "return"]: return co = frame.f_code diff --git a/src/helper/hive_helper.py b/src/helper/hive_helper.py index 8ddb8db..e1c76f9 100644 --- a/src/helper/hive_helper.py +++ b/src/helper/hive_helper.py @@ -8,6 +8,7 @@ from typing import Any from .const import HIVE_TYPES +from .hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -35,7 +36,7 @@ def epoch_time(date_time: Any, pattern: str, action: str) -> Any: class HiveHelper: """Hive helper class.""" - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Hive Helper. Args: @@ -52,8 +53,8 @@ async def get_device_name(self, n_id: str): Returns: str: Name of device. """ - product_name = False - device_name = False + product_name: str | None = None + device_name: str | None = None try: product_name = self.session.data.products[n_id]["state"]["name"] @@ -190,7 +191,7 @@ def get_device_data(self, product: dict): return device - def convert_minutes_to_time(self, minutes_to_convert: str): + def convert_minutes_to_time(self, minutes_to_convert: int): """Convert minutes string to datetime. Args: @@ -206,7 +207,7 @@ def convert_minutes_to_time(self, minutes_to_convert: str): converted_time_string = converted_time.strftime("%H:%M") return converted_time_string - def get_schedule_nnl(self, hive_api_schedule: list): # pylint: disable=too-many-locals + def get_schedule_nnl(self, hive_api_schedule: dict): # pylint: disable=too-many-locals """Get the schedule now, next and later of a given nodes schedule. Args: @@ -302,7 +303,7 @@ def get_schedule_nnl(self, hive_api_schedule: list): # pylint: disable=too-many return schedule_now_and_next - def get_heat_on_demand_device(self, device: dict): + def get_heat_on_demand_device(self, device: Device): """Use TRV device to get the linked thermostat device. Args: diff --git a/src/hive.py b/src/hive.py index 90aa0e0..2c7b47d 100644 --- a/src/hive.py +++ b/src/hive.py @@ -4,7 +4,6 @@ import logging import sys import traceback -from os.path import expanduser from aiohttp import ClientSession @@ -19,8 +18,7 @@ _LOGGER = logging.getLogger(__name__) -debug = [] -home = expanduser("~") +debug: list[str] = [] def exception_handler(_exctype, _value, tb): @@ -41,7 +39,7 @@ def exception_handler(_exctype, _value, tb): tb_entry.line, tb_entry.locals, ) - traceback.print_exc(tb) + traceback.print_exc() sys.excepthook = exception_handler @@ -93,8 +91,8 @@ class Hive(HiveSession): def __init__( self, websession: ClientSession | None = None, - username: str = None, - password: str = None, + username: str | None = None, + password: str | None = None, ): """Generate a Hive session. diff --git a/src/hotwater.py b/src/hotwater.py index 9fc639e..6bb3f0b 100644 --- a/src/hotwater.py +++ b/src/hotwater.py @@ -4,6 +4,7 @@ from typing import Any from .helper.const import HIVETOHA, HTTP_OK +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -18,7 +19,7 @@ class HiveHotwater: session: Any hotwater_type = "Hotwater" - async def get_mode(self, device: dict): + async def get_mode(self, device: Device): """Get hotwater current mode. Args: @@ -50,7 +51,7 @@ async def get_operation_modes(): """ return ["SCHEDULE", "ON", "OFF"] - async def get_boost(self, device: dict): + async def get_boost(self, device: Device): """Get hot water current boost status. Args: @@ -71,7 +72,7 @@ async def get_boost(self, device: dict): return final - async def get_boost_time(self, device: dict): + async def get_boost_time(self, device: Device): """Get hotwater boost time remaining. Args: @@ -90,7 +91,7 @@ async def get_boost_time(self, device: dict): return state - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get hot water current state. Args: @@ -121,7 +122,7 @@ async def get_state(self, device: dict): return final - async def set_mode(self, device: dict, new_mode: str): + async def set_mode(self, device: Device, new_mode: str): """Set hot water mode. Args: @@ -150,7 +151,7 @@ async def set_mode(self, device: dict, new_mode: str): return final - async def set_boost_on(self, device: dict, mins: int): + async def set_boost_on(self, device: Device, mins: int): """Turn hot water boost on. Args: @@ -183,7 +184,7 @@ async def set_boost_on(self, device: dict, mins: int): return final - async def set_boost_off(self, device: dict): + async def set_boost_off(self, device: Device): """Turn hot water boost off. Args: @@ -222,7 +223,7 @@ class WaterHeater(HiveHotwater): Hotwater (object): Hotwater class. """ - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise water heater. Args: @@ -230,7 +231,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_water_heater(self, device: dict): + async def get_water_heater(self, device: Device): """Update water heater device. Args: @@ -281,7 +282,7 @@ async def get_water_heater(self, device: dict): device.status = device.status or {"current_operation": None} return device - async def get_schedule_now_next_later(self, device: dict): + async def get_schedule_now_next_later(self, device: Device): """Hive get hotwater schedule now, next and later. Args: @@ -302,18 +303,18 @@ async def get_schedule_now_next_later(self, device: dict): return state - async def setMode(self, device: dict, new_mode: str): # pylint: disable=invalid-name + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name """Backwards-compatible alias for set_mode.""" return await self.set_mode(device, new_mode) - async def setBoostOn(self, device: dict, mins: int): # pylint: disable=invalid-name + async def setBoostOn(self, device: Device, mins: int): # pylint: disable=invalid-name """Backwards-compatible alias for set_boost_on.""" return await self.set_boost_on(device, mins) - async def setBoostOff(self, device: dict): # pylint: disable=invalid-name + async def setBoostOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_boost_off.""" return await self.set_boost_off(device) - async def getWaterHeater(self, device: dict): # pylint: disable=invalid-name + async def getWaterHeater(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_water_heater.""" return await self.get_water_heater(device) diff --git a/src/hub.py b/src/hub.py index fca8b8f..181d8af 100644 --- a/src/hub.py +++ b/src/hub.py @@ -1,8 +1,10 @@ """Hive Hub Module.""" import logging +from typing import Any from .helper.const import HIVETOHA +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -17,7 +19,7 @@ class HiveHub: hub_type = "Hub" log_type = "Sensor" - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise hub. Args: @@ -25,7 +27,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_smoke_status(self, device: dict): + async def get_smoke_status(self, device: Device): """Get the hub smoke status. Args: @@ -46,7 +48,7 @@ async def get_smoke_status(self, device: dict): return final - async def get_dog_bark_status(self, device: dict): + async def get_dog_bark_status(self, device: Device): """Get dog bark status. Args: @@ -67,7 +69,7 @@ async def get_dog_bark_status(self, device: dict): return final - async def get_glass_break_status(self, device: dict): + async def get_glass_break_status(self, device: Device): """Get the glass detected status from the Hive hub. Args: diff --git a/src/light.py b/src/light.py index 6f15f0b..5d3108a 100644 --- a/src/light.py +++ b/src/light.py @@ -5,6 +5,7 @@ from typing import Any from .helper.const import HIVETOHA, HTTP_OK +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,7 @@ class HiveLight: session: Any light_type = "Light" - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get light current state. Args: @@ -43,7 +44,7 @@ async def get_state(self, device: dict): return final - async def get_brightness(self, device: dict): + async def get_brightness(self, device: Device): """Get light current brightness. Args: @@ -67,7 +68,7 @@ async def get_brightness(self, device: dict): return final - async def get_min_color_temp(self, device: dict): + async def get_min_color_temp(self, device: Device): """Get light minimum color temperature. Args: @@ -88,7 +89,7 @@ async def get_min_color_temp(self, device: dict): return final - async def get_max_color_temp(self, device: dict): + async def get_max_color_temp(self, device: Device): """Get light maximum color temperature. Args: @@ -109,7 +110,7 @@ async def get_max_color_temp(self, device: dict): return final - async def get_color_temp(self, device: dict): + async def get_color_temp(self, device: Device): """Get light current color temperature. Args: @@ -130,7 +131,7 @@ async def get_color_temp(self, device: dict): return final - async def get_color(self, device: dict): + async def get_color(self, device: Device): """Get light current colour. Args: @@ -157,7 +158,7 @@ async def get_color(self, device: dict): return final - async def get_color_mode(self, device: dict): + async def get_color_mode(self, device: Device): """Get Colour Mode. Args: @@ -176,7 +177,7 @@ async def get_color_mode(self, device: dict): return state - async def set_status_off(self, device: dict): + async def set_status_off(self, device: Device): """Set light to turn off. Args: @@ -224,7 +225,7 @@ async def set_status_off(self, device: dict): return final - async def set_status_on(self, device: dict): + async def set_status_on(self, device: Device): """Set light to turn on. Args: @@ -272,7 +273,7 @@ async def set_status_on(self, device: dict): return final - async def set_brightness(self, device: dict, n_brightness: int): + async def set_brightness(self, device: Device, n_brightness: int): """Set brightness of the light. Args: @@ -307,7 +308,7 @@ async def set_brightness(self, device: dict, n_brightness: int): return final - async def set_color_temp(self, device: dict, color_temp: int): + async def set_color_temp(self, device: Device, color_temp: int): """Set light to turn on. Args: @@ -351,7 +352,7 @@ async def set_color_temp(self, device: dict, color_temp: int): return final - async def set_color(self, device: dict, new_color: list): + async def set_color(self, device: Device, new_color: list): """Set light to turn on. Args: @@ -395,7 +396,7 @@ class Light(HiveLight): HiveLight (object): HiveLight Code. """ - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise light. Args: @@ -403,7 +404,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_light(self, device: dict): + async def get_light(self, device: Device): """Get light data. Args: @@ -464,7 +465,7 @@ async def get_light(self, device: dict): async def turn_on( self, - device: dict, + device: Device, brightness: int | None, color_temp: int | None, color: list | None, @@ -489,7 +490,7 @@ async def turn_on( return await self.set_status_on(device) - async def turn_off(self, device: dict): + async def turn_off(self, device: Device): """Set light to turn off. Args: @@ -500,14 +501,16 @@ async def turn_off(self, device: dict): """ return await self.set_status_off(device) - async def turnOn(self, device: dict, brightness: int, color_temp: int, color: list): # pylint: disable=invalid-name + async def turnOn( + self, device: Device, brightness: int, color_temp: int, color: list + ): # pylint: disable=invalid-name """Backwards-compatible alias for turn_on.""" return await self.turn_on(device, brightness, color_temp, color) - async def turnOff(self, device: dict): # pylint: disable=invalid-name + async def turnOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for turn_off.""" return await self.turn_off(device) - async def getLight(self, device: dict): # pylint: disable=invalid-name + async def getLight(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_light.""" return await self.get_light(device) diff --git a/src/plug.py b/src/plug.py index 15bf313..7453713 100644 --- a/src/plug.py +++ b/src/plug.py @@ -4,6 +4,7 @@ from typing import Any from .helper.const import HIVETOHA, HTTP_OK +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -18,7 +19,7 @@ class HiveSmartPlug: session: Any plug_type = "Switch" - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get smart plug state. Args: @@ -38,7 +39,7 @@ async def get_state(self, device: dict): return state - async def get_power_usage(self, device: dict): + async def get_power_usage(self, device: Device): """Get smart plug current power usage. Args: @@ -57,7 +58,7 @@ async def get_power_usage(self, device: dict): return state - async def set_status_on(self, device: dict): + async def set_status_on(self, device: Device): """Set smart plug to turn on. Args: @@ -84,7 +85,7 @@ async def set_status_on(self, device: dict): return final - async def set_status_off(self, device: dict): + async def set_status_off(self, device: Device): """Set smart plug to turn off. Args: @@ -119,7 +120,7 @@ class Switch(HiveSmartPlug): SmartPlug (Class): Initialises the Smartplug Class. """ - def __init__(self, session: object): + def __init__(self, session: Any): """Initialise switch. Args: @@ -127,7 +128,7 @@ def __init__(self, session: object): """ self.session = session - async def get_switch(self, device: dict): + async def get_switch(self, device: Device): """Home assistant wrapper to get switch device. Args: @@ -179,7 +180,7 @@ async def get_switch(self, device: dict): device.status = device.status or {"state": None} return device - async def get_switch_state(self, device: dict): + async def get_switch_state(self, device: Device): """Home Assistant wrapper to get updated switch state. Args: @@ -192,7 +193,7 @@ async def get_switch_state(self, device: dict): return await self.session.heating.get_heat_on_demand(device) return await self.get_state(device) - async def turn_on(self, device: dict): + async def turn_on(self, device: Device): """Home Assisatnt wrapper for turning switch on. Args: @@ -205,7 +206,7 @@ async def turn_on(self, device: dict): return await self.session.heating.set_heat_on_demand(device, "ENABLED") return await self.set_status_on(device) - async def turn_off(self, device: dict): + async def turn_off(self, device: Device): """Home Assisatnt wrapper for turning switch off. Args: @@ -218,14 +219,14 @@ async def turn_off(self, device: dict): return await self.session.heating.set_heat_on_demand(device, "DISABLED") return await self.set_status_off(device) - async def turnOn(self, device: dict): # pylint: disable=invalid-name + async def turnOn(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for turn_on.""" return await self.turn_on(device) - async def turnOff(self, device: dict): # pylint: disable=invalid-name + async def turnOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for turn_off.""" return await self.turn_off(device) - async def getSwitch(self, device: dict): # pylint: disable=invalid-name + async def getSwitch(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_switch.""" return await self.get_switch(device) diff --git a/src/sensor.py b/src/sensor.py index 0ef830d..1f069fd 100644 --- a/src/sensor.py +++ b/src/sensor.py @@ -4,6 +4,7 @@ from typing import Any from .helper.const import HIVE_TYPES, HIVETOHA, sensor_commands +from .helper.hivedataclasses import Device _LOGGER = logging.getLogger(__name__) @@ -14,7 +15,7 @@ class HiveSensor: session: Any sensor_type = "Sensor" - async def get_state(self, device: dict): + async def get_state(self, device: Device): """Get sensor state. Args: @@ -38,7 +39,7 @@ async def get_state(self, device: dict): return final - async def online(self, device: dict): + async def online(self, device: Device): """Get the online status of the Hive hub. Args: @@ -67,7 +68,7 @@ class Sensor(HiveSensor): HiveSensor (object): Hive sensor code. """ - def __init__(self, session: object = None): + def __init__(self, session: Any = None): """Initialise sensor. Args: @@ -75,7 +76,7 @@ def __init__(self, session: object = None): """ self.session = session - async def get_sensor(self, device: dict): + async def get_sensor(self, device: Device): """Gets updated sensor data. Args: @@ -122,9 +123,9 @@ async def get_sensor(self, device: dict): ): code = sensor_commands.get( device.hive_type, - sensor_commands.get(getattr(device, "custom", None)), + sensor_commands.get(getattr(device, "custom", "")), ) - device.status = {"state": await code(self, device)} + device.status = {"state": await code(self, device)} # type: ignore[misc] props = data.get("props") or {} props["online"] = online device.device_data = props @@ -153,6 +154,6 @@ async def get_sensor(self, device: dict): device.status = device.status or {"state": None} return device - async def getSensor(self, device: dict): # pylint: disable=invalid-name + async def getSensor(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_sensor.""" return await self.get_sensor(device) diff --git a/src/session.py b/src/session.py index e51c625..1b13ca5 100644 --- a/src/session.py +++ b/src/session.py @@ -8,10 +8,11 @@ import time from datetime import datetime, timedelta from pathlib import Path +from typing import Any from aiohttp import ClientSession from aiohttp.web import HTTPException -from apyhiveapi import API, Auth +from apyhiveapi import API, Auth # type: ignore[import-not-found] from .device_attributes import HiveAttributes from .helper.const import DEVICES, HIVE_TYPES, PRODUCTS @@ -75,7 +76,7 @@ def __init__( self._refresh_lock = asyncio.Lock() self.tokens = SessionTokens() self.config = SessionConfig(username=username) - self.data = Map( + self.data: Any = Map( { "products": {}, "devices": {}, @@ -84,13 +85,24 @@ def __init__( "minMax": {}, } ) - self.entity_cache = {} - self.device_list = {} + self.entity_cache: dict[str, Device] = {} + self.device_list: dict[str, list[Device]] = {} self.hub_id = None self._last_poll_slow = False self._slow_poll_threshold = 3 self._refresh_threshold = 0.90 - self._update_task = None + self._update_task: asyncio.Task | None = None + + async def close(self) -> None: + """Close the underlying aiohttp ClientSession.""" + if not self.api.websession.closed: + await self.api.websession.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, *_) -> None: + await self.close() @staticmethod def _entity_cache_key(device) -> str: @@ -164,7 +176,10 @@ async def _retry_with_backoff( raise except Exception as err: # pylint: disable=broad-except last_err = err - raise (reraise_as or type(last_err)) from last_err + exc_type = reraise_as or ( + type(last_err) if last_err is not None else RuntimeError + ) + raise exc_type() from last_err # pylint: disable=broad-exception-raised def open_file(self, file: str) -> dict: """Open a JSON fixture file from the package data directory. @@ -177,7 +192,7 @@ def open_file(self, file: str) -> dict: """ return json.loads((_DATA_DIR / file).read_text(encoding="utf-8")) - def add_list(self, entity_type: str, data: dict, **kwargs) -> Device: + def add_list(self, entity_type: str, data: dict, **kwargs) -> Device | None: """Add entity to the device list. Args: @@ -259,12 +274,12 @@ async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): Returns: dict: Parsed dictionary of tokens """ - data = {} + data: dict = {} _LOGGER.debug( "update_tokens - Input tokens: %s", self.helper.sanitize_payload(tokens) ) if "AuthenticationResult" in tokens: - data = tokens.get("AuthenticationResult") + data = tokens.get("AuthenticationResult") or {} self.tokens.token_data.update({"token": data["IdToken"]}) if "RefreshToken" in data: self.tokens.token_data.update({"refreshToken": data["RefreshToken"]}) @@ -564,7 +579,7 @@ async def hive_refresh_tokens(self, force_refresh: bool = False): return result - async def update_data(self, _device: dict): + async def update_data(self, _device: Device): """Get latest data for Hive nodes - rate limiting. Args: @@ -718,7 +733,7 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- return get_nodes_successful - async def start_session(self, config: dict = None): + async def start_session(self, config: dict | None = None): """Setup the Hive platform. Args: @@ -941,11 +956,11 @@ def deviceList(self): # pylint: disable=invalid-name """Backwards-compatible alias for device_list.""" return self.device_list - async def startSession(self, config: dict = None): # pylint: disable=invalid-name + async def startSession(self, config: dict | None = None): # pylint: disable=invalid-name """Backwards-compatible alias for start_session.""" return await self.start_session(config) - async def updateData(self, device: dict): # pylint: disable=invalid-name + async def updateData(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for update_data.""" return await self.update_data(device) diff --git a/tests/common.py b/tests/common.py deleted file mode 100644 index e98ce8a..0000000 --- a/tests/common.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Mock services for tests.""" - -# pylint: skip-file - - -class MockConfig: - """Mock config for tests.""" - - -class MockDevice: - """Mock Device for tests.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0f833d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Shared pytest fixtures.""" + +import pytest +from apyhiveapi import Hive + + +@pytest.fixture +async def file_session(): + """Hive session loaded from the bundled data.json fixture.""" + async with Hive(username="use@file.com", password="") as hive: + await hive.start_session({}) + yield hive diff --git a/tests/test_hub.py b/tests/test_hub.py index 6424d0a..91c210d 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -15,10 +15,12 @@ def test_hub_smoke(): @pytest.mark.asyncio async def test_force_update_polls_when_idle(): """force_update() calls _poll_devices and returns its result when no poll is running.""" - hive = Hive(username="test@example.com", password="pass") - hive._poll_devices = AsyncMock(return_value=True) - - result = await hive.force_update() + async with Hive( + username="test@example.com", + password="pass", # pragma: allowlist secret + ) as hive: + hive._poll_devices = AsyncMock(return_value=True) + result = await hive.force_update() assert result is True hive._poll_devices.assert_called_once() @@ -27,11 +29,14 @@ async def test_force_update_polls_when_idle(): @pytest.mark.asyncio async def test_force_update_skips_when_locked(): """force_update() returns False without polling when the update lock is already held.""" - hive = Hive(username="test@example.com", password="pass") - hive._poll_devices = AsyncMock(return_value=True) - - async with hive.update_lock: - result = await hive.force_update() + async with Hive( + username="test@example.com", + password="pass", # pragma: allowlist secret + ) as hive: + hive._poll_devices = AsyncMock(return_value=True) + + async with hive.update_lock: + result = await hive.force_update() assert result is False hive._poll_devices.assert_not_called() diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..3de0c5a --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,181 @@ +"""File-mode integration tests and unit tests for session utilities.""" + +# pylint: disable=redefined-outer-name + +from unittest.mock import AsyncMock, patch + +import pytest +from apyhiveapi import Hive +from apyhiveapi.helper.hive_helper import HiveHelper + + +class TestFileSession: + """Integration tests using the bundled data.json file fixture.""" + + async def test_start_session_returns_devices(self, file_session): + """start_session populates device_list with entries from the fixture.""" + dl = file_session.device_list + assert dl, "device_list should not be empty" + + async def test_climate_devices_present(self, file_session): + """Fixture contains heating products — climate entries should exist.""" + assert file_session.device_list.get("climate"), "expected climate devices" + + async def test_light_devices_present(self, file_session): + """Fixture contains light products — light entries should exist.""" + assert file_session.device_list.get("light"), "expected light devices" + + async def test_switch_devices_present(self, file_session): + """Fixture contains switch products — switch entries should exist.""" + assert file_session.device_list.get("switch"), "expected switch devices" + + async def test_water_heater_devices_present(self, file_session): + """Fixture contains hot water products — water_heater entries should exist.""" + assert file_session.device_list.get("water_heater"), "expected water_heater" + + async def test_binary_sensor_devices_present(self, file_session): + """Fixture contains sensor products — binary_sensor entries should exist.""" + assert file_session.device_list.get("binary_sensor"), "expected binary_sensor" + + async def test_get_climate_returns_status(self, file_session): + """get_climate populates device.status with required heating fields.""" + device = file_session.device_list["climate"][0] + updated = await file_session.heating.get_climate(device) + assert updated.status is not None + assert "current_temperature" in updated.status + assert "target_temperature" in updated.status + assert "mode" in updated.status + + async def test_get_light_returns_status(self, file_session): + """get_light populates device.status with required light fields.""" + device = file_session.device_list["light"][0] + updated = await file_session.light.get_light(device) + assert updated.status is not None + assert "state" in updated.status + + async def test_device_has_hive_id(self, file_session): + """Every device in device_list has a hive_id.""" + for entity_type, devices in file_session.device_list.items(): + for device in devices: + assert device.hive_id, f"{entity_type} device missing hive_id" + + async def test_update_data_returns_bool(self, file_session): + """update_data returns a bool without raising.""" + device = file_session.device_list["climate"][0] + result = await file_session.update_data(device) + assert isinstance(result, bool) + + +class TestGetScheduleNnl: + """Unit tests for HiveHelper.get_schedule_nnl — pure schedule parsing.""" + + def _make_schedule(self, _offset_minutes: int = 0) -> dict: + """Build a minimal weekly schedule with three slots per day.""" + day_names = ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + slots = [ + {"start": 0, "value": {"target": 17.0}}, + {"start": 420, "value": {"target": 20.0}}, + {"start": 1320, "value": {"target": 18.0}}, + ] + return {day: list(slots) for day in day_names} + + def test_returns_now_next_later(self): + """get_schedule_nnl returns a dict with now, next, and later keys.""" + session = object.__new__(Hive) + helper = HiveHelper(session) + result = helper.get_schedule_nnl(self._make_schedule()) + assert set(result.keys()) == {"now", "next", "later"} + + def test_now_has_datetime_fields(self): + """The 'now' slot contains Start_DateTime and End_DateTime.""" + session = object.__new__(Hive) + helper = HiveHelper(session) + result = helper.get_schedule_nnl(self._make_schedule()) + assert "Start_DateTime" in result["now"] + assert "End_DateTime" in result["now"] + + def test_empty_schedule_returns_empty(self): + """An empty schedule (all days have no slots) returns an empty dict.""" + session = object.__new__(Hive) + helper = HiveHelper(session) + empty = { + day: [] + for day in ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + } + result = helper.get_schedule_nnl(empty) + assert result == {} + + +EXPECTED_ATTEMPTS = 2 + + +class TestTokenRefreshRetry: + """Tests for the retry/backoff path in session._retry_with_backoff.""" + + async def test_succeeds_on_first_attempt(self): + """A coroutine that succeeds immediately is called exactly once.""" + async with Hive( + username="test@example.com", + password="pass", # pragma: allowlist secret + ) as hive: + calls = 0 + + async def coro(): + nonlocal calls + calls += 1 + return "ok" + + result = await hive._retry_with_backoff(coro) # pylint: disable=protected-access + assert result == "ok" + assert calls == 1 + + async def test_retries_on_failure_then_succeeds(self): + """A coroutine that fails once is retried and its success is returned.""" + async with Hive( + username="test@example.com", + password="pass", # pragma: allowlist secret + ) as hive: + attempts = [] + + async def coro(): + attempts.append(1) + if len(attempts) < EXPECTED_ATTEMPTS: + raise Exception("transient") # pylint: disable=broad-exception-raised + return "recovered" + + with patch("asyncio.sleep", new=AsyncMock()): + result = await hive._retry_with_backoff(coro, delays=(0, 0, 0)) # pylint: disable=protected-access + + assert result == "recovered" + assert len(attempts) == EXPECTED_ATTEMPTS + + async def test_raises_after_all_retries_exhausted(self): + """A coroutine that always fails raises after all retries are exhausted.""" + async with Hive( + username="test@example.com", + password="pass", # pragma: allowlist secret + ) as hive: + + async def always_fails(): + raise Exception("permanent failure") # pylint: disable=broad-exception-raised + + with patch("asyncio.sleep", new=AsyncMock()): + with pytest.raises(Exception) as exc_info: + await hive._retry_with_backoff(always_fails, delays=(0, 0)) # pylint: disable=protected-access + assert "permanent failure" in str(exc_info.value.__cause__)